fix(harness): close the turn's event source when delivery stops early - #541
Open
michaelxu2288 wants to merge 2 commits into
Open
michaelxu2288 wants to merge 2 commits into
michaelxu2288 wants to merge 2 commits into
Conversation
The delivery adapters (auto_send for the async push path, yield_events and UnifiedEmitter.yield_turn for the sync yield path) closed their streaming contexts and flushed span derivation in `finally`, but never closed the event source they were iterating. Every tap relies on that close: the Claude Code and Codex taps close their raw line iterator in a `finally`, which is what terminates the CLI subprocess in the scaffolds. When a turn is cancelled while delivery is awaiting the backend (stream_update or a context close) rather than the source, or a sync client disconnects mid-stream, the source stays suspended at a yield. The turn object keeps a reference to its event generator, so garbage collection does not finalize it while the caller still holds the turn, and the CLI subprocess and its stdout handle leak. Both adapters now close the source in `finally`, after the existing cleanup and guarded so a failing aclose cannot mask the original exception. yield_turn closes its delivery generator explicitly instead of leaving it to async-generator finalization. Plain async iterators without aclose and already-exhausted generators are unaffected. Three new tests (cancel during a blocked stream_update; early close of yield_events; early close of yield_turn with a turn that pins its generator) fail on main with `assert [] == [True]` and pass here. tests/lib/core/harness and tests/lib/adk: 571 passed, 1 skipped.
_handle_streaming_response iterated the handler's async generator inside generate_json_rpc_stream but never closed it. When a client disconnects, Starlette closes the body iterator, and the handler (with the harness turn and CLI subprocess under it) stayed suspended until garbage collection. Close the handler generator in a finally, guarded so a failing aclose cannot mask the original error. New test: take one chunk from the streaming response, close the body iterator, and assert the handler generator's finally ran. It fails on main (assert [] == [True]) and passes here.
This branch has not been deployed
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
The harness delivery adapters never close the event source they iterate:
auto_send, the async push path behindUnifiedEmitter.auto_send_turn;yield_eventsandUnifiedEmitter.yield_turn, the sync HTTP yield path.Their
finallyblocks close streaming contexts and flush span derivation, but leaveturn.eventsalone. Every CLI tap depends on being closed. The Claude Code and Codex taps close their raw line iterator in afinally, and that close is what terminates the CLI subprocess in the scaffolds.It goes wrong in two cases:
ctx.stream_updateor a context close) rather than the source;In both, the source stays suspended at a
yield. The turn object keeps a reference to its event generator, and agents hold the turn to readturn.session_id. So garbage collection never finalizes it, and the CLI subprocess and its stdout pipe leak.Fix
auto_sendandyield_eventscloseeventsin theirfinally, after the existing cleanup. The close is guarded withcontextlib.suppress(Exception), so a failingaclosecannot mask the original exception.UnifiedEmitter.yield_turncloses its delivery generator explicitly instead of leaving it to async-generator finalization.aclose, and generators that are already exhausted, behave exactly as before.Verification
test_auto_send.py: cancelauto_sendwhilestream_updateblocks, with the source held by a local and nogc.collect();test_yield_delivery.py: closeyield_eventsearly;test_emitter.py: closeyield_turnearly, with a turn that pins its generator the way the CLI taps do.mainwithassert [] == [True](the source'sfinallynever ran) and pass here.aclosestill delivers normally.uv run pytest -n 0 tests/lib/core/harness tests/lib/adk: 571 passed, 1 skipped.ruff checkandpyright(1.1.399) are clean.The PR appears safe to merge; no actionable issue remains established.
Summary
Harness delivery now closes event sources when delivery stops early, so CLI tap cleanup runs on cancellation and client disconnects. The ACP response stream also closes its handler generator, carrying disconnect cleanup upstream.
Diagram
%%{init: {'theme': 'neutral'}}%% flowchart LR A[ACP response body closes] --> B[Handler generator closes] B --> C[yield_turn closes delivery] C --> D[yield_events closes turn events]Reviews (2) · Last reviewed commit: "fix(acp): close the handler generator wh..."