Repository navigation
Conversation
The guardian ignores HUP/INT/TERM and its sleep tick inherits that disposition, so the EXIT trap's SIGTERM never stopped the tick and the following wait blocked for up to a second on every acq invocation. Stop the tick with SIGKILL instead. Orphan detection, root removal and the USR1 handshake are unchanged. Add a lifecycle test that bounds exit latency and asserts no root, guardian or sleep tick survives exit, SIGHUP/INT/TERM, a replaced EXIT trap, exec, or SIGKILL of the owner. Refs: #655 Co-authored-by: OpenCode Agent <user@gsa.gov>
Review found the exit/signal cases tolerated an orphaned sleep tick that ended on its own within the 5s settle bound. Hold those cases to the tight latency bound so a tick left behind fails the test, and clarify the comment on why SIGKILL is required. Co-authored-by: OpenCode Agent <user@gsa.gov>
|
🤖: AI agent (First Mate), AI-assisted; human review required. CI at head 7e90ac8 is green: pre-commit (8m34s), Conventional Commit Title, and the bash 3.2 offline suite (build job 15m52s). The bash 3.2 job duration did not visibly drop. Other recent runs of the same job took 11m38s (chore/649) and 13m56s (fix/648), so run-to-run noise (runner, bash build/cache) is larger than the saving. The measured, controlled gains are in the local table above. Re-measured on the pushed head, 5 runs each with |
wz-gsa
left a comment
There was a problem hiding this comment.
Fixed-head adversarial review of 7e90ac882ecc7d562de8597afebda435a052f91d (complete three-file diff inspected; all required checks pass). Major — acq:737-745: the guardian keeps tick=$! after wait "$tick" has already reaped the child and only clears it on the next line. If USR1 arrives in that window, the EXIT trap sends SIGKILL to a numeric PID it no longer owns; a reused PID could be an unrelated same-user process. Bind cleanup to an actual live child/reap state rather than sending an unqualified SIGKILL to a stale saved PID, and keep the fast exit guarantee. Test cleanup — test/bats/kit-fetch-exit-latency.py:93-102,131-137: Case.__init__ starts a child before parsing READY and finding a tick, but failures there precede callers’ try/finally; the test can leak the acq owner/guardian/root. close() also kills stored guardian/tick PIDs based on existence rather than identity. Make construction exception-safe and reap the owned Popen child; do not kill a saved PID after identity is lost. The timing/cleanup scenarios are useful, but green CI does not close these windows. Static inspection only, no PR code run on the host. AI-assisted review (OpenCode); rereview a corrected head.
The guardian kept tick=$! after `wait` had reaped the child, so a USR1 handled in that window sent SIGKILL to a PID it no longer owned. The exit trap now asks ps whether the saved PID is a `sleep` whose parent is the guardian and signals nothing otherwise, keeping the millisecond exit. The latency test gains deterministic coverage of the window (reused-PID bystander, stale PID, live non-sleep child), and its Case setup is now exception-safe, reaps the owner and kills only PIDs whose recorded identity (command and start time) still matches. Co-authored-by: OpenCode Agent <user@gsa.gov>
…USR1 Ignore USR1 and disable globbing once the guardian starts stopping its tick, so a repeated USR1 cannot abort the exit trap before the root is removed (the ps check widened that window from microseconds to milliseconds). Adds a test that streams USR1 while a slowed ps runs; the guard-removed mutant fails it. Co-authored-by: OpenCode Agent <user@gsa.gov>
|
🤖: AI agent (First Mate), AI-assisted; human review required. Addressed both review items on new head (A) Stale
|
| Test | What it checks |
|---|---|
window-bystander-spared |
tick points at an unrelated live sleep that is not the guardian's child, and it must survive. |
window-stale-pid-clean |
The real stale PID case. |
window-double-usr1-clean |
USR1 stream plus slow ps. |
window-non-sleep-child-spared |
tick is a live guardian child that is not sleep. |
Evidence
Mutations (each run against both kit-fetch-lifecycle.py and kit-fetch-exit-latency.py):
| Mutation | Result |
|---|---|
guardian rm -rf removed |
killed by both |
| orphan check disabled | killed by both |
| USR1 handler disabled | killed by 104 (root left behind) |
kill -s KILL reverted to kill |
killed by 104 (exit 1.03 s) |
| tick kill removed | killed by 104 (exit 1.03 s) |
| ppid/identity guard removed | killed by 104 (killed an unrelated process) |
| ppid check only removed | killed by 104 (killed an unrelated process) |
sleep comm check removed |
killed by 104 (killed a non-sleep child) |
trap "" USR1 removed |
killed by 104 (root left behind) |
The USR1 handler mutant survives the lifecycle test; the exit-latency test kills it.
Timings. ACQ_BACKEND=sbx, 5 runs, in ms:
acq --help |
acq version |
|
|---|---|---|
| before the PR (ef24965) | 1037 1057 1042 1052 1036 | 1034 1041 1038 1037 1041 |
| 7e90ac8 | 89 81 97 131 99 | 282 271 272 259 415 |
| 42fb94d | 87–90 | 213–388 |
The extra ps fork is not measurable against the ~1 s saved.
Other shells.
- Real bash 3.2.57:
kit-fetch-exit-latency.pypasses all 11 cases. Inkit-fetch-lifecycle.pythenestedcase fails identically on 7e90ac8 and on the new head, because the relocatable bash wrapper cannot re-exec$BASH. With that case stubbed out it passes up toforeground SIGINT group, and the later failure is also the same on 7e90ac8 and the new head. Both look like fixture artifacts of the relocatable wrapper, not regressions. The CI bash 3.2 job is the authoritative run and passed. - The reviewer also ran the guardian under dash, bash 5.3 (
--posix) and bash 3.2.57 (--posix): all window tests pass. scripts/run-shellcheckshows no new findings.
Reviews. Two blocking adversarial sub-agent reviews, covering races, signals, PID reuse and leaks.
- Review 1: no blockers, one should-fix (the repeated-USR1 case), now fixed.
- Review 2: no blockers or should-fixes. It left a theoretical NIT: a USR1 burst of 8 before
trap "" USR1has run can still re-enterexit 0. Nothing inacqcan produce that, because the owner sends exactly one USR1.
🤖: AI agent (First Mate), AI-assisted; human review required.
Closes #655
Context
Every
acqinvocation waited about one second at exit for the kit-fetch guardian. Root cause (reproduced): the guardian subshell runstrap '' HUP INT TERM, so itssleep 1tick inherits an ignored SIGTERM. A non-interactive shell cannot reset a signal ignored on entry, sosh -c 'trap - TERM; exec sleep 1'does not help (measured: still ~0.68 s). On USR1 the guardian's EXIT trap rankill "$tick"(ignored) and thenwait "$tick", which blocked until the tick ended by itself.The diagnosis in the issue is correct. One clarification: the guardian's tick is the only thing waited on;
ps/trin the loop are not involved.Plan / change
acq: the guardian's EXIT trap useskill -s KILL "$tick"(one word) plus a comment explaining why KILL is required.test/bats/104-kit-fetch-exit-latency.bats+kit-fetch-exit-latency.py(new files;kit-fetch-lifecycle.pyuntouched, so no conflict with perf(test): shorten hostport and cli-kits test tail #654).User-visible behavior change
Faster: every
acqcommand no longer pays up to ~1 s at exit.acq version(real, sbx backend selected)acq --help*The "after" run overlapped other load on the box (first-run/cache effect); isolated runs earlier measured 0.04-0.10 s for both. Timing without a backend selected (
acq versionexits 1 with no backend here) was 0.01 s both before and after because it exits before the guardian wait matters; the numbers above useACQ_BACKEND=sbx.Observable differences:
kill -s KILLon the tick prints no job-control message in dash, bash 5.3, bash 5.3--posix, bash 3.2.57, bash 3.2.57--posix(checked; tests assert empty stderr).ps -o ppidonce per second and removes the root when the owner is gone. Residual window before and after: up to ~1 s plus the poll (measured 1.0-1.03 s). Not made faster; out of scope.acqexec-ing a long-running child (sbx/msb session): unchanged. The guardian still outlives the exec'd program's start and removes the root within ~1 s of the owner no longer being the guardian's parent (measured ~1.0 s before and after). During a long exec'd session the guardian keeps polling exactly as before.Does not change:
$$, stdin, terminal signals, exec semantics, the USR1 handshake, the ready handshake, orphan detection, and what is removed (rm -rf -- "$root"of the privateacq-kit-fetch.*root only).Verification transcript (Linux aarch64, dash /bin/sh, bash 5.3)
killTERM 0.69 s (dash) / 0.66 s (bash) ->kill -s KILL0.006 s / 0.012 s. Under real bash 3.2.57: 0.707 s -> 0.004 s;bash --posix: 0.704 s -> 0.005 s.trap - TERM; exec sleep 1alternative: still ~0.68 s.ACQ_BATS_JOBS=1, bats wall time, includes load noise from concurrent runs):(An earlier un-selected-backend run of 60-help showed 6.7 s -> 5.5 s; absolute numbers vary heavily with host load.)
test/bats/103-kit-fetch-lifecycle.bats: pass. New104-kit-fetch-exit-latency.bats: pass (exit, SIGTERM/INT/HUP each < 0.5 s with root, guardian and tick gone; replaced trap, exec, parent SIGKILL: removed within bound, ~1 s).scripts/fetch-bash32-oci.pyfromfeat/632-local-bash32-runner, nothing pushed to that branch): the new test and the fullkit-fetch-lifecycle.py(21 cases, with$BASHpointed at 3.2.57) pass.rm -rfremoved (103 and 104 fail); orphan check|| exit 0->|| :(103 and 104 fail); USR1 handler disabled (103 and 104 fail);kill -s KILLreverted tokill(104 fails:('exit', 0, 1.02)); tick kill+wait removed (104 fails: leaked tick);while trueloop mutation passes, expected because the loop condition is not a guarantee the tests exercise beyond what the orphan check covers.waitreturns, so no new pid-reuse risk beyond what TERM already had.scripts/run-shellcheck acq test/bats/104-kit-fetch-exit-latency.bats: no new findings (existing SC2034 warning is in another file's context, unchanged).CI bash 3.2 job duration: to be added once CI reports.
Rollback
Revert this commit (one-word change in
acq; tests are additive).Security impact
None widened. Only the guardian's own child
sleep(a pid it spawned and still holds unreaped untilwait) receives SIGKILL instead of SIGTERM. The only deleted path remains the mktemp-created private root (rm -rf -- "$root", unchanged). No new network, credential, privilege or file-access behavior. A pid-reuse hazard cannot arise from the changed line any more than with SIGTERM.