Visitar URL original
Raise a failed task's error even if the scope's block catches it by mtsolmn · Pull Request #112 · google/duet · GitHub
Skip to content

Raise a failed task's error even if the scope's block catches it - #112

Open
mtsolmn wants to merge 1 commit into
google:mainfrom
mtsolmn:fix-scope-reraise-swallowed-task-error
Open

mtsolmn wants to merge 1 commit into
google:mainfrom
mtsolmn:fix-scope-reraise-swallowed-task-error

Conversation

@mtsolmn

@mtsolmn mtsolmn commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Problem

When a task spawned in a scope fails, its error is thrown into the scope's main task at whatever await the block is currently suspended in. This is the original error (e.g. a ValueError), so a broad except Exception: in the block can catch it. If the block then exits normally, new_scope waits for the remaining tasks and returns normally, and the failure is lost without a trace.

Reproduction

import duet

async def fail():
    await duet.completed_future(None)
    raise ValueError("task failed")

async def main():
    async with duet.new_scope() as scope:
        scope.spawn(fail)
        try:
            await duet.sleep(1)
        except Exception as e:  # e.g. a retry/log-and-continue loop
            print("caught", repr(e))
    print("scope exited normally")  # main: reached, the ValueError is lost

duet.run(main)

Fix

  • Scope._run records the first error of a failed spawned task in the Scope.
  • If the block exits normally, new_scope raises that error when the block exits, before waiting for the remaining tasks, so they are interrupted with ScopeExit as for any other error.
  • Only Exceptions are recorded: a BaseException such as KeyboardInterrupt that the block deliberately handled is not raised again.
  • If the block raises an error of its own (e.g. it catches the task's error and raises another), that error is raised as before.

This gives the structured-concurrency guarantee that a failure of a spawned task is never silently lost, like trio nurseries.

Alternative considered: delivering background failures to the main task wrapped in a BaseException that new_scope unwraps and re-raises. That protects against except Exception: in the block, but not against except BaseException:, and it brings back the question #109 removed of which (nested) scope should unwrap which wrapper. Recording the failure in the scope needs no wrapping, and the error is raised at the end of the scope however the block handled it.

Behaviour change: a block that deliberately catches a spawned task's error and carries on now still raises that error when the block exits. To handle a task's errors, catch them inside the task instead. I didn't find any such use in duet itself (pmap_aiter forwards errors to the consumer through its collector, so it is unaffected).

Tests

  • New: TestScope.test_task_error_raised_if_caught_in_block[future] and [nested_scope]: the block catches the task's error with except Exception: around a plain await and around duet.sleep. Both fail on main (DID NOT RAISE Fail) and pass here; the test also checks that the raised error is the same object the block caught.
  • New: TestScope.test_block_error_takes_precedence_over_caught_task_error: the block catches the task's error and raises a ValueError from it; the ValueError is raised. Passes on main too; it pins down that the block's own error still wins.
  • New: TestScope.test_caught_task_error_interrupts_other_tasks: after the block catches the task's error, the scope's other task (in duet.sleep(1)) gets ScopeExit and the scope raises the error right away.
  • New: TestScope.test_caught_keyboard_interrupt_from_task_not_reraised: a KeyboardInterrupt from a task that the block handles with except KeyboardInterrupt: is not raised again.
  • The full suite passes, also when merged together with Re-register remaining ready tasks if a root task raises in tick #103–Don't await tasks when a scope is closed by GeneratorExit #106 and Don't hang when a scope is cancelled from its own block #111.

When a task spawned in a scope fails, its error is thrown into the scope's
main task, wherever the block is currently awaiting. If the block catches
it, e.g. with a broad `except Exception:` around an await, and then exits
normally, new_scope finished the remaining tasks and returned normally,
so the failure was silently lost:

    async with duet.new_scope() as scope:
        scope.spawn(failing_task)
        try:
            await something()
        except Exception:
            log_and_continue()
    # Previously reached without an error.

Record the first error of a failed task in the Scope. If the block exits
normally, raise it when the block exits, before waiting for the remaining
tasks, so that they are interrupted with ScopeExit as for any other
error. If the block raises an error of its own, that error is raised as
before.

Only Exceptions are recorded. A BaseException such as KeyboardInterrupt
that the block deliberately handled is not raised again.

This matches the structured-concurrency guarantee that a failure of a
spawned task is never lost. Behaviour change: a block that deliberately
catches a spawned task's error and carries on now still raises that error
at the end of the block; to handle a task's errors, catch them inside the
task.
@mtsolmn
mtsolmn force-pushed the fix-scope-reraise-swallowed-task-error branch from fb40378 to 9d5a8a9 Compare November 10, 2026 08:57

@mangeshraut712 mangeshraut712 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This closes a real structured-concurrency hole, and the semantics now match asyncio's TaskGroup and trio nurseries: a child failure can't vanish because the parent block swallowed it. The precedence test (the block's own error wins) and the KeyboardInterrupt carve-out are well chosen. One interaction to check:

Comment thread duet/api.py Resolved
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants