Repository navigation
Stop the isolation boundary before cleanup on pid-targeted cancellation - #178
Merged
Merged
Conversation
[Medium impact] The PR appears safe to merge; the previous startup-cancellation issue is fixed. SummaryThe PR stops sandboxed commands before removing their staging files or archive when cancellation targets only the script’s pid.
The previous startup-cancellation finding is fixed. No new actionable issues were found. Tests were not run during this review.
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
A TERM, INT, HUP, or QUIT sent only to isolated-check.sh or isolate-run.sh ran the script's EXIT trap and removed the archive or stage while the sandboxed command kept running, reparented and with network access, and on the --live path with CURSOR_API_KEY. Neither script signalled its child, and the namespace init forwards only signals it receives itself. A process-group signal reached the boundary directly, which is why the existing termination test passed. isolate-run.sh now runs the boundary as its own session and waits for it, ported from oakimov/opencode-plugin-compat@c78395c. Cancellation signals are forwarded to that session (unshare ignores TERM while it waits; the namespace PID 1 shares its process group), and the session is killed after ISOLATE_TERM_GRACE seconds (default 10). Leftover session members are killed before staging files are removed. INT and QUIT, ignored in a non-interactive background job, are restored for the command. isolated-check.sh starts every boundary through check_supervise: the checks, and the live test that holds the key. It runs isolate-run.sh in the background, passes a cancellation on as TERM, waits for the boundary to stop, then exits with 128 plus the signal number so its EXIT trap removes the archive last. live-test.sh stopped its OpenCode process before removing its files on TERM, INT, and HUP, but QUIT left that process running: bash 3.2 dies without running the EXIT trap and bash 5 keeps waiting. QUIT is now trapped like INT and TERM. New tests signal only the script pid. On Linux (namespaces) and macOS (sandbox-exec) they fail on the previous scripts, leaving the command running, and pass with this change. SIGKILL still cannot be forwarded.
A trap that bash takes between two commands does not end the `wait` that follows it. TERM, INT, HUP, or QUIT trapped before isolate_supervise had stored the leader's pid, or just before its `wait` started, was recorded, but the stop sequence ran only after `wait` returned. isolate-run.sh then waited for the command to end by itself, forever for a command that ignores TERM. Nothing was removed under the running command, but the cancellation never finished. The trap now runs the stop sequence itself (isolate_sup_stop): send the signal to the session once it exists, kill the session after ISOLATE_TERM_GRACE, and return when the leader has gone. isolate_supervise calls it once more for a signal trapped before the leader was known. Two tests take the trap at each boundary through a BASH_ENV hook whose DEBUG trap signals the script just before the named command. Both fail on the previous script, which never exits, and pass with this change.
The ownership fixture in test/review-eval.test.ts starts a descendant that appends to `heartbeat` in the runner's workspace every 50 ms, opening the file with create. The defective runner variants remove that workspace while the descendant still runs. A write that landed between the runner's unlink and rmdir recreated the file, the removal failed with ENOTEMPTY, and the runner exited 1 with the workspace in place instead of the status the test expects. It failed one full macOS run in six. The descendant now creates the file once, before it reports ready, and appends without creating. With a writer that does not pause, the `term` runner failed 15 of 15 runs before and none of 15 after.
oakimov
force-pushed
the
isolate-pid-cancellation
branch
from
November 10, 2026 19:56
9bd4875 to
c03f2ac
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A TERM, INT, HUP, or QUIT sent only to the pid of
scripts/isolated-check.shorscripts/isolate-run.sh(what a tool runner'skill <pid>does) ran the script's EXIT trap, which removed the archive or stage, while the sandboxed command kept running: reparented, with network, and on the--livepath withCURSOR_API_KEY. Neither script signalled its child, and the namespace PID 1 forwards only signals it receives itself.forwards termination to the command sessionsignals the whole process group, which reaches the boundary directly, so it did not catch this.Fix
scripts/isolate-run.sh:isolate_supervise, ported from oakimov/opencode-plugin-compat@c78395c. The Linuxunsharecall and the macOSsandbox-execcall both go through it. The boundary runs as its own session from a background job, with stdin kept and INT/QUIT restored. TERM, INT, HUP, and QUIT are forwarded to that session; a session still runningISOLATE_TERM_GRACEseconds later (default 10) is killed, and anything left in it is killed before the staging files are removed. A cancelled run exits 128 plus the signal number. The file follows the reference except for one header sentence on SIGKILL and the stop sequence in the next item.scripts/isolate-run.sh, beyond the reference: a trap that bash takes between two commands does not end thewaitthat follows it. A signal trapped beforeisolate_supervisehad stored the leader's pid, or just before itswaitstarted, was recorded, and the script then waited for the command to end by itself: forever for a command that ignores TERM. Nothing was removed under the running command, but the cancellation never finished. The trap now runs the stop sequence itself (isolate_sup_stop): send the signal once the session exists, kill the session after the grace period, return when the leader has gone.isolate_supervisecalls it once more for a signal trapped before the leader was known. The same boundaries incheck_supervisealready exit 143 at once.scripts/isolated-check.sh: one wrapper,check_supervise, starts every boundary.check_run_isolatedandcheck_run_live(the call that passes the key) both use it. It runsisolate-run.shin the background, passes a cancellation on as TERM, waits until it has exited, then exits 128 plus the signal number so the EXIT trap removes the archive last.scripts/live-test.sh: it already stopped its OpenCode process before removing its files on TERM, INT, and HUP. QUIT left that process running (bash 3.2 dies without running the EXIT trap, bash 5 keeps waiting), so QUIT is now trapped like INT and TERM.No provider code changes:
src/anddist/are untouched, so prompt caching and both OpenCode majors are unaffected.Verification
Each new test signals only the script's pid and checks with
psthat the command is gone once the script has exited.isolate-run.sh kills a command that ignores TERM before removing its stageisolate-run.sh forwards TERM so the command can stop itselfisolated-check.sh waits for the boundary before removing the archive on SIGTERMisolated-check.sh waits for the boundary before removing the archive on SIGINTisolated-check.sh waits for the live boundary, which holds the key, before removing the archiveremoves plugin-list files and stops OpenCode when the live test receives SIGQUITisolate-run.sh stops a command when TERM is trapped before the session leader is knownisolate-run.sh stops a command when TERM is trapped just before the wait startsThe same before and after on both platforms; for the last two rows, "unfixed" is the first commit of this PR. The live-boundary case runs the real
check_run_liveagainst a stand-inlive-test.shand a placeholder key. The last two cases take the trap at an exact command boundary: aBASH_ENVhook sets a DEBUG trap that sends TERM to the script just before the named command.sandbox-exec, bash 3.2 and 5.3):bun run typecheck,bun run build, thenbun test ./testthree times in a row: 3414 pass, 5 skip (the Linux-only cases), 0 fail each time, with no stored Cursor credential visible to the tests. No marker process left inps -A -o args=afterwards.test/review-scripts.test.tsthree times in a row: 54 pass, 0 fail, 0 skip each time, no marker process left.unshare --forkstays alive after TERM to its pid, the namespace PID 1 is in its process group, and a group SIGKILL ends the namespace.Fixture fix in
test/review-eval.test.tsOne full macOS run in six failed in
only the settling runners leave no owned process or workspace behind; the transfer keeps its service: thetermrunner returned 1 with its workspace in place instead of 143. The fixture's descendant appended toheartbeatin the workspace every 50 ms and opened the file with create, so a write between the runner's unlink and rmdir brought the file back and the removal failed with ENOTEMPTY. The descendant now creates the file once, before it reports ready, and appends without creating. With a writer that does not pause, thetermrunner failed 15 of 15 runs before and none of 15 after. It is a separate commit.Other scripts
scripts/test-harness.pydefers INT and TERM to checkpoints and settles the check's process group before it removes the snapshot. It removes nothing on any other signal.scripts/review-publish.py,scripts/complete-resolutions.py,scripts/review-eval.py, the install and configure scripts, and the TypeScript generators start no background child and have no exit handler that removes files.Limits, now documented in
docs/pr-review.mdsandbox-execis not stopped with the boundary. On Linux the namespace ends with its PID 1.