Repository navigation
fix(ci): a cancelled dispatch lane stops its phase promptly - #8178
Conversation
Quest-Claim: 88ef78d8908669c2
A trapped Bash shell defers its trap while a foreground command runs, and a TERM that lands after its last pending-trap check but before it forks never reaches the child. A lane's phase subshell could then run its whole phase before the cancel took effect. The lane and its phase now fork every command as a job and wait for it: `wait` runs the trap at once, and Bash runs a trap left pending across a fork as soon as the fork returns. The trap resends TERM to the lane's process group, so a job forked after the first TERM still gets it, then waits for every job to exit. A new test forces the window with an xtrace PS4 hook that terminates the run right before the phase forks rs's lint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 8 minutes. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. |
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed head 063ff29.
No actionable findings. Using asynchronous jobs plus interruptible waits is a sound direction for the deferred-TERM window, while the resend-and-wait traps retain ownership of the running phase. The conversion covers the phase fork and all step commands; the file-list/probe rewrites avoid the old foreground pipelines and command substitution. Explicit stdin forwarding preserves the formatter inputs, and normal command exit statuses still reach the lane report.
Verification limits: GitHub-only static review of the dispatcher, changed tests and adjacent termination/flake checks. I did not execute the Bash race reproducer, the test suite or the reported gate, and did not independently verify the cited Bash implementation line numbers. The forced-window and repeated-run results remain author-reported.
There was a problem hiding this comment.
Important
The new forced-window test is flaky: on bash 5.2.21 its jobs="3" subtest failed 8 of 30 back-to-back runs (and 1 of 4 full-suite runs), always with the dispatcher exiting 2 instead of 143. Details and a validated fix direction are inline.
Reviewed changes
- Every phase command is a job —
runforks the command as an async job with the caller's stdin and waits for it, so a pending TERM trap runs at once;stopignores further TERMs, resends TERM to the lane's process group, and waits for every job before the lane exits. - Phase subshell becomes a lane job — the phase is forked with
set +e; wait "$!", keeps itsstop; exit 143trap, and restores INT/QUIT;run_lane's trap becomesstop; cancelled; exit 143. - Listing and probe rewrites —
fileswrites$logs/<module>.filesforxargs(onegit ls-filesper phase), and the flake probe captures to$logs/flake.probeinstead of a command substitution. - Tests and quest — a forced pre-fork-window test and a
files-listing test are added; the completed quest file and its m1 README line are removed.
No implementation defect found in sh/dispatch.sh: I checked the stdin hand-off, the errexit paths, $listed dynamic scoping, the probe-output equivalence, and the process-group invariant (set -m puts each lane in its own group, and bash disables job control in subshells so a lane's jobs stay in that group). The forced test does hang against origin/main, so it genuinely detects the bug.
deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏
…ispatcher Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Forced-window test signals the lane group — the
BASH_ENVxtrace hook now sendskill -TERM 0(the lane's process group, which is what a cancel delivers) instead ofkill -TERM $$, keeping the dispatcher's own lane-launch window out of the test. - Comment updated — the comment above the hook now states the group target and why it avoids the dispatcher's window.
Validated on bash 5.2.21: the forced-window test passed 40/40 back-to-back runs (against 8/30 failures before the change, always jobs="3" with exit 2), and the full sh/dispatch-query.test.py suite passed 38/38. The delta since the prior review contains no sh/dispatch.sh change; the quest/m1/README.md churn in the range-diff is the upstream merge, and the PR's only README change remains removing the completed quest line. The earlier implementation review stands.
deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed c15dc50, comparing with 063ff29 and accounting for merged main changes.
No new actionable findings. The forced-window hook now signals its own lane process group rather than Bash's inherited $$ dispatcher PID. That targets the cancellation condition being tested without also racing the dispatcher's lane launch. The job-control boundary keeps the signal inside the lane; the dispatcher implementation and cancellation/child-exit assertions retain their reviewed behavior. This addresses the published test-flakiness concern.
Verification limits: GitHub-only base-aware comparison of all four changed files and inspection of the dispatcher and regression hook. No Bash reproducer, repeated-run trials or tests executed; reported Bash-version results remain author-provided. Rechecked open/non-draft state, full head/base and published reviews immediately before posting.

Summary
Finishes
quest/m1/dispatch-cancel-race.md, the follow-up from moq-dev/moq#8138.A trapped Bash shell defers its trap while a foreground command runs. A TERM that lands after the shell's last pending-trap check (
execute_cmd.c:643in 5.3) but beforemake_childblocks signals never reaches the child: the child is forked after the group signal was delivered. Insh/dispatch.sh, a lane's phase subshell (trap 'exit 143' TERM) could then run its whole phase before the cancel took effect. The lane shell had the same window around forking the phase subshell.The fix forks every command a trapped lane shell runs as a job:
run CMD...launches the command as a job andwaits for it.waitruns a trap at once, and Bash runs a trap left pending across an async fork as soon as the fork returns (execute_cmd.c:797,:1230).stop, the body of both TERM traps, ignores further TERMs, resends TERM to the lane's process group (which reaches a job forked after the first TERM), then waits for every job. It usesuntil wait; do :; donefor the same stalewait_signal_receivedreason as the EXIT trap from fix(ci): a terminated dispatch run waits for every lane and reports it #8138, because its own resend can land while Bash still recordswaitas the current builtin.runrestore INT/QUIT, which Bash ignores in async lists without job control, so phase commands keep today's dispositions.runkeeps the caller's stdin explicitly (<&0).stepcommand goes throughrun. The two shapes that forked in the foreground are rewritten:filesnow writes the listing to$logs/<module>.files, and the lints runrun xargs ... <"$listed". Python's check and fix now list the files once instead of twice.The guarantee from moq-dev/moq#6617 stays: a cancel returns only after the phase's command has exited.
Evidence
All on the pinned Nix Bash 5.3.20.
test_terminating_just_before_a_phase_forks_its_command_stops_itsetsBASH_ENVto an xtrace hook.PS4expands in the phase shell after the DEBUG trap and the last pending-trap check, right before it forks a foreground command. At rs's lint line, the hook's command substitution TERMs the lane's process group (exactly what a cancel delivers), then blocks until the lane's group TERM kills it, so the TERM is pending when the shell forks. Againstmain's dispatcher, both subtests (1 and 3 jobs) hang until the test's 60 s guard expires. With this PR they exit 143 at once withcheck rs (cancelled). A DEBUG-trap hook cannot force this window, because Bash runs the pending TERM trap inside the DEBUG trap.Choices made without asking
runjob instead of dropping the phase subshell's trap. An untrapped phase dies on TERM with no race, but it orphans the running command, which breaks ci: run check and ci modules in concurrent lanes #6617's "returns only after the tree exits". Bash does not exec-optimize a function's last command, so even single-command phases would orphan.stepentries must userun. A foreground command would bring this latency race back for that phase only. The forced test guards rs's line.Public API and wire
None.
sh/CI tooling only.Validation
python3 sh/dispatch-query.test.py: 38 tests OK. The new tests are the forced window, andtest_formatters_get_the_listed_files, which coversfileslisting (deleted tracked files skipped) reaching xargs.shellcheck sh/dispatch.sh,shfmt -d sh/dispatch.sh,ruff check/ruff format --checkon the test: clean.quest check: ok.quest gate just check(selects everything, sincesh/dispatch.shis root orchestration):test js. Its one failure waslite-07 answers a bounded window that resolves empty and FINs in reply, the known flake moq-dev/moq#8150 quarantines, unrelated tosh/.Suggested follow-ups
None.
Review
Codex
gpt-6.1-sol, medium effort, viaquest review --base origin/main(base9920a3f550).9bf391fe481f8ba5c802dc02286c94a181bad0d0, 1 finding:run's async block could drop a redirected stdin (run xargs ... <"$listed"). Fixed in 063ff29 with} <&0 &. It was latent in the dispatcher, whererun_lane ... </dev/null &sets Bash'sstdin_redir(execute_cmd.c:2837), but real in plain Bash.063ff29ec1969238ef2cef58cdfc754be37f24bb: no findings.exit 143trap parse (exit 2). Fixed in c15dc50: the hook now sendskill -TERM 0; 10/10 local passes, still fails againstmain's dispatcher.origin/main, round 1 (fresh session) onc15dc50431916c6b65d1d1dc139b6874d84a6ca9: no findings.sh/dispatch-query.test.py: 38 tests OK.🤖 Generated with Claude Code
(Written by Claude Opus 5.5 with low effort)