Files
max-sixty__worktrunk/tests/cancel_background.rs
T
Maximilian Roos bb43011c8d fix(picker): cancel background commands when the picker exits (#3560)
## What

`shell_exec` gains `cancel_background_commands()`: it latches a flag
that stops
background `Cmd::run` / `Cmd::pipe_into` before they spawn, and SIGTERMs
the
PIDs of those already running (tracked in a registry each capture-mode
command
joins for its lifetime). The picker calls it once, after skim releases
the
terminal and after the stashed-warning drain.

Preview diffs pick up `--no-optional-locks`, matching what `wt list`'s
`git status` already does: a preview reads a worktree the user may be
working
in, and can now be signalled mid-run.

Cancellation covers both accept and abort, and only ever targets
background
threads — the foreground thread is the one cancelling, and goes on to
run the
switch itself.

## Why

Accepting the picker abandoned its preview work rather than stopping it.
`wt`'s
exit ends the pool's threads but not the `git` children they spawned, so
those
children kept running, orphaned, computing diffs into an in-memory cache
that no
longer existed — churning disk on a repo the user had already left.

Measured on a 5-worktree fixture whose `git diff` sleeps 15s, with a
shell (not
`wt`) as the PTY session leader, since `wt` as session leader SIGHUPs
its own
children on exit and hides the effect entirely:

| | children alive at t+0 / +1 / +3 / +6s after `wt` exits |
| --- | --- |
| without cancellation | 12 / 12 / 12 / 12 |
| with cancellation | 0 / 0 / 0 / 0 |

Single-variable control: same build, same fixture, cancellation gated
off.
Accept latency is unchanged (0.13s).

## Why a latch, not just a sweep

A one-shot sweep over live PIDs isn't enough on its own. A task that has
already
cleared its caller's supersede check and parked on `CMD_SEMAPHORE` holds
no PID
for the sweep to find, and spawns the moment a permit frees — the very
permits
the sweep just freed by signalling everything holding one.

Capping concurrency at 2 to force a queue reproduces it: sweep-only
still leaves
survivors, the latch reaches 0. For the same reason a freshly spawned
child
re-reads the flag as it registers, so a child spawned into the sweep's
window
escapes neither.

This also replaced a first attempt that bumped the preview
orchestrator's spawn
generation. That stops *queued* tasks, but those tasks had already
passed the
generation check before parking on the semaphore, so it measured no
better than
the sweep alone. One mechanism in `shell_exec` covers all three states —
queued,
parked, running — so the orchestrator is untouched.

## Notes

- **`alt-x` removals are exempt.** A removal is dispatched to its own
thread so
  the picker stays live, so pressing Enter straight after can leave a
  `git worktree remove` in flight when the sweep fires. Its *result* is
discardable; its *effects* are not, and a signal between the worktree
remove
  and the branch delete would strand the user half-removed.
`shell_exec::uninterruptible` exempts a thread from both halves — the
sweep
skips its PIDs, and the latch doesn't refuse the calls it makes
afterwards,
since a removal is several git calls rather than one. The picker routes
every
  removal dispatch through one `spawn_removal` helper that applies the
exemption, so it's carried by the spawn path itself rather than
remembered at
  each call site.

- **SIGTERM, not SIGKILL**, so git's lockfile handlers run rather than
stranding
  an `index.lock` in a worktree the user is about to work in.
- **Cancel is ordered after `drain_stashed_warnings`.** A `--prs` forge
call
killed mid-flight fails like any other and stashes that failure as a
warning;
cancelling first would print a spurious "couldn't fetch PRs" to the
user.
- **`Cmd::run`'s plain branch now spawns explicitly** instead of calling
`cmd.output()`, whose stdio defaults it reproduces exactly — `output()`
hands
  back only the finished result, never the running child.
- **Windows** gets the latch but not the signal; a command already
running there
  still finishes. Bounding those would take a job object.
- **Tests.** The cancelled paths (both refusals and the mid-flight
signal) are
covered by `tests/cancel_background.rs`, deliberately its own test
binary:
cancellation latches process-wide state and signals every background PID
in
the process, so beside other tests it takes them out — `TestRepo`'s
helpers
drive git through the same `Cmd` path, and this is a real failure, not a
theoretical one. Alone in its process it has nothing to collide with,
and the
  test is deterministic (a file marker for readiness, not a sleep).

An earlier revision of this description claimed `codecov/patch`
contradicted
its own line data. That was wrong — a mis-read of the API on my part,
and the
check was right. Local `cargo llvm-cov` had been failing to build `skim`
(E0554), so I'd taken the API as a substitute for measuring. The build
failure
was a stale local tool: cargo-llvm-cov ≥ 0.7.0 instruments only
workspace
crates instead of the whole dependency graph, so on CI's pinned 0.8.7
`skim`
never sees the `cfg(coverage)` that trips its nightly feature gate. With
that
upgraded, local coverage reproduces codecov's four missed lines exactly.

Three were in `test_background_command_registers_while_running`, now
removed —
its poll loop's body only ran when the spawned thread hadn't registered
yet.
Coverage is the lesser reason: the test polled a process-global registry
for
non-emptiness, so under the shared-process runner the coverage job uses
it
could be satisfied by another test's PID and pass without its own
command ever
registering. `tests/cancel_background.rs` pins that property correctly,
and
more strongly — the sweep reaches a running command only if it
registered.

Three lines are left uncovered, knowingly. One is the spawn-race
re-check,
  which by construction only fires on a race that can't be scheduled
deterministically — covering it would need a scheduling hook in library
code,
which the repo's "no test code in library code" rule rules out. The
other two
  are pre-existing: the `spawn_removal` refactor re-indents the dispatch
  closures, which codecov counts as new patch lines, pulling in the
morph-failure backstop (the `warn` and `revert_morph` calls). That
backstop
only fires when a file appears in the TOCTOU window between the
removal's
safety check and the rename, so it can't be deterministically tested
either.
If `codecov/patch` reads red, this is why — the structural exemption was
  judged worth more than the metric.

- **PID reuse.** Between the kernel reaping a child and the guard's
`Drop`
removing its PID, a freed PID is briefly still listed. Inherent to
signalling
  by PID — the reap and the deregistration aren't one operation — and
deregistering before the wait isn't a fix but a removal, since the wait
*is*
the command's lifetime. Documented on `BackgroundPid` rather than
mitigated.

> _This was written by Claude Code on behalf of Maximilian Roos_

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 16:36:25 -07:00

137 lines
5.2 KiB
Rust

//! Cancellation of background commands (`worktrunk::shell_exec`).
//!
//! This lives in its own test binary because `cancel_background_commands`
//! latches process-wide state and signals every background PID in the process.
//! Folded into `integration`, it would take out whatever ran beside it —
//! `TestRepo`'s helpers drive git through the same `Cmd` path — and under a
//! shared-process runner that is a real failure, not a theoretical one. One
//! test, alone in its process, has nothing to collide with.
#![cfg(unix)]
use std::io::ErrorKind;
use std::time::{Duration, Instant};
use worktrunk::shell_exec::{Cmd, cancel_background_commands, uninterruptible};
/// Cancelling reaches a command that is already running and refuses one that
/// has not started yet — the two halves of the guarantee, since a sweep over
/// live PIDs can't see a task still queued behind the command semaphore — while
/// leaving an `uninterruptible` thread's commands alone in both directions.
///
/// The first half is also what pins PID registration: the sweep can only reach
/// a running command that registered itself, so if registration regressed this
/// test's `sleep` would run to completion. Asserting on the registry directly
/// instead would be weaker — it is process-global, so under a shared-process
/// runner the assertion can be satisfied by some other test's command.
///
/// Every command runs on a spawned thread: the foreground thread is exempt (it
/// is the one doing the cancelling), and with `FOREGROUND_THREAD` unset under a
/// test harness, every other thread counts as background.
#[test]
fn cancel_stops_speculative_commands_and_spares_uninterruptible_ones() {
let dir = tempfile::tempdir().unwrap();
let started = dir.path().join("started");
let marker = started.display().to_string();
// `exec` so the sleep *is* the process we spawned. Left as a child of the
// shell, it would survive the signal to its parent and keep the captured
// stdout pipe open, blocking the wait for the full 30s.
let running = std::thread::spawn(move || {
Cmd::new("sh")
.arg("-c")
.arg(format!("touch '{marker}'; exec sleep 30"))
.run()
});
// Wait for the child to be genuinely running, so the sweep has a live PID
// to find rather than racing the spawn.
let deadline = Instant::now() + Duration::from_secs(30);
while !started.exists() {
assert!(Instant::now() < deadline, "the command never started");
std::thread::sleep(Duration::from_millis(10));
}
// A mutation the user already asked for — an `alt-x` removal — runs on a
// thread that opts out, and must survive the sweep rather than be killed
// between two git steps.
let protected_marker = dir.path().join("protected");
let protected_path = protected_marker.display().to_string();
let protected = std::thread::spawn(move || {
uninterruptible(|| {
Cmd::new("sh")
.arg("-c")
.arg(format!("touch '{protected_path}'; exec sleep 2"))
.run()
})
});
while !protected_marker.exists() {
assert!(
Instant::now() < deadline,
"the exempt command never started"
);
std::thread::sleep(Duration::from_millis(10));
}
let cancelled_at = Instant::now();
cancel_background_commands();
let output = running
.join()
.unwrap()
.expect("a signalled command still yields its Output");
assert!(
cancelled_at.elapsed() < Duration::from_secs(25),
"the sleep ran to completion instead of being signalled"
);
assert!(
!output.status.success(),
"a signalled command should report failure"
);
// Anything starting afterwards is refused before it spawns a process.
let refused = std::thread::spawn(|| Cmd::new("sh").arg("-c").arg("sleep 30").run())
.join()
.unwrap();
assert_eq!(
refused
.expect_err("a cancelled command must not spawn")
.kind(),
ErrorKind::Interrupted
);
// Same for the two-process pipeline path, which spawns on its own.
let piped = std::thread::spawn(|| {
Cmd::new("sh")
.arg("-c")
.arg("sleep 30")
.pipe_into(Cmd::new("cat"))
})
.join()
.unwrap();
assert_eq!(
piped
.expect_err("a cancelled pipeline must not spawn")
.kind(),
ErrorKind::Interrupted
);
// The exempt command ran to completion despite the sweep.
let protected_output = protected
.join()
.unwrap()
.expect("an uninterruptible command runs");
assert!(
protected_output.status.success(),
"cancellation must not signal a command the user asked for"
);
// And the latch doesn't refuse its *later* commands either — a removal is
// several git calls, not one, and the ones after the sweep must still run.
let after = std::thread::spawn(|| uninterruptible(|| Cmd::new("true").run()))
.join()
.unwrap()
.expect("an uninterruptible command still spawns after cancellation");
assert!(after.status.success());
}