Skip to content

Tear down blocked task chains iteratively - #747

Open
rewitt94 wants to merge 2 commits into
pydantic:mainfrom
rewitt94:fix/iterative-task-cancellation
Open

Tear down blocked task chains iteratively#747
rewitt94 wants to merge 2 commits into
pydantic:mainfrom
rewitt94:fix/iterative-task-cancellation

Conversation

@rewitt94

@rewitt94 rewitt94 commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Before: Cancelling nested blocked tasks recursed with cancel_task calling itself, overflowing the stack, aborting workers.

Now: Cancellation drains a heap worklist iteratively; constant stack, no abort.

Same class as #649 and #704


Summary by cubic

Tears down blocked task chains and nested gathers iteratively to avoid stack overflows and orphaned tasks. Previously, cancellation recursed, overflowed on deep chains, and skipped tasks owned by nested gathers, causing lingering work and “Scheduler::get_task_mut: task not found” panics.

  • Replaces recursion in Scheduler::cancel_task with a SmallVec worklist and cancel_one; constant stack, and safe to drop the parent before queued children due to inc_refs.
  • Adds queue_gather_tasks to walk nested gathers and enqueue spawned-task children; leaves external futures anchored.
  • Adds tests for a 20,000-deep chain on a 2 MiB stack and nested-gather cancellation; updates docs noting a failing gather cancels siblings (differs from CPython).
  • No public API changes; runtime now cancels nested-gather–spawned tasks and avoids aborts/panics.

Written for commit dc501ca. Summary will update on new commits.

Review in cubic

@codecov

codecov Bot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.71429% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/monty/src/bytecode/vm/scheduler.rs 85.71% 1 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 2 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread crates/monty/src/bytecode/vm/scheduler.rs Outdated
@codspeed-hq

codspeed-hq Bot commented Aug 14, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 4.43%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

❌ 3 regressed benchmarks
✅ 33 untouched benchmarks
⏩ 16 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
loop_mod_13__monty 397.7 µs 416.7 µs -4.55%
loop_mod_13_limits__monty 399.5 µs 418.4 µs -4.51%
list_comp__monty 282.6 µs 295.1 µs -4.23%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing rewitt94:fix/iterative-task-cancellation (dc501ca) with main (edeb82a)

Open in CodSpeed

Footnotes

  1. 16 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

rewitt94 added a commit to rewitt94/monty that referenced this pull request Aug 15, 2026
`Scheduler::cancel_one` only mapped a blocked gather's `pending_children`
back to tasks through `coroutine_to_task`, so a child that was itself a
`GatherFuture` was skipped: the tasks that inner gather had spawned stayed
in `Scheduler::tasks`, kept running, and on completion delivered a result
to the task that had just been cancelled — panicking with
`Scheduler::get_task_mut: task not found`.

The walk now follows nested gathers through `queue_gather_tasks`, using a
second worklist so neither deep task chains nor deep gather nesting
recurses. It runs before the task is dropped, while the task still holds
the `Blocked` inc_ref anchoring the tree.

Reported by cubic on pydantic#747.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Scheduler::cancel_task` recursed into the tasks spawned by the gather
each task was blocked on, so a chain built through coroutines — which
costs no native stack to build, since the commit walk unwinds at every
coroutine and leaves the depth in `Scheduler::tasks` — turned that stored
depth back into frames on teardown and aborted the process.

It now drains an explicit worklist, with the per-task walk moved into
`cancel_one`. A task is dropped ahead of the children it queues, which is
sound because each child owns an inc_ref on that same gather.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1 issue found across 4 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="crates/monty/src/bytecode/vm/scheduler.rs">

<violation number="1" location="crates/monty/src/bytecode/vm/scheduler.rs:454">
P2: When a very large nested-gather tree is cancelled, `queue_gather_tasks` traverses every node without polling `ResourceTracker`. Teardown can therefore exceed the configured time limit and monopolize a worker; propagate a time-check failure or otherwise bound this traversal.

(Based on your team's feedback about native-loop time guards.)</violation>
</file>

Tip: cubic used a learning from your PR history. Let your coding agent read cubic learnings directly with the cubic MCP.

Re-trigger cubic

// Gathers nest as a tree — a gather may only be awaited once, so the
// walk cannot revisit a node and terminates.
let mut gathers: SmallVec<[HeapId; 4]> = smallvec![root];
while let Some(gather_id) = gathers.pop() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: When a very large nested-gather tree is cancelled, queue_gather_tasks traverses every node without polling ResourceTracker. Teardown can therefore exceed the configured time limit and monopolize a worker; propagate a time-check failure or otherwise bound this traversal.

(Based on your team's feedback about native-loop time guards.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/monty/src/bytecode/vm/scheduler.rs, line 454:

<comment>When a very large nested-gather tree is cancelled, `queue_gather_tasks` traverses every node without polling `ResourceTracker`. Teardown can therefore exceed the configured time limit and monopolize a worker; propagate a time-check failure or otherwise bound this traversal.

(Based on your team's feedback about native-loop time guards.) </comment>

<file context>
@@ -427,26 +424,51 @@ impl Scheduler {
+        // Gathers nest as a tree — a gather may only be awaited once, so the
+        // walk cannot revisit a node and terminates.
+        let mut gathers: SmallVec<[HeapId; 4]> = smallvec![root];
+        while let Some(gather_id) = gathers.pop() {
+            // Coroutine and external children land here too; only gathers have
+            // children of their own to walk.
</file context>

@rewitt94 rewitt94 Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Currently, Monty polls the ResourceTracker only in fallible native loops that produce a value and never in any teardown path. So there is no precedent for this.
Timeout enforcement happens at the end of the turn still.

`Scheduler::cancel_one` only mapped a blocked gather's `pending_children`
back to tasks through `coroutine_to_task`, so a child that was itself a
`GatherFuture` was skipped: the tasks that inner gather had spawned stayed
in `Scheduler::tasks`, kept running, and on completion delivered a result
to the task that had just been cancelled — panicking with
`Scheduler::get_task_mut: task not found`.

The walk now follows nested gathers through `queue_gather_tasks`, using a
second worklist so neither deep task chains nor deep gather nesting
recurses. It runs before the task is dropped, while the task still holds
the `Blocked` inc_ref anchoring the tree.

Reported by cubic on pydantic#747.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread limitations/asyncio.md
Comment on lines +57 to +73
### A failing `gather` cancels its siblings

When one child of a `gather` raises, every sibling still running is cancelled where it is blocked and never resumes.
That includes the tasks of any gather a sibling was itself awaiting.
CPython leaves those siblings running as tasks on the loop, so:

```python
async def worker():
for _ in range(3):
await asyncio.gather(step())
done.append('finished')
```

appends `'finished'` under CPython after a sibling of `worker()` raises, but not under Monty.

External calls already passed to the host are not cancelled.
The host still resolves them and the results are discarded.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

How hard would it be to fix this divergence?

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