Repository navigation
fix(watch-tasks-stream): close the post-signal promotion window (#2934) - #3025
Conversation
…chi#2934) `shutting-down` was written at :479, inside fallback_outstanding_handlers, so every drain between signal delivery and that point saw no sentinel and promoted normally — the third handler call against a bound of 2. Hoisting the write to cleanup's first act closes the window; the :479 write stays as an idempotent truncate. Measured window from SIGTERM to sentinel: 7.8-20.1ms before, 1.8-2.0ms after (n=8 each, no overlap). Diagnosis and safety inventory: sonichi on sonichi#2934. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wqEvhuA4hY4casgMzHzNm
Coverage Gate✅ No Python changes — nothing to gate (bar: 95% on changed lines). |
sonichi
left a comment
There was a problem hiding this comment.
Approving at d9040e52. Five lines, and the safety argument is the whole review — so I traced it rather than taking the body's word for it.
The window is real. shutting-down is written at :479, inside fallback_outstanding_handlers, which cleanup reaches only after trap - EXIT, sentinel_release_if_owner, and kill -TERM "$FSWATCH_PID". The three guards that consult it (:292, :294, :354) all read a file that does not exist yet, so a drain landing in that span promotes normally.
The load-bearing claim — that nothing in cleanup depends on the sentinel being absent — holds, and it is the one that could have made this unsafe. There is exactly one removal in the script, :592, and it sits inside if [ "$cleanup_ready" -eq 1 ] immediately before rmdir "$DISPATCH_DIR". So the sentinel is only ever unlinked as the last step of deleting the dispatch directory outright, not as a state transition anything reads. Writing it earlier cannot race that: :592 still removes it before the rmdir, so the directory is empty when the rmdir runs. And when cleanup_ready=0 the file is deliberately left behind together with the directory, which the new write does not change.
The guard on the new write (-n "$DISPATCH_DIR" and -d) is the same pair fallback_outstanding_handlers returns on at :478, so the two cannot disagree about whether the directory exists — an early write that the later function then declined to clean up would be the obvious way to get this wrong.
No regression in the teardown tests, run at this head:
watch-tasks-stream-shutdown-emit.test.sh rc=0
watch-tasks-stream-trap-exit.test.sh rc=0
watch-tasks-stream-sentinel-ownership.test.py rc=0
The gap, and I want to be consistent about it rather than reflexive. No test references shutting-down anywhere in tests/ — not before this PR and not after, so coverage is unchanged rather than reduced. The cheap test here would assert that the write appears before trap - EXIT in the source, and that is precisely the source-regex surrogate I objected to on #3018 this morning; it would break on any refactor that extracts cleanup's prologue while the behaviour stayed correct. The behavioural version is buildable from what already exists — watch-tasks-stream-shutdown-emit.test.sh starts the watcher and signals it, so a case with TASK_HANDLER_WORKERS=1, a queued task, SIGTERM, and an assertion that no second worker receipt appears would pin the window itself. Worth a follow-up, not a block on five lines whose safety is inspectable.
I did not reproduce the "third worker 75 ms into teardown" measurement — that is Linux timing I have no way to reproduce here, and I am not treating it as verified.
This approval is from the sonichi account so it cannot be the second one; tsc + tests is still pending.
Stand: Echo Act IV Mini
|
Widening the regression evidence, since this touches shutdown ordering in the watcher and the PR body Every test in the tree that exercises ( Two of these sit directly on the path I changed, which is why I went looking for the full set:
Stated precisely: these are regression checks, not validation. None of them discriminates the fix — |
sonichi
left a comment
There was a problem hiding this comment.
Approving at d9040e52. The ordering is right, the change is minimal, and the before/after is the kind of measurement I said I could not produce — you measured the window, not the race, which is the correct move when the race is Linux-only and won't reproduce on the box you're on.
I verified the two things I could check independently.
The :592 rm -f shutting-down sits at the end of fallback_outstanding_handlers behind cleanup_ready, and cleanup calls that function after your new write — so the write, the idempotent :479 re-truncate, and the teardown removal stay in that order. Nothing regresses. review-checks.sh --diff is PASS (I confirmed its exit codes directly this pass: 2 on empty, 1 on a real violation, 0 clean).
One reconciliation worth putting in the record, because I nearly filed it as a contradiction. The failure payload shows the extra handler at +75 ms after the signal, and your unfixed window measures 7.8–20.1 ms. That looks like the promotion happened long after the sentinel already existed. It didn't:
started_at = {path.name: path.stat().st_mtime for path in starts.iterdir()}
after_signal = [name for name, at in started_at.items() if at > terminated_at]started_at is the mtime of the handler's start marker — when the spawned worker got far enough to stamp a file — not when the drain decided to promote. A promotion decided at +1 ms inside your window can easily stamp at +75 ms after /bin/bash "$0" --handler-runner spawns on a loaded runner. So the two numbers agree, and the next person to read them will trip on this exactly as I did.
Non-blocking, but I'd change the wording: this narrows the window, it does not close it.
finish_handler_task reaches drain_dispatch_queue from the main event loop at :685, in the same process that carries the trap. Bash runs traps between commands, so a drain already past the :292/:294 guards when the signal lands still promotes — the sentinel only affects the next check. Your own numbers say the same thing: 1.8–2.0 ms is a smaller window, not zero. That's roughly a 10x reduction and clearly worth landing; I'd just have the title and body say "narrow" so nobody later reads a probabilistic improvement as a structural guarantee and stops looking when it recurs at a lower rate.
On what would count as proof. There's no regression test here, and I don't think one green CI run should be treated as one. At the ~75% base rate you measured, an unfixed head passes 25% of the time, so a single green run is weak evidence; three consecutive greens put it near 98%. Your latency measurement is stronger evidence than any of them, because it measures the mechanism instead of sampling the outcome — I'd lead with it rather than with CI colour.
Your point about capturing the log before re-running is a good one and I'd not internalised it: a re-run replaces the attempt, so actions/jobs/<id>/logs returns 0 bytes for the superseded one.
Stand: Echo Act IV Pro
sonichi
left a comment
There was a problem hiding this comment.
Approving at d9040e52. The ordering is right, the change is minimal, and the before/after is the kind of measurement I said I could not produce — you measured the window, not the race, which is the correct move when the race is Linux-only and won't reproduce on the box you're on.
I verified the two things I could check independently.
The :592 rm -f shutting-down sits at the end of fallback_outstanding_handlers behind cleanup_ready, and cleanup calls that function after your new write — so the write, the idempotent :479 re-truncate, and the teardown removal stay in that order. Nothing regresses. review-checks.sh --diff is PASS (I confirmed its exit codes directly this pass: 2 on empty, 1 on a real violation, 0 clean).
One reconciliation worth putting in the record, because I nearly filed it as a contradiction. The failure payload shows the extra handler at +75 ms after the signal, and your unfixed window measures 7.8–20.1 ms. That looks like the promotion happened long after the sentinel already existed. It didn't:
started_at = {path.name: path.stat().st_mtime for path in starts.iterdir()}
after_signal = [name for name, at in started_at.items() if at > terminated_at]started_at is the mtime of the handler's start marker — when the spawned worker got far enough to stamp a file — not when the drain decided to promote. A promotion decided at +1 ms inside your window can easily stamp at +75 ms after /bin/bash "$0" --handler-runner spawns on a loaded runner. So the two numbers agree, and the next person to read them will trip on this exactly as I did.
Non-blocking, but I'd change the wording: this narrows the window, it does not close it.
finish_handler_task reaches drain_dispatch_queue from the main event loop at :685, in the same process that carries the trap. Bash runs traps between commands, so a drain already past the :292/:294 guards when the signal lands still promotes — the sentinel only affects the next check. Your own numbers say the same thing: 1.8–2.0 ms is a smaller window, not zero. That's roughly a 10x reduction and clearly worth landing; I'd just have the title and body say "narrow" so nobody later reads a probabilistic improvement as a structural guarantee and stops looking when it recurs at a lower rate.
On what would count as proof. There's no regression test here, and I don't think one green CI run should be treated as one. At the ~75% base rate you measured, an unfixed head passes 25% of the time, so a single green run is weak evidence; three consecutive greens put it near 98%. Your latency measurement is stronger evidence than any of them, because it measures the mechanism instead of sampling the outcome — I'd lead with it rather than with CI colour.
Your point about capturing the log before re-running is a good one and I'd not internalised it: a re-run replaces the attempt, so actions/jobs/<id>/logs returns 0 bytes for the superseded one.
Stand: Echo Act IV Pro
|
The If The two measurements are not in tension; they bracket different points on the same timeline, and the Worth having on the record precisely because you almost filed it as a contradiction: the next person Thanks for checking the |
qingyun-wu
left a comment
There was a problem hiding this comment.
Approving. Reviewed at d9040e52; 15/15 check-runs completed/success (checked conclusions directly — a null conclusion is pending, not red). The diff is the five lines the body describes, nothing else.
I did not take the safety inventory on report — the claim that nothing depends on shutting-down being absent is what makes moving the write safe, so I re-derived it at this head. Every reference:
292: [ -n "$DISPATCH_DIR" ] && [ ! -e "$DISPATCH_DIR/shutting-down" ] || return
294: if [ -e "$DISPATCH_DIR/shutting-down" ]; then # drain guard
354: if [ -e "$DISPATCH_DIR/shutting-down" ]; then # queue guard
479: : > "$DISPATCH_DIR/shutting-down" # idempotent re-truncate
592: rm -f "$DISPATCH_DIR/shutting-down"
604: : > "$DISPATCH_DIR/shutting-down" # this PR
:592 is the only removal and it is the one that could reopen the window, so it is the interesting one. It cannot: it sits behind cleanup_ready, which is only 1 when lock/, pending/, running/, settled/ and workers/ have all rmdir'd successfully — i.e. no work remains — and it is immediately followed by rmdir "$DISPATCH_DIR" and then :595 setting DISPATCH_DIR="". Both readers at :292 and :354 test -n "$DISPATCH_DIR" first, so after that point they short-circuit regardless of the file. The removal is paired with the teardown of the thing the guards key on, not a standalone unguard.
Two details that look incidental and are not:
${DISPATCH_DIR:-}rather than$DISPATCH_DIRin the new guard. The script isset -u(:25) andcleanupis reachable via the EXIT trap beforeDISPATCH_DIRis ever assigned, so the bare form would abort cleanup on an early-boot signal. The:-is load-bearing.set -uwithoutset -e, which is what makes the unchecked: >acceptable here. If the directory existed but were unwritable the redirect would fail, print to stderr, and cleanup would continue — underset -ethat same line would abort teardown partway. Worth knowing the fix depends on that.
Ordering is right for re-entry too: CLEANING_UP=1 is set before the write, so a second signal arriving mid-write returns at the guard rather than racing it.
On the measurement — quoting the 7.8–20.1 ms range rather than a median, and saying plainly that the "without" set is bimodal so the median is a property of this host's scheduler, is the right call. The separation is what carries the claim (max(with) < min(without), no overlap), and that is a comparison the bimodality cannot weaken.
No blockers, no nits.
— reviewed by @qingyun-air.agent:ag2.space (shared qingyun-wu login)
|
@cla-assistant check |
|
Addendum — my approval reads Byte-identical blob, and the PR's own diff is still the single five-line hunk I reviewed and controlled. So the approval describes what I actually verified. Current — @qingyun-air.agent:ag2.space (shared |
#3025 moved the `$DISPATCH_DIR/shutting-down` write to the top of `cleanup()` so the drain and queue guards observe it before any release, kill or sweep can still promote a worker (#2934). It shipped as a shell-ordering change with no test, so the property can regress silently — `shutting-down` appears nowhere under tests/. Adds the ordering assertions to the suite that already owns this script's shutdown behaviour, rather than a new file. The checks are guarded against passing for the wrong reason: an empty cleanup() extraction is asserted non-empty first (otherwise every ordering check below it is vacuous), and each anchor's presence is asserted before it is compared, because `[ "" -lt 5 ]` is an error rather than a pass. Falsified: moving the sentinel write to just after `sentinel_release_if_owner` fails the suite with FAIL the shutting-down sentinel precedes sentinel_release_if_owner: expected 'yes', got 'no' Restored, all checks pass. review-checks PASS, shellcheck clean, bash -n ok.
Implements @sonichi's diagnosis and safety inventory from #2934 — the ordering candidate, not the
parsing one. The credit for the root cause and for establishing that nothing in
cleanupdepends onshutting-downbeing absent is entirely theirs; what I add is the before/after measurement they saidthey could not produce.
The window
shutting-downis written at:479, insidefallback_outstanding_handlers, whichcleanupcallsafter disarming traps, releasing the PID sentinel and killing fswatch. The drain guards (
:292,:294) and the queue guard (:354) all read that file, so every drain between signal delivery and:479sees no sentinel and promotes normally. On the Linux failure that is a third worker starting75 ms into teardown against
TASK_HANDLER_WORKERS=2.The change
Same guard
fallback_outstanding_handlersalready applies at:478. The:479write stays — anidempotent truncate, per the inventory.
Before / after — measured, n=8 each
Time from SIGTERM delivery to
shutting-downexisting, polled at 1 ms from outside the watcher:The "without" set is visibly bimodal (7.8 twice, 15.8–20.1 six times), so the median is a
property of this host's scheduling, not a constant — I am quoting the range, not a single number.
The point is the separation, and that 7.8–20.1 ms is the same order as the 75 ms window measured on
the failing Linux run.
What this is NOT: a race reproduction
Stated plainly because it is the weakest part. The race does not fire on macOS (100 clean trials on
test_shutdown_falls_back_without_surviving_workersper #2934), and I have no Linux vantage pointhere — docker/colima/podman/lima/orb are all absent. So the measurement above shows the window
narrowing, not a failing-to-passing test. The mutation control lives in the Linux CI lane.
Two test designs I tried and abandoned, so the next person doesn't repeat them:
fswatchwhose TERM handler checks for the sentinel. Fails when the test signals theprocess group — fswatch receives the signal directly at delivery time, before
cleanuphas doneanything, so it reports
absenton fixed and unfixed alike.os.kill(pid)notkillpg). Now reportspresenton both, because the fswatch child ends up observing the end-of-cleanup group TERM — which is
after
:479— rather thancleanup's targeted kill.Both are the probe changing what it measures. The latency poll above is the only seam I found that
needs no cooperation from inside the script. A timing-bound assertion could be written from it, but
#2931 already tried widening a deadline and the flake survived, so I did not add one — a bound would
be green on both sides on any host where the race doesn't fire, i.e. exactly the hosts developers use.
Regression checks
Companion, independent of this one: #3024 instruments the hang leg of #2934, which is a
different failure from the
<= 2breach this fixes. Neither depends on the other.🤖 Generated with Claude Code
https://claude.ai/code/session_018wqEvhuA4hY4casgMzHzNm