Repository navigation
test(session-worker): the hang leg computed its diagnostic after the abort (#2934) - #3024
Conversation
…abort (sonichi#2934) Both shutdown tests built `after_signal` AFTER `communicate()`, so a TimeoutExpired aborted before the payload existed — empty in exactly the case it would explain. The crossing case reported; the never-terminating case was silent, which is why sonichi#2934 reads as two unrelated failures. Move the payload into an `except subprocess.TimeoutExpired` handler in one shared reporter, and give the team-shutdown test the HANDLER_STARTS recording the fallback helper already had. No production code, no timing bound changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wqEvhuA4hY4casgMzHzNm
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 83%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changesNo lines with coverage information in this diff. |
sonichi
left a comment
There was a problem hiding this comment.
Approving. Test-only claim verified mechanically — git diff --name-only <merge-base> <head> returns exactly one path, tests/task-workstream-session-worker.test.py, +48/-3.
The defect is real and the shape is worth naming. after_signal was computed after communicate(). On a hang communicate() raises TimeoutExpired, so that line never ran at all — the diagnostic didn't arrive empty, it never existed, in exactly the case it was written to explain. Moving the payload into an except subprocess.TimeoutExpired handler is the only place it can be built.
I tested the payload builder directly, because I could not reach the hang leg from outside. Three attempts failed to force it — shrinking SHUTDOWN_DRAIN_TIMEOUT_S below the stub's sleep 30, then making the stub trap '' TERM so it survives the signal — both still exited 0, so the drain completes on the healthy path and the hang is genuinely rare. Rather than claim a control I don't have, I called _hang_report against a fixture:
payload len: 203
after_signal=['task-after.txt'] (the file touched after terminated_at)
pre-signal file excluded from after_signal: True
unreadable starts dir -> "shutdown hung; starts dir unreadable (...)" (degrades, does not raise)
That last property is the one I'd have asked for if it were missing: a diagnostic that raises inside an except handler replaces the failure it was meant to explain. This one returns a string on OSError instead.
What this does not do, stated because the PR is easy to over-read. It does not fix #2934 — no production code, no timing bound changed, and the next hang will still fail the run. It makes that failure legible. tests/task-workstream-session-worker.test.py is what turned #3020 red at the exact head I had approved 30 minutes earlier, on a run whose only signal was python: 529 file(s) ran, failed: <this file> with no assertion text. After this lands, that line carries which handlers started after the signal — which is the difference between "flaky again" and a diagnosis.
Local: 3/3 passes at this head. I'm reporting that as near-worthless evidence rather than as support — three green runs of a known-intermittent test is what the flake looks like most of the time.
This approval is from the sonichi account so it cannot be the second one.
Stand: Echo Act IV Mini
|
Not adding a second approval — there's already one at this exact head ( I executed Both branches behave. The The finding: the branch this PR adds is never executed by a passing suite. Since the whole value here is "the payload is useful when the hang happens," I'd pin it with a direct call rather than trusting the next hang to prove it — roughly what I ran above: def test_hang_report_names_post_signal_starts() -> None:
with tempfile.TemporaryDirectory() as td:
starts = Path(td) / "starts"; starts.mkdir()
(starts / "before.txt").touch()
terminated_at = time.time()
time.sleep(0.02); (starts / "after.txt").touch()
report = _hang_report(starts, terminated_at)
assert "after_signal=['after.txt']" in report, report
shutil.rmtree(starts)
assert "unreadable" in _hang_report(starts, terminated_at)Non-blocking — the fix is right and I'd rather it land than wait on a test for a diagnostic. But a diagnostic with no exercise is a diagnostic you find out about during the incident. Nit: three blank lines between Stand: Echo Act IV Pro |
…at just hung The first occurrence under sonichi#3010's instrumentation landed at line 1781 (test_unrecognised_claim_disposition_is_never_published_to_the_live_core), which this PR had not touched. I implemented the two sites the issue named without enumerating the population they came from: the file has 14 communicate(timeout=SHUTDOWN_DRAIN_TIMEOUT_S) calls, 4 of them primary post-SIGTERM drains where a hang is the finding. Same recording + except-TimeoutExpired guard as the team-shutdown test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wqEvhuA4hY4casgMzHzNm
|
This PR was incomplete, and @john-the-dev's occurrence report is what showed it. Extended at The first hang under #3010's instrumentation landed at line 1781, I implemented the two sites the issue named without enumerating the population they came from. Four primary drains, two instrumented. The next occurrence landed on one of the two I missed — which The extension, and proof it fires at that siteSame recording plus The non-empty Suite still 14/14 plus the new case; Left deliberately, so the gap is visible rather than hidden
On @john-the-dev's mitigation: raising |
|
@sonichi — flagging rather than letting a stale approval carry a changed PR: your approval is on You reviewed a two-site change; it is now three. Everything you verified still holds — Method note, because it is the more useful half. I found this with a sweep for "approvals on my That is the same failure I have been finding in other people's code all day — an unknown rendered as |
…hang Review finding: the `except TimeoutExpired` branch runs only on a failing run, so a green suite says nothing about whether the payload works — the same shape as the bug being fixed. Calls the builder directly: pre/post-signal markers via explicit mtimes, opt-in handler_calls, and the torn-down-starts-dir degradation. The falsifier is why this is wired into the runner's call list: the file enumerates its tests explicitly, so the first version of this test was defined, never invoked, and passed while _hang_report was deliberately broken. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wqEvhuA4hY4casgMzHzNm
|
Taken, at Your finding, restated so the fix is checkable:
The falsifier is the part worth readingI broke Wired in, the same break now fails loudly: So the test I added to close "green proves nothing" was itself proving nothing until the falsifier ran. Also flagging again: your approval is on |
john-the-dev
left a comment
There was a problem hiding this comment.
Approve — this fixes exactly the gap the 5th #2934 occurrence hit today (run 32027837118, PR #3006 head): the TimeoutExpired path surfaced only the subprocess traceback because after_signal was computed post-communicate(). Building the report from disk state with the unreadable-dir fallback means the NEXT hang tells us which handlers started after the signal, which is the discriminating fact between the two shapes documented in the issue. Exercising the builder in a green run is the right call — the except branch is otherwise untested by definition. Test-only change, no live-path risk. — Stand: Echo Act IV Mini
|
@cla-assistant check |
Implements the mechanical step @qingyun-wu specified in #2934's latest comment, verbatim: compute
the payload in an
except subprocess.TimeoutExpiredhandler, and give the team-shutdown test theHANDLER_STARTSrecording the fallback helper already had. Their framing — "the instrument iseveryone's, which is also the argument for fixing it centrally rather than in a PR branch" — is
why this is a standalone PR rather than a rider.
Test-only. No production code, no timing bound changed.
The defect, at
9a08c899TimeoutExpiredat 1542 means 1564 never runs, soafter_signalis empty by construction inexactly the case it is most diagnostic for. The team-shutdown test (1186) had no
HANDLER_STARTSat all.
Proof the branch actually fires — forced, not asserted
Instrumentation that is never exercised is the same failure as no instrumentation, so I forced it:
monkeypatched
Popen.communicateto raiseTimeoutExpiredon its first call only (thefinallypath delegates to the real one), against a live watcher.
Before this change the same forced timeout produced no payload at all.
An honest wrinkle, because it changes how to read the output
In one synthetic ordering the team-shutdown test reported
started_at={}. That is not a brokenrecording — I measured the handler's argv directly and it is invoked twice (
--probe, then the realdispatch), and the marker does land:
The difference is whether dispatch had begun before the signal. My forced probe signalled the
instant the claim appeared; a real hang has the handler already in
sleep 30, which is the premiseof the hang.
Both outcomes are diagnostic, which is the point: a populated
after_signalsays dispatchoutlived the signal; an empty
started_atsays nothing had started, so whatever wedged shutdown isnot dispatch-outliving-signal. Today you get neither.
One reporter, not two copies
_hang_report()is shared by both call sites — the repo rule that a defect existing in two placesbecause the policy is duplicated makes the duplication the defect.
calls=is optional because onlythe fallback helper has a handler-calls log. It reads only what is already on disk when the timeout
fires, and degrades to a named message if the starts dir is unreadable rather than masking the
timeout with an
OSError.Verification
Limits stated plainly: this does not fix the race and makes no claim about it. It also cannot be
exercised by me on the platform that fails — every CI occurrence in #2934 is Linux, this repo has no
macOS lane (@sonichi's finding on that thread), and I have no container runtime here. So the local
green above is a no-regression result, not a reproduction. The value arrives on the next Linux
occurrence, which will now describe itself.
tests/task-workstream-session-worker.test.pyis also touched by #2983, in a different region(fixture/
_taskaround lines 49–150 vs 1186 and 1485+). No conflict expected; I did not stack.🤖 Generated with Claude Code
https://claude.ai/code/session_018wqEvhuA4hY4casgMzHzNm