fix: replace wait-timeout so a signal-handler panic can't abort wt - #3857
Conversation
worktrunk-bot
left a comment
There was a problem hiding this comment.
Reviewing my own PR, so this is a COMMENT rather than an approval.
fast-checks is red on the doc step (job) — nothing else in that job failed. ChildWaiter is pub, so its two intra-doc links to kill_timed_out_tree resolve to a private item and -D rustdoc::private-intra-doc-links rejects them:
error: public documentation for `ChildWaiter` links to private item `kill_timed_out_tree`
--> src/shell_exec.rs:1041:7
error: public documentation for `kill` links to private item `kill_timed_out_tree`
--> src/shell_exec.rs:1084:64
I'm pushing the fix — plain code spans in both places, keeping the prose. No human author will pick this up otherwise.
waiter_gone()'s text names one of the two cases it covers. After the status is delivered the channel is disconnected and empty, so every later wait() / wait_timeout() takes the same arm and reports "the thread waiting on this child terminated unexpectedly" — which is exactly the spent-waiter case test_child_waiter_reports_a_spent_waiter pins, and nothing terminated unexpectedly there. No current caller reaches it (run_with_timeout_impl and the pager discard the second wait(); delayed_stream's Phase 2 wait() still has a pending value), but the message is what a future second-wait() caller would surface through .context("Failed to wait for command"). Pushing a wording fix that covers both causes alongside the doc-link fix.
One thing I left alone, for a maintainer to weigh
kill_timed_out_tree(waiter.pid()) inherits the staleness window that ChildWaiter::kill's docstring reasons about, and its own docstring doesn't mention it. Under wait-timeout the child was still unreaped when wait_timeout returned None, so child.id() was guaranteed to name the same process (a zombie at worst). Now the waiter thread reaps asynchronously, so between the expired deadline and the killpg the child can exit, be reaped, and free the pgid — and this call signals a whole process group by that id, not a single pid. It needs pid wraparound inside a few microseconds, so I don't think it's worth code, but the docstring on kill_timed_out_tree still reads as though the pid is guaranteed live ("its pid is the pgid and the TERM → KILL escalation reaches every member").
Related and benign, but worth recording since it's a real behavior change the PR description doesn't mention: forward_signal_with_escalation decides whether to escalate by polling killpg(pgid, 0). Previously the unreaped zombie leader kept that check true through the 200 ms grace, so the SIGTERM was followed by a group SIGKILL essentially every time — defeating the "git's lockfile handlers run on TERM" rationale the docstring gives for sending TERM first. With the waiter reaping promptly, a child that exits on TERM with no surviving group members now genuinely skips the SIGKILL. That's the intended semantics, so this is a fix, not a regression.
Otherwise the change reads correctly to me. Both run_with_timeout_impl and delayed_stream spawn with Stdio::null() stdin and the pager takes stdin/stdout off the child before handing it over, so moving the Child into a thread that calls wait() — which drops stdin — doesn't close a pipe anyone still writes to. Dropping a ChildWaiter without reaping is also better than the old Child drop: the thread outlives the handle and reaps, where Child::drop leaked a zombie. And the wait-timeout removal is complete — no source, Cargo.toml, or Cargo.lock references remain, and check-unused-dependencies, minimal-versions, and crate-build are all green.
`ChildWaiter` is `pub`, so its intra-doc links to the private `kill_timed_out_tree` fail `-D rustdoc::private-intra-doc-links`; plain code spans keep the prose without the link. `waiter_gone()` is also reached when the status was already delivered — an empty *and* disconnected channel — so it no longer claims the waiter thread died.
`ChildWaiter::kill` documents that signalling by pid can, in principle, reach a recycled pid; `kill_timed_out_tree` takes the same pid and signals a process group with it, so record the wider blast radius and the same microsecond bound there too.
The pager's `Err` arm did exactly what the timeout arm did, so a wait that failed outright now falls through to the same kill-and-fall-back. Covered by a pager that outlives PAGER_TIMEOUT, which also pins that the timeout bounds the wait.
|
Closing out the one item my review left for a maintainer to weigh, since it turned out to be documentation rather than a code decision.
Correction (edited after
Verification
|
… waiter Answers the "is there a dependency for this?" question on #3856: yes — `shared_child` is the same API shape the in-tree `ChildWaiter` grew (`wait`/`wait_timeout`/`kill`/`id`/`take_std*`), from the author of `duct`, and it adds exactly two crates to the graph (`shared_child`, `sigchld`); `signal-hook`, `os_pipe`, `libc` and `windows-sys` are all already there. It does reach SIGCHLD again, which the in-tree waiter avoided by owning a thread per child — but through `signal_hook`, whose wake discards write errors by design rather than `panic!`ing in an `extern "C"` handler. That difference is the whole of #3856. It also probes the wake fd and falls back to `write()` on a pipe, so `send()` — the syscall the Codex sandbox denies — is not even on the path. Two incidental wins: the timed wait is `waitid(WNOWAIT)`, so the child stays unreaped across the deadline and the stale-pid window the previous commits documented at both kill sites is gone; and `kill()` goes through the `Child` handle rather than a raw signal. The `SigCgt` regression test asserted the absence of a SIGCHLD handler, which is no longer the property that matters. It's replaced by a graph assertion: `wait-timeout` must not come back, however it gets there.
`include_str!` embeds the file at compile time, which `embedded_assets_ship_in_package` requires to be covered by `cargo package` and the `flake.nix` source filter — `Cargo.lock` is covered by neither, so the guard failed on macOS CI. Tests only ever run from the source tree, so reading it through `CARGO_MANIFEST_DIR` asserts the same thing without embedding anything.
`run_with_timeout_impl`'s spawn error had no test: a command that can't be spawned has to fail as a spawn error rather than be waited out to the deadline, which is worth pinning on its own. The pager timeout test read the clock a second time inside the assertion message, so that line only ran when the test failed. One binding covers both uses and reports the elapsed time that was actually compared.
worktrunk-bot
left a comment
There was a problem hiding this comment.
Reviewing my own PR, so this is a COMMENT rather than an approval.
The shared_child refactor holds up on the points it turns on. wait_deadline is waitid(WEXITED | WNOWAIT) plus a sigchld poll and does not reap on the Ok(None) path, so the new kill_timed_out_tree docstring ("the pid cannot name a different process group by the time the signal lands") is correct as written. wait_timeout, take_stdin/stdout/stderr and SharedChild::spawn all exist in 1.1.0, and timeout is a default feature, so the "1.1" requirement is safe under minimal-versions. sigchld reaches SIGCHLD through signal_hook::low_level::pipe::register over an os_pipe, which is the write-not-send path the description claims. signal-hook 0.3.18 was already in the lock before this PR, so the new crates add no duplicate-version churn.
Two things.
1. The description's "One behavior change worth naming" is wrong, and contradicts the paragraph above it
The description says both of these:
the timed wait is non-reaping, so the child stays a zombie across the deadline
The teardown now reaps promptly, so a child that exits on TERM genuinely skips the SIGKILL.
Only the first is true of what shipped. The second was true of the in-tree ChildWaiter that 74470c2 deleted — its background thread reaped asynchronously. With shared_child, wait_deadline returns Ok(None) without reaping, so at kill_timed_out_tree the group leader is still an unreaped zombie, process_group_alive's killpg(pgid, 0) still succeeds through the 200 ms grace, and forward_signal_with_escalation escalates to the group SIGKILL exactly as often as it did under wait-timeout. Nothing changed here; the section claims a behavior change this PR does not make. I verified the zombie half directly — killpg(pgid, 0) against a group whose only member is an unreaped zombie succeeds, and returns ESRCH only after the reap.
I'm rewriting that section of the description. The same correction applies to my earlier conversation comment, which repeated the claim while it was still accurate for 7f156df.
2. run_with_timeout_impl's Err arm leaves the timeout bounding nothing — pre-existing, but newly reachable
match child.wait_timeout(timeout)? propagates a failed wait straight out of the thread::scope closure, with no kill_timed_out_tree and no child.kill(). thread::scope then joins the two reader threads on the way out, and those sit in read_to_end until the child closes its pipes — so the caller waits the child's full runtime and gets back an error that isn't TimedOut. That is precisely the failure this function's own docstring says the teardown exists to prevent ("otherwise the timeout doesn't bound anything"), and for the git ls-remote case it names, the full runtime is ~127 s per address.
The shape is pre-existing — wait-timeout's wait_timeout had the same ? — but this PR makes the Err materially more plausible. shared_child allocates a pipe and registers a SIGCHLD handler on every timed wait (sigchld::Waiter::new()? inside wait_deadline_noreap), where wait-timeout set its self-pipe up once, process-wide. So fd exhaustion or a sandboxed pipe/sigaction now surfaces as Err where wait-timeout surfaced as an abort. Given the bug this PR exists to fix is "a denied syscall in the timed-wait machinery takes wt down", it's worth not leaving a path where a denied syscall in the same machinery silently removes the bound instead.
a1dc5d4 folded exactly this Err arm into the kill path for the pager; run_with_timeout_impl didn't get the same treatment.
I'm not fixing it here, deliberately: the Err(e) => Err(e) tail can't be driven from a test, so the fix adds an uncovered line and puts codecov/patch back under its 98.12% target on a PR whose point is elsewhere. Better as its own change, where the coverage argument can be made on its own terms.
Smaller notes, not worth changing
pager.rs's merged arm logs "Pager did not exit within {:?}" for anErrtoo, where the wait failed rather than the pager overrunning.?outcomeis in the record, so the distinction is recoverable.test_wait_timeout_crate_stays_out_of_the_dependency_graphmatches the whole lockfile, so a future dev-dependency pullingwait-timeoutin transitively (theproptest→rusty-forkpath) would fail it without the shipped binary being affected. No such dep today, and "however it gets there" is the stated intent.track_if_cancellable(child.id())now evaluatesid()eagerly at all five call sites rather than lazily inside thethen, which forSharedChildtakes the inner mutex. Immeasurable at one call per spawn.
`run_with_timeout_impl` propagated a failed `wait_timeout` straight out of the `thread::scope` closure, with no `kill_timed_out_tree` and no `child.kill()`. The scope then joins the two reader threads on the way out, and those sit in `read_to_end` until the child closes its pipes -- so the caller waits out the child's full runtime (~127 s per address for the `git ls-remote` case the docstring names) and gets back an error that isn't `TimedOut`. That is the exact failure the docstring says the teardown exists to prevent. The shape is pre-existing -- `wait-timeout`'s `wait_timeout` had the same `?` -- but `shared_child` makes the `Err` materially more plausible: it allocates a pipe and registers a `SIGCHLD` handler on every timed wait, where `wait-timeout` set its self-pipe up once, process-wide. So fd exhaustion or a sandboxed `pipe`/`sigaction` now surfaces as `Err` where `wait-timeout` surfaced as an abort. Leaving a path where a denied syscall in the timed-wait machinery silently removes the bound would undercut the point of this PR. Folds the `Err` arm into the timeout arm, the same way a1dc5d4 did for the pager's wait. The existing timeout tests cover every line of the merged arm; only the branch that unwraps a real wait error is undriveable from a test, and it shares its lines with the timeout path.
worktrunk-bot
left a comment
There was a problem hiding this comment.
Reviewing my own PR, so this is a COMMENT rather than an approval.
33c187250 closes the run_with_timeout_impl gap my last review left open, and the merged-arm form is the right shape — every line in the arm runs under the existing timeout tests, so the fix cost no patch coverage (codecov/patch is green on this head).
The third exposed site still has the standalone Err arm the description says it lost
The description says "Both timed-wait sites now fold their Err arm into their timeout arm, because a failed wait needs the same teardown a timeout does" — but it also names three exposed call sites up top, and the third, Cmd::delayed_stream's Phase 1 wait (the match child.wait_timeout(remaining) under the // Phase 1: comment), still returns straight out:
Err(e) => {
let _ = stdout_handle.join();
let _ = stderr_handle.join();
trace.fail(&e);
return Err(e).context("Failed to wait for command");
}Those arms are outside the diff — wait-timeout's wait_timeout had the same signature, so they carried over unchanged — and the site is genuinely less severe than the one just fixed: Phase 1's timeout is a display threshold, not a wall-clock bound, so nothing gets un-bounded. But the rest of the shape is identical, and the "newly more plausible Err" argument the description makes for run_with_timeout_impl applies here just as well, on the hotter path. The two spawn_delayed_reader joins sit in read_to_end until the child closes its pipes, so the caller waits out the child's full runtime; the SharedChild is then dropped unreaped (shared_child has no Drop impl), leaking a zombie; and a command that ran to completion is reported as Failed to wait for command.
Teardown isn't the fix here — killing a user's git worktree add because our wait pipe failed would be worse than the disease. The fall-through is: treat Err like Ok(None) and drop into streaming. Phase 2's child.wait() goes through waitid(WEXITED | WNOWAIT) with no pipe and no SIGCHLD registration, so it is unaffected by whatever made the timed wait fail; and if the failure is genuine rather than machinery-specific, Phase 2's own Err arm surfaces it anyway, just after the child has actually finished. That turns "wt fails an ordinary command under a sandbox that denies pipe/sigaction" into "wt streams its output immediately", which is the degradation this PR argues for everywhere else.
I'd rather not push it onto this head — it's pre-existing, out of the diff, and this PR is otherwise green. Either way the description needs a line: as written it claims a treatment the third site doesn't have.
What I checked on the new arm
outcome.err()isNoneexactly onOk(None), sinceOk(Some(_))is handled above — theTimedOutsynthesis can't shadow a real wait error.kill_timed_out_tree's safety argument ("the caller still holds an unreapedSharedChild") carries to the newErrcaller, rather than only to the timeout case. Inshared_child1.1.1, everywait_deadlineerror path fails before a reap:sigchld::Waiter::new()?andtry_wait_noreap'swaitidboth return beforeChild::waitis reached, and the one post-exit error —inner_guard.child.wait()?— means thewaitpiditself failed, so the pid is still reserved.let _ = child.wait()in the arm can't hang if the failure was in the wait machinery:SharedChild::waitiswaitid(WEXITED | WNOWAIT)plus a blocking reap, with no pipe on the path, and the two kills preceding it close the reader threads' pipes either way.- Every
Cmd::timeoutcaller is a probe or a read —git ls-remote, the fsmonitor stop/lsofprobes,git reap's probes, the shell probe — so newly killing the tree on a spurious wait error can't interrupt a write.
|
Author role, acting on my own review above. Item 2 is fixed here after all — Item 1 was already applied to the description before the review posted. The stale claim also survived in my earlier conversation comment, which is now edited with the correction rather than left to be read as current. A further review landed on this push while I was polling, flagging that the description claimed a treatment the third exposed site —
The advisory failure, and what's still pending
A |
|
DiagnosisFailed job — The panic is First sighting of this shape — no open issue matches it, and one non-reproducing failure on one arm runner isn't enough to build a fix against. Noting it here so a recurrence has a prior to point at; the rerun leaves the check reading |
Problem
Every timed wait in
wtwent throughwait-timeout0.2.1, which reaps children from a process-globalSIGCHLDhandler. That handler pokes a self-pipe vianotify(), which panics on any write error other thanWouldBlock. Becausesigchld_handlerisextern "C", the panic cannot unwind — it goes straight topanic_cannot_unwind→abort(). Under a sandbox that denies the send (the Codex CLI'sworkspace-writemode, where the write failsEPERM),wt switch --createdied withSIGABRTand no diagnostic the user orwtcould do anything with.Three call sites were exposed:
run_with_timeout_implandCmd::delayed_streaminsrc/shell_exec.rs, and the picker's pager insrc/commands/picker/pager.rs. (picker/prs.rsusesCondvar::wait_timeout_whilefrom std, which is unrelated.)Solution
Drop
wait-timeoutand wait throughshared_childinstead —SharedChild::spawnpluswait/wait_timeout/kill/id/take_std*at all three sites. It adds exactly two crates to the graph (shared_child,sigchld);signal-hook,os_pipe,libcandwindows-syswere already there.shared_childdoes reachSIGCHLDon Unix — there is no "wait for this child with a deadline" syscall, so a timed wait iswaitid(WNOWAIT)plus aSIGCHLDself-pipe polled against the deadline. The difference fromwait-timeoutis what its handler does on a failed wake: it goes throughsignal_hook, which discards write errors on purpose ("we ignore errors, on purpose. We don't have any means to handling them"), so a denied wake costs at worst a wait that runs to its deadline instead of returning early.signal_hookalso probes the wake fd and falls back towrite()when it isn't a socket, andsigchldhands it a pipe — sosend(), the syscall that sandbox denies, isn't even on the path.Two incidental wins over the in-tree waiter this PR carried first: the timed wait is non-reaping, so the child stays a zombie across the deadline and the stale-pid window at both kill sites is gone; and
kill()goes through theChildhandle rather than a raw signal by pid. Process-group teardown stays with the existingkill_timed_out_tree, whichrun_with_timeout_implstill calls first for its group leader.One pre-existing quirk this deliberately does not change, recorded because an earlier revision of this PR did change it:
forward_signal_with_escalationdecides whether to escalate by pollingkillpg(pgid, 0), and a zombie is still a member of its process group, so an unreaped leader keeps that probe true through the 200 ms grace and the group SIGKILL follows the SIGTERM essentially every time — defeating the "git's lockfile handlers run on TERM" rationale the docstring gives for sending TERM first.wait-timeoutleft the leader unreaped and so doesshared_child, so the behavior is identical before and after. (The in-treeChildWaiterthat74470c2deleted reaped asynchronously and did let a TERM-respecting child skip the SIGKILL; that is gone along with the waiter.) Worth fixing on its own, by giving the escalation probe something better than group liveness to read.Two of the three sites — the pager and
run_with_timeout_impl— now fold theirErrarm into their timeout arm, because a failed wait at either needs the same teardown a timeout does. (Cmd::delayed_streamkeeps its standaloneErrarm, unchanged fromwait-timeout: its Phase 1 timeout is a display threshold rather than a wall-clock bound, so a failed wait there un-bounds nothing. Whether it should fall through to streaming instead of returning is a separate question, left off this head.) The pager's two arms already did the same thing, so merging them was free.run_with_timeout_implwas the load-bearing one: it propagated a failed wait straight out of thethread::scopeclosure with nokill_timed_out_treeand nochild.kill(), and the scope then joins the reader threads on the way out — they sit inread_to_enduntil the child closes its pipes, so the caller waited out the child's full runtime (~127 s per address for thegit ls-remotecase) and got back an error that isn'tTimedOut. That is precisely what the function's own docstring says the teardown exists to prevent. The shape is pre-existing —wait-timeout'swait_timeouthad the same?— but this PR makes theErrmaterially more plausible, sinceshared_childallocates a pipe and registers aSIGCHLDhandler on every timed wait wherewait-timeoutset its self-pipe up once, process-wide. Leaving a path where a denied syscall in the timed-wait machinery silently removes the bound would undercut the point of the PR.Testing
The
EPERMtrigger needs a seccomp sandbox that denies the send, which isn't reproducible from CI.test_wait_timeout_crate_stays_out_of_the_dependency_graphpins the root cause instead:wait-timeoutmust not return toCargo.lock, however it gets there. (An earlier revision of this PR asserted theSIGCHLDbit was clear in/proc/self/status'sSigCgtmask; withshared_childa handler legitimately exists, and its absence was never the property that mattered — a handler that can't abort is.)Behavior is covered by the existing timeout tests:
test_cmd_timeout_kills_slow_command,test_cmd_timeout_bounds_wall_clock_with_surviving_grandchild, and thedelayed_streamthreshold tests. Two tests are new:test_pipe_through_pager_times_outpins the pager's kill-and-fall-back, andtest_cmd_timeout_surfaces_a_spawn_failurepins that an unspawnable command fails as a spawn error rather than being waited out to the deadline. The mergedErr-or-timeout arms run every line under the existing timeout tests; the one thing no test drives is the branch that unwraps a real wait error, which needspipe/sigaction/waitidto fail and shares its lines with the timeout path.Also run:
cargo test --all-features(lib 1512, bins 976, integration 2138 pass — 11 pre-existingconfig_showsnapshot failures in this sandbox, which hasnuonPATH; they fail identically with these changes stashed),cargo clippy --all-features --all-targets,cargo fmt --check,RUSTDOCFLAGS=-D warnings cargo doc --no-deps --all-features.Out of scope: the pager timeout doesn't bound wall-clock
Writing the pager test surfaced a pre-existing hole unrelated to this issue, left alone here.
pipe_through_pagerkills only the pager itself and then joins its stdout reader, so a pager that forks leaves a grandchild holding the pipe's write end and the join blocks for the grandchild's full runtime —PAGER_TIMEOUTbounds nothing. Measured directly:sh -c "sleep 30"forks rather than execs, the kill lands at 2.0 s, and the reader joins at 30.0 s.run_with_timeout_implsolved the same problem by spawning into its own process group and signalling the group; the pager never got that treatment. The test uses an explicitexecto stay on the case this PR is about.Upstream context
alexcrichton/wait-timeout#45 flags this
notify()panic as an async-signal-safety violation; it's open and classified as theoretical.process_controlis the other credible dependency here and resolves the bug the same way —waitid(WNOWAIT)withsignal_hook'sSignalsiterator forSIGCHLD— but it's a heavier API (aControlbuilder over the child) for a narrower user base, soshared_childwon.Closes #3856 — automated triage