Repository navigation
Conversation
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.
mtsolmn
force-pushed
the
fix-scope-cancel-from-own-block
branch
from
November 9, 2026 23:14
1e804ae to
774a5e5
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:The same happens with
scope.cancel()followed by anyawaitin the block, includingawait duet.sleep(1).cancel()interrupts the scope's main task, which here is the task that is currently running.Task.interruptstores 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._advancereplaces 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 ofduet.sleep(1).Cancelling from another task, which is what the existing
test_canceldoes, 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, asTask.interruptwould 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 (hereCancelledError). The wake-up logic moves into a smallTask._wake()helper used by both places.Tests
Since the failure mode is a hang, the new tests run
duet.runin 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.TestScope.test_cancel_from_own_block:scope.cancel()and thenawaita future that never completes. RaisesCancelledErrorand the awaited future is cancelled.TestScope.test_cancel_from_own_block_in_sleep: the same withawait duet.sleep(10).TestScope.test_cancel_from_own_block_interrupts_tasks: the example above. The scope raisesCancelledErrorand the background task getsScopeExit.All three hang on
mainand 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
mainthe first hangs, and the second already behaves the same way), but I left them for a separate PR since the fixes changenew_scopesemantics:scope.cancel()and then exits without awaiting anything (and nothing was spawned), theCancelledErroris raised at the task's nextawaitafter the scope rather than at the scope boundary.scope.cancel()and then raises an error in the same step while it has spawned tasks, the pendingCancelledErroris 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 byCancelledError, andmain_task.interruptibleis leftFalse, since it is reset after theawaitrather than in afinally.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).