Repository navigation
fix(claude-channel): serve waits for the worker's exit code after ended - #172
allenhutchison wants to merge 3 commits into
Conversation
…is out If the session hung up right after reading `ended`, serve returned the initial 0 before the worker had set its exit code, so a stopped channel could exit 0 instead of its real code. serve now waits up to 5s for the worker when ended has been pushed. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lk5Upqk2oCRiV3ZENbLyLL
Vycari Review (shadow)Shadow round 2 on 9659cda. This comment is non-gating and needs no response: it is collected to measure agreement with the current reviewer. The round's verdict is the Vycari Review (shadow) check run. Open: P0 0 · P1 1 · P2 1 · confirmed fixed 0 · withdrawn 0 Judge's notes Reordering the ending marker fixes the skipped-wait race from the last round. The prior finding that a timed-out verdict wait returns 0 is still open; the PR body discloses it as a known limit. The remaining new finding is a pre-existing stdin-closed-first gap, rated P2. P1 · VR-c55f8f41 · After the 5 second verdict wait times out, serve returns 0 even though the worker's code is non-zero and still pendingsrc/pepper_cli/claude_channel.py lines 1228–1231 · symbol serve · lens fail-open · status still-open When ending is set and worker_done does not fire within VERDICT_WAIT_SECONDS, the finally block proceeds and serve returns outcome['code'], which is still the initial 0. The PR body acknowledges this as a known limit. The result is that a worker that already decided on exit 2 or 1 is reported to the process as a clean exit 0, and the wait only delays shutdown by 5 seconds before doing so. Evidence: Read serve lines 1223-1232 and run_worker 1206-1215. Timeout falls through to 'return outcome["code"]' with no signal to the caller. The PR body documents the timeout returning 0 as a known limit. Triggering needs the worker to stall in push beyond 5 seconds, which is rare in practice, so the severity is P2. Failure scenario The worker has pushed 'ended' (code 2) but is blocked in portal.call, for example because the write stream is not drained, for more than 5 seconds. Stdin closes. serve waits 5 seconds, returns 0, and the process exits 0 while the worker would have returned 2. Scripts that check the exit code see success for a refused cursor file. How this was verified (traced-callers) Read serve lines 1223-1232. A timeout of worker_done.wait is ignored and the initial outcome code 0 is returned. This is a documented, bounded limit. Reachable from: stdin EOF after a worker push of ended that stalls longer than 5s. Proving test P2 · VR-572588c6 · Concurrent shutdown still converts a terminal failure into exit 0src/pepper_cli/claude_channel.py lines 1113–1116 · symbol _Pusher.push · lens concurrency-time · status still-open Moving ending.set() before the closed check ensures serve waits, but the closed check can still discard the verdict. If shutdown sets closed after ending is set, push raises ChannelClosedError before ChannelWorker._ended returns its _Ended exception. ChannelWorker.run catches ChannelClosedError and returns 0, so worker_done now publishes a completed but incorrect success verdict. Evidence: _accept_scope catches the cursor-binding refusal and calls _ended at src/pepper_cli/claude_channel.py:721-726. _ended pushes before constructing _Ended(code) at :401-409. The reordered marker at :1113-1114 permits shutdown's closed assignment at :1229 before the check at :1115-1116. ChannelWorker.run explicitly converts the resulting ChannelClosedError to 0 at :783-784; run_worker publishes that 0 and signals worker_done at :1215-1216. Thus the wait at :1231-1233 does execute and does not time out, ruling out the previously reported skipped-wait and timeout mechanisms. The added closed-pusher test at tests/test_claude_channel.py:1304-1312 asserts the marker and exception but never checks the real worker's resulting verdict. Failure scenario A scoped worker detects a cursor belonging to another room and starts pushing ended with verdict 2. After ending.set(), stdin closes and serve sets pusher.closed and stop. The worker resumes, raises ChannelClosedError, and returns 0. serve observes ending, waits successfully for worker_done, and exits 0 despite the refused cursor. The reordered marker therefore leaves the prior failure scenario reachable through verdict replacement rather than a skipped wait. How this was verified (traced-callers) Read _ended (push before returning _Ended) and ChannelWorker.run (ChannelClosedError returns 0). Confirmed the closed-before-push race yields 0, which matches the base behaviour and the documented stdin-closed-first semantics. Reachable from: pepper agents claude-channel -> serve (claude_channel.py:1355) -> run_worker -> ChannelWorker.run. Proving test |
… a sleep The worker now returns only after serve has set stop and, unless serve waits for the verdict, after serve has returned. Without the fix serve returns 0 first, deterministically. The driver closes stdin in a finally and hands its error back, so a failed handshake cannot hang the test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lk5Upqk2oCRiV3ZENbLyLL
serve sets closed and then reads ending; push read closed and then set ending, so serve could slip between them, skip the verdict wait and return 0. Setting ending first means whichever side moves second sees the other. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lk5Upqk2oCRiV3ZENbLyLL
|
Round 2 summary (head 9659cda):
Local: ruff format/check, ty, full pytest (2652 passed, 2 skipped) at 9659cda. |
Human overview
When Claude Code hung up right after reading
ended,servecould return 0 before the worker had set its real exit code (2 for a refused cursor file, 1 for auth failures).servenow waits up to 5 seconds for that verdict onceendedhas been pushed. This also removes the flake intest_a_scoped_cursor_file_for_another_room_ends_the_channel.Closes #159
AI reviewer
Root cause:
ChannelWorkerpushesended(via_ended) and only afterwards returns its code torun_worker, which writesoutcome["code"].servereturnedoutcome["code"]as soon as stdin hit EOF, so an EOF landing between the push and the write returned the initial 0. This is a product race, not only a test race: a session that closes stdin on readingendedloses the non-zero code, andlinger/terminatenever run.Change (claude_channel.py):
_Pusher.ending(threading.Event) is set when anendedpush is attempted, before theclosedcheck.servesetsclosedand then readsending, so whichever side moves second sees the other: anendedthat passes theclosedcheck is always waited for (Vycari Review VR-572588c6 found the check-then-set window in the first version). A set afterservehas looked is harmless; nothing reads it again.servekeeps aworker_doneEvent, set byrun_workerright afteroutcome["code"]is written.finally, ifendingis set,servewaits onworker_done(shielded, in a thread, so the loop stays free) for at mostVERDICT_WAIT_SECONDS(5.0).endedis unchanged: no wait, returns 0 as documented.Choice: product fix rather than holding stdin open in the test. Holding stdin open would hide a real exit-code loss. Alternative not taken: joining the worker unconditionally, because the worker can sit in a blocking HTTP read on ordinary shutdown (same reason as #156).
Known limits: if the worker has not finished within 5s,
servestill returns 0 (the initial value); the wait only delays shutdown, bounded.outcomeis written on the worker thread and read afterworker_done, so no extra locking.Verification:
test_serve_returns_the_verdict_of_a_worker_that_pushed_ended_before_stdin_closed: the worker returns only afterservehas setstop(it is shutting down) and after the test has seenservereturn, bounded at 1s. Without the waitservereturns 0 first; with it,serveblocks on the worker, which returns 2. The driver closes stdin in afinallyand reports its error. Checked against the originalclaude_channel.py: failsassert 0 == 2, 3 of 3 runs.test_an_ended_push_marks_the_pusher_ending_even_when_the_channel_has_closedpins the set-before-check order (fails on the first version of this PR).uv run ruff format --check,ruff check,ty check src/pepper_cli/clean. Fulluv run pytestat 9659cda: 2652 passed, 2 skipped.endingwhen the channel is already closed; reversed in round 2 for the ordering above), 4 not fixed: two are the bounded 5s delay and the 0-on-timeout above, one is a benign cross-thread read, one was the test's sleep-based timing, which I then replaced with an Event.Checklist (repo-specific)
uv run ruff format --checkanduv run ruff checkclean (no new lint ignores)uv run ty check src/pepper_cli/cleanuv run pytestpasses; coverage floor in.claude/maintainerd.jsonnot loweredSKILL.md: no command or output change🤖 Generated with Claude Code
https://claude.ai/code/session_01Lk5Upqk2oCRiV3ZENbLyLL


Confidence Score: 5/5
The PR appears safe to merge; both previous test issues are addressed.Summary
servewaits up to five seconds for the worker's exit code when stdin closes after anendedpush.ended.Diagram
sequenceDiagram participant Worker participant Pusher participant Serve participant Session Worker->>Pusher: Push ended Pusher->>Pusher: Set ending before checking closed Pusher->>Session: Send ended if open Session->>Serve: Close stdin Serve->>Serve: Set closed and stop Serve->>Worker: Wait for worker_done, up to 5 seconds Worker->>Serve: Save exit code and set worker_done Serve->>Session: Return exit codeReviews (2) · Last reviewed commit: "fix(claude-channel): mark ending before ..." · Reviewed by Greptile