Visitar URL original
Don't hang when a scope is cancelled from its own block by mtsolmn · Pull Request #111 · google/duet · GitHub
Skip to content

Don't hang when a scope is cancelled from its own block - #111

Open
mtsolmn wants to merge 3 commits into
google:mainfrom
mtsolmn:fix-scope-cancel-from-own-block
Open

mtsolmn wants to merge 3 commits into
google:mainfrom
mtsolmn:fix-scope-cancel-from-own-block

Conversation

@mtsolmn

@mtsolmn mtsolmn commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

This PR is stacked on PR #104. This branch contains #104's two commits. Only the last commit, "Don't hang when a scope is cancelled from its own block", belongs to this PR. I'll rebase onto main once #104 is merged.

Problem

Calling scope.cancel() from the scope's own block hangs forever, e.g. when a block has what it needs and wants to stop its other tasks:

import duet

async def task():
    await duet.AwaitableFuture()  # Waits until interrupted.

async def main():
    async with duet.new_scope() as scope:
        scope.spawn(task)
        await duet.completed_future(None)
        scope.cancel()  # main: hangs, the task is never interrupted

duet.run(main)

The same happens with scope.cancel() followed by any await in the block, including await duet.sleep(1).

cancel() interrupts the scope's main task, which here is the task that is currently running. Task.interrupt stores the interrupt and sets the task's ready future, but that is still the ready future of the previous step, which is already done. When the task then yields its next future, Task._advance replaces the ready future with a new one that only fires once that future completes. So the interrupt is not delivered until then, which may be never. And since a task keeps at most one pending interrupt, later interrupts are dropped as well, e.g. the timeout of duet.sleep(1).

Cancelling from another task, which is what the existing test_cancel does, works.

Fix

In Task._advance, after setting up the ready future for the newly yielded future, check whether an interrupt arrived while the task was running. If so, wake the new step right away, as Task.interrupt would have done if the task had been waiting: set its ready future and cancel the future it awaits. The next tick then delivers the interrupt (here CancelledError). The wake-up logic moves into a small Task._wake() helper used by both places.

Tests

Since the failure mode is a hang, the new tests run duet.run in a thread (run_with_hang_guard) and fail if it is still running after 5 s. A duet timeout can't serve as the guard, because the bug also makes the task ignore later interrupts, timeouts included.

  • New: TestScope.test_cancel_from_own_block: scope.cancel() and then await a future that never completes. Raises CancelledError and the awaited future is cancelled.
  • New: TestScope.test_cancel_from_own_block_in_sleep: the same with await duet.sleep(10).
  • New: TestScope.test_cancel_from_own_block_interrupts_tasks: the example above. The scope raises CancelledError and the background task gets ScopeExit.

All three hang on main and pass here, in a few milliseconds each.

Not addressed here

Reviewing this turned up two related, pre-existing issues with an interrupt that is still pending when the block finishes. Neither is made worse by this change (on main the first hangs, and the second already behaves the same way), but I left them for a separate PR since the fixes change new_scope semantics:

  • If the block calls scope.cancel() and then exits without awaiting anything (and nothing was spawned), the CancelledError is raised at the task's next await after the scope rather than at the scope boundary.
  • If the block calls scope.cancel() and then raises an error in the same step while it has spawned tasks, the pending CancelledError is thrown into the scope's cleanup, which is meant to be uninterruptible. The scope then exits before its tasks finish, the original error is replaced by CancelledError, and main_task.interruptible is left False, since it is reset after the await rather than in a finally.

Happy to follow up on these if you agree on the direction (e.g. raise a pending interrupt when the block exits, and discard it, unless it is a KeyboardInterrupt, before the uninterruptible cleanup).

When SIGINT arrives while the scheduler is waiting between ticks, the next
tick delivers a KeyboardInterrupt to a task. It picked an arbitrary task,
next(iter(self.active_tasks)), and cleared the pending flag. But
Task.interrupt silently ignores tasks that are not interruptible or already
have an interrupt pending. While a scope cleans up after an error, its main
task is uninterruptible and its other tasks already have interrupts
pending, so a Ctrl-C in that window was dropped and the program kept
running.

Make Task.interrupt return whether it accepted the interrupt, and in tick
offer the KeyboardInterrupt to the active tasks until one accepts it. If
none does, keep the SIGINT pending and try again on the next tick.

A Ctrl-C could still be lost in two more ways, now also fixed:
- A task that fails with KeyboardInterrupt reports it to its main task,
  which drops it if it can't take an interrupt right now (e.g. it is
  finishing its tasks after another error). Re-post the SIGINT instead, so
  the next tick delivers it to a task that can take it.
- If the run finishes while a SIGINT is still pending, duet.run now raises
  KeyboardInterrupt instead of returning normally.
- Only clear the pending SIGINT flag in tick when a task takes the
  KeyboardInterrupt.
- Rename accepted to interrupted.
- Clear the pending SIGINT flag when duet.run raises KeyboardInterrupt for
  it, so it is not delivered twice (Scheduler.take_interrupt replaces the
  interrupt_pending property).
Calling scope.cancel() from the scope's own block, e.g.

    async with duet.new_scope() as scope:
        scope.spawn(task)
        ...
        scope.cancel()  # Got what we need, stop the other tasks.

hung forever. cancel() interrupts the scope's main task, which here is the
task that is currently running. Task.interrupt stores the interrupt and
sets the task's ready future, but that is still the ready future of the
previous step, which is already done; when the task then yields its next
future, _advance replaces the ready future with a new one that only fires
when that future completes. So the interrupt was not delivered until then,
and since a task keeps at most one pending interrupt, later interrupts,
such as the timeout of a duet.sleep, were dropped as well.

This has been the case since Scope.cancel() was added; cancelling from
another task, as the existing tests do, works.

In _advance, if an interrupt arrived while the task was running, wake the
new step right away, as Task.interrupt would have done had the task been
waiting: set its ready future and cancel the future it awaits. The next
tick then delivers the interrupt. The wake-up logic moves into a small
Task._wake() helper shared by both places.
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.

1 participant