Repository navigation
Description
Found by the adversarial seat during the pre-merge code review of #286, and reproduced by it — filed as a residual rather than fixed there because it is bounded and cannot redeliver, so it did not warrant holding that merge.
What
#286 added a bounded outbound writer task (_drain_outbound) and wired its teardown into two paths:
_cleanup_client— cancelsoutbound_writerand nulls bothmanaged.outboundandmanaged.outbound_writerdisconnect_server— cancels the writer, then sweeps background tasks
There is a third teardown path that was not in that plan's acceptance list: handshake failure. The except-paths in _connect_stdio (around manager.py:2456) and _connect_remote_stream (around manager.py:2780) cancel only read_task and stderr_task. If a writer was started before the handshake failed, it survives that pop.
The reviewer reproduced a live _drain_outbound task after the except-path pop.
Severity — bounded, stated accurately
- Retry-capped.
MAX_CONNECTION_RETRIES = 3, and the reconnect loop makes 3 attempts, so this is at most a small number of leaked tasks per reconnect cycle rather than unbounded growth. - Cannot redeliver onto a new process. The leaked writer holds the discarded
ManagedClient. All three constructors (_connect_stdio:2413,_connect_remote_stream:2731,adopt_process:3641) allocate a fresh object, so no production path reuses one. - Net effect is a slow task/queue leak against a server whose handshake repeatedly fails — not a protocol-correctness defect at the downstream boundary.
Suggested fix
Include outbound_writer in the handshake except-paths the same way #286 included it in _cleanup_client, and null outbound/outbound_writer there too so the postcondition holds on every teardown path rather than on two of three.
Prove it with a measured mutation, per the convention these runtime fixes have used: assert no live _drain_outbound task survives a failed handshake, then remove the fix and confirm the test is red for a named reason with the mutation confirmed applied via git diff --stat. Name the test so it is selected by the existing filter (it must contain one of c01_, c02_, c04_, stalled_sink, outbound_queue, outbound_writer) — a name matching no filter runs as part of nothing and still exits 0.
Two other non-blocking nits from the same review
disconnect_servercancels the writer but does not nullmanaged.outbound. Harmless today because the object is discarded, but the same postcondition argument applies.- A frame whose
"method"is present but not a string falls through to the pending-response lookup. It is not a valid JSON-RPC request, so this is a robustness nit at an untrusted boundary rather than a correctness bug.
Activity
runtime: drop malformed downstream frames instead of ending the read …
runtime: drop mcp-rejected remote frames instead of ending the SSE re…
runtime: outbound writer teardown on handshake failure; malformed fra…
ViperJuice commentedon Sep 30, 2026
Resolved by #321 (merged as 7208815). Both handshake except-paths and
disconnect_servernow tear down the outbound writer and nulloutbound/outbound_writerthrough one helper; teststest_outbound_writer_does_not_survive_failed_stdio_handshake/..._remote_stream_handshake/test_outbound_writer_disconnect_server_resets_outbound_queuego red when the fix is reverted (mutation rows M1-M3 in the PR). Both nits are fixed too: a non-stringmethodis dropped, and every malformed-frame shape (non-object, bad id types, settled futures, parser limits, and on SSE/streamable HTTP theValidationErrormcp puts on the stream) is dropped without ending the read loop. Remaining non-blocking item: #324.docs(plan): rev 8 - describe every rejected stdio frame; static rule …