Repository navigation
fix(v1): drain EnvClient cancel tasks by removal so close() cannot spin - #2518
Merged
Merged
Conversation
Since CPython 3.12, asyncio.gather() over tasks that have all already finished completes eagerly, so awaiting it never yields to the loop. EnvClient.close() drained `_cancel_tasks` by re-gathering the set until the tasks' `discard` done-callbacks emptied it; those callbacks sit in the ready queue behind the coroutine and never run, so the loop spins at 100% CPU inside asyncio.run's _cancel_all_tasks. Ctrl-C with rollouts in flight in served mode hit this, as did a supervisor's SIGTERM to the process group; 3.11 drained. close() now pops each task before awaiting it, so the set shrinks whether or not the loop ever runs the callbacks. The regression test runs the finished-tasks state in a child interpreter: a spin that never yields never lets an in-loop timeout fire either. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Contributor
ApprovabilityVerdict: Approved at Macroscope's review found this PR approvable — This is a focused shutdown bug fix that replaces a callback-dependent task drain with explicit removal and awaiting, plus a regression test for the event-loop spin. Its impact is confined to EnvClient teardown and introduces no schema, security, deployment, or user-facing capability changes. You can add or adjust custom eligibility rules. Learn more. |
kcoopermiller
added a commit
that referenced
this pull request
Sep 4, 2026
Resolves `verifiers/v1/serve/client.py` in main's favour: #2518 fixes the same eager-gather spin in `EnvClient.close()` that this branch had patched (pop each cancel task before awaiting it), so the branch's hunk is dropped. `uv lock` re-run on the merged tree; prime-runs stays 0.1.1, prime-traces 0.0.3, and main's Python 3.14 bump carries through. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYdQmKAVnGteT1tkimJb3g
This was referenced Sep 18, 2026
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.
Symptom
In served mode (the default), Ctrl-C with rollouts in flight leaves the eval leader alive at 100% CPU after it prints
interrupted — cleaning up, please wait...; onlykill -9ends it. A supervisor's SIGTERM to the whole process group does the same. Faulthandler shows the leader insideEnvClient.close(), called from_server's teardown underasyncio.run's_cancel_all_tasks.Cause
_cancel_all_taskscancels the in-flightclient.run()calls; each one schedules a fire-and-forget_send_canceltask and parks it in_cancel_taskswithset.discardas its done callback. By the timeclose()runs, those sends have finished, and theirdiscardcallbacks are queued on the loop but have not run.close()drained the set withwhile self._cancel_tasks: await asyncio.gather(*self._cancel_tasks, return_exceptions=True). Since CPython 3.12,gather()over futures that are all already done completes eagerly insidegather()itself, so theawaitreturns without yielding to the loop. The queueddiscardcallbacks never run, the set never empties, and the loop spins.A stdlib-only version of the loop (three finished tasks,
discarddone-callbacks, the samewhile/gatherdrain) drains on 3.11.10 and spins on 3.12.13, 3.13.14 and 3.14.7.Fix
close()pops each task from the set before awaiting it, so the set shrinks by removal rather than by callback. Cancels scheduled while a pending one is awaited are still picked up by thewhile. Thediscardcallback stays for the normal path, where it releases a finished cancel's reference during the run.Measurement
A served-mode eval of the
echo-v1fixture (null harness, subprocess runtime) against a local OpenAI-compatible stub that never answers, so both rollouts sit inEnvClient._request, then one signal. Python 3.13.14, macOS.Test
test_env_client_close_drains_finished_cancelsseeds anEnvClientwith finished_send_canceltasks in the state above and callsclose(). It runs the scenario in a child interpreter with asubprocesstimeout: the oldclose()never yields to the loop, so no in-loop timeout (asyncio.wait_for) can bound it. Fails onmainunder 3.12+ (killed at 30 s), passes with this change in about a second.Related issues
#1991, #1992 and #1994 describe the pool's own shutdown of its workers: a hard-killed worker orphaning its subprocess, the broker keeping a dead worker's request pending, remote resources a killed worker cannot release. This is a different mechanism. The pool and its workers already exit when the leader dies (the death pipe in
_arm_teardown); here it is the leader itself that cannot exit.🤖 Generated with Claude Code
Note
Medium Risk
Touches eval teardown and ZMQ client shutdown on interrupt; behavior change is narrow but on a critical exit path used by served-mode eval.
Overview
Fixes served-mode shutdown where the eval leader could sit at 100% CPU inside
EnvClient.close()after SIGINT/SIGTERM (Python 3.12+), because draining fire-and-forget_send_canceltasks withasyncio.gather()on already-finished tasks completes without yielding, sodiscarddone-callbacks never run and_cancel_tasksnever empties.EnvClient.close()now pops each task from_cancel_tasksand awaits it individually, shrinking the set by removal instead of relying on callbacks. The existingdiscardcallback remains for the normal in-run path.Adds
test_env_client_close_drains_finished_cancels, which reproduces finished cancel tasks with queued callbacks in a child process with a 30s subprocess timeout (in-loop timeouts cannot bound a non-yielding spin).Reviewed by Cursor Bugbot for commit 14abddd. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Fix
EnvClient.closespinning when cancel tasks are already finishedgather-based drain inEnvClient.closewith a loop that removes each tracked task from the set before awaiting it, so finished cancel tasks don't keep the set non-emptyclose()completes within 30 secondsCancelledErrorexceptions from an individual cancel task now propagate fromclose, whereas the previousgathercall suppressed themMacroscope summarized 14abddd.