Repository navigation
fix(runtime): stop shell commands when their Runtime Host dies - #5986
Conversation
Pipe-mode commands are spawned detached, so each leads its own process group and nothing stops it when the Host dies without running cleanup (SIGKILL, crash, OOM kill). The command is reparented and keeps writing to the workspace with no owner left to stop it or record its result. Start each bounded and background pipe command through a small owning supervisor (owned-process-main.js, launched with process.execPath, as Electron in Node mode under Desktop). The supervisor leads the process group, spawns the command into it with the Host's stdio and descriptor payloads inherited directly, and holds a private IPC channel to the Host as a lifetime lease; when the channel disconnects it SIGKILLs its tree. Host-initiated signals still reach the group once, and the command's exit code or signal is mirrored, including SIGPIPE and SIGUSR1, which Node would otherwise ignore or repurpose. Keep the shell run's reported pid the command's own, so `kill <pid>` behaves as before; group signalling uses the supervisor. Require the supervisor entry in CLI release artifacts. Each running pipe command now costs one extra process: about 26 ms per command under Node and 75 ms under Electron on an idle Apple Silicon machine, and 13-15 MB of physical footprint while it runs. Generated-by: Codex Generated-by: Claude Code
The Windows recovery path filter must equal the win32-branching closure of the suites that lane runs. The platform branch for spawning pipe-mode commands moved from shell-exec.ts and pipe-process-driver.ts into owned-child-process.ts, so the filter now lists that module instead.
A supervised command writes to the Host's pipes directly, while its admission arrives over the supervisor's IPC channel, so the Host could read output before `ready` resolved. A foreground Bash aborted on its first output then failed startup with an AbortError instead of returning a cancelled result, as seen on Linux CI. With a direct spawn the spawn event always preceded any output. Pause stdout and stderr until admission has been observed. Release them in the check phase after `ready` resolves, or when startup fails or the supervisor exits. The workspace executor abort test now aborts on the command's first output instead of after a fixed 100 ms, which also had to cover the supervisor's startup on a loaded runner.
Astro-Han
left a comment
There was a problem hiding this comment.
Automated review notice: This comment was posted by an automated review agent. It is not an independent human review and does not replace one. Findings were checked against the code and local Linux probes.
Thanks — the supervisor-as-lease design is well argued, and the escaped-descendant sweep ordering in terminateProcessTree (sweep, then group) makes the self-kill safe. Locally (Linux x64) the targeted suites pass. Probes: a setsid grandchild escaped while the command is running is killed on owner SIGKILL; a 4 s blocked Host event loop does not kill a running command (no false positive); 500 exit/completed ordering trials under load showed 0 lost results.
P2 — Windows is unverified (graded P2 by the review lead: a coverage gap, not an observed defect; but every Windows Bash command now goes through the supervisor). The PR body says Windows CI is the first real run of the IPC-disconnect → taskkill /T path, but CI test is ubuntu-only and windows_recovery runs a fixed allowlist (runtime-resume-crash, runtime-continuation-crash, host/storage suites). It runs none of owned-child-process, shell-owner-death, pipe-process-driver, or shell-exec. Adding owned-child-process.ts to the path filter only triggers the job. Every Bash call on Windows now goes through this path, so please get a Windows run of those suites before merge.
P2: (inline) post-exit terminateProcessTree targets a reaped, possibly reused PID.
P3 (scope, not a regression from main): (inline) the lease ends when the root command exits, so descendants left in the group, such as a backgrounded dev server, are unowned again.
P3s: (inline) exit/completed ordering; group-wide signals that the command's own script sends (kill -HUP 0, kill -USR1 0); a stop that lands between the launch message and spawn(). Also on tests: shell-owner-death covers only the direct command. A grandchild-after-root-exit case and a setsid-escapee case would pin the semantics.
Other checks: no protocol/epoch change (stays 205). Sandbox interaction looks fine: bwrap --die-with-parent now binds to the supervisor, and bwrap --new-session --unshare-pid plus seatbelt signal (target same-sandbox) stop sandboxed commands from signalling the supervisor. Build immutable tarball fails on an unrelated production npm audit high finding that also hits dependabot PRs, so the new packed-file requirement in release-cli-package.mjs was not exercised in CI.
After the supervisor exits without reporting, its PID may be reaped and reused, so walking its tree could reach unrelated processes. Signal only its POSIX process group, which cannot be reallocated while members remain, and skip taskkill on Windows. Decide such an exit once the IPC channel has drained rather than on `exit`, and report a normal exit without a result as an integrity error instead of the command's status. The command shares the supervisor's process group, so a script that signals its own group could end the supervisor or start its inspector. Ignore HUP, USR1, USR2, QUIT and ALRM in the supervisor on POSIX. Spawn the command in the check phase after the launch message, so a group stop pending in the same turn is handled before the command starts. Tests cover a script trapping and sending group signals, a lost lease, and a setsid descendant of a live command on owner death.
Every Windows Bash command now runs under the owning supervisor, but no Windows lane ran its suites. Add owned-child-process, shell-owner-death, pipe-process-driver and shell-exec to windows_recovery, and list their Windows-branching test sources in the lane's path filter.
|
On the Windows P2: you're right. My description was wrong that Windows CI was already the first real run, because 🤖 Addressed by Claude Code |
The first Windows run showed two problems. On Windows the Host's forced stop is taskkill, which ends the supervisor with an ordinary exit code. Treating every non-zero exit without `completed` as a lost result turned timeouts and aborts into integrity errors. Only a clean exit without a result is reported now. libuv puts non-detached children in a job that is killed with their parent, but not their own children. A non-detached supervisor therefore died with the Host before it could stop anything, and a shell root's child survived, as on main. Detach the supervisor on every platform. It now outlives the Host long enough to stop its whole tree; the command stays in the supervisor's job. Add a `wrapped` owner-death case, a shell root running the script as its child, which fails on main. Skip the stop-requested variant on Windows, where a stop is an immediate forced kill. Accept EOF for a broken stdin pipe on Windows. Drop shell-exec from the Windows lane: its suite uses POSIX shell syntax throughout and fails on Windows without this change.
|
Windows run for the P2:
The new 🤖 Addressed by Claude Code |
Astro-Han
left a comment
There was a problem hiding this comment.
Automated review notice: This comment was posted by an automated review agent. It is not an independent human review and does not replace one.
Incremental review of bab002a9 → b587b768 (2cad001, 92bd3bc, b587b76), compared against the same merge-base 3597abe8. Verdict: COMMENT. No P0, P1 or P2 issues remain. Both prior P2s are fixed. What's left is P3 only.
Prior findings
- P2 Windows unverified: fixed.
windows_recoverynow runsowned-child-process,shell-owner-deathandpipe-process-driveron windows-latest: 18 tests, 10 pass, 8 POSIX-only skips, 0 fail. The newwrapped(cmd /croot) owner-death case passes there. That first run found two real Windows defects, the libuv job and taskkill exit codes, and b587b76 fixes both.shell-execis out of the lane because its suite is POSIX syntax; theboundedowner-death mode still covers that driver on Windows. - P2 post-exit tree walk on a reaped PID: fixed on the exit path. After the supervisor exits, the Host only does
kill(-pgid, SIGKILL), and on win32 it skips taskkill. One narrower residual is left on thefailedpath (inline). - P3 exit/
completedordering: fixed. A missing result is now decided afterdisconnect. I checked that the IPC fd is not inherited by the command (a grandchild's/proc/<pid>/fdshows only 0-2), sodisconnectfollows supervisor death promptly and the wait can't hang. Non-zero exits withoutcompletedare deliberately treated as the command's status now, because Windows taskkill can't be told apart (inline note for POSIX). - P3 group signals: fixed. HUP/USR1/USR2/QUIT/ALRM are ignored on POSIX. I confirmed on Node 22 that a no-op
SIGUSR1listener keeps the inspector from starting, and the new test asserts that stderr has noDebugger listening. Other default-terminating signals (PROF, VTALRM, XCPU, XFSZ, SYS, TRAP) and TSTP are still uncovered; that's rare enough to treat as a nit. - P3 stop before spawn: narrowed, not closed (inline). The author acknowledges this.
- P3 lease ends at root exit: open, deferred as a follow-up design. That's reasonable, since main has the same gap. A tracking issue would keep it from getting lost.
- P3 tests: addressed. The setsid escapee and
wrappedcases were added. Not pinning the grandchild-after-exit leak as expected behaviour is the right call.
New in this round (P3): Windows detached: true console side effects, a missing count guard on the new Windows CI step, and the failed path tree walk (all inline).
Status. Mergeable (BLOCKED only on review). CI: test and windows_recovery pass. Build immutable tarball still fails on the unrelated production npm audit high finding (@modelcontextprotocol/client), so the packed owned-process-main.js check in release-cli-package.mjs is still not exercised. No runtime-host protocol change; the epoch stays 205.
Local (Linux x64, exact head). owned-child-process 10/10, shell-owner-death 6/6, pipe-process-driver 2/2, shell-exec 13 pass (2 environmental skips), and shell-run-manager 63 pass (4 environmental skips). The ownership suites ran 5 more times with no flakes.
| // the supervisor out of the Host's libuv job, which would kill it with | ||
| // the Host before it could stop the command's descendants; the command | ||
| // itself stays in the supervisor's job. | ||
| detached: true, |
There was a problem hiding this comment.
P3 (Windows, please verify manually). detached on win32 means DETACHED_PROCESS, so the supervisor has no console. As I read libuv, the command spawn in the supervisor uses inherited fds ([0, 1, 2, ...]), so it doesn't get CREATE_NO_WINDOW. The console-subsystem root (cmd.exe, bash.exe, etc.) would then allocate a fresh console per command, hidden only through SW_HIDE. Before this change it inherited the Host's (windowless) console. CI can't show this. Could someone check packaged Desktop on Windows 11 with Windows Terminal set as the default terminal for a console flash, and look at the per-command conhost cost? If either is a problem, CREATE_NO_WINDOW on the command spawn (or an allocated hidden console in the supervisor) would avoid it.
There was a problem hiding this comment.
Agreed on the reading, and it predates the detach change. The supervisor's command spawn passes inherited fds, so libuv never adds CREATE_NO_WINDOW. A console-subsystem root then gets its own console, hidden only via SW_HIDE. The earlier non-detached supervisor had no console either (CREATE_NO_WINDOW), so it was affected the same way. main also lacks CREATE_NO_WINDOW for commands with source-fd inputs. I can't run packaged Desktop on Windows 11 here, so I'm leaving this open for a manual check. If it flashes, the fix is to have the supervisor hand the command non-inherited stdio, or a native launcher that passes CREATE_NO_WINDOW.
🤖 Addressed by Claude Code
…lane A `failed` message means the supervisor is already exiting on its own, so a tree walk from its PID could start after it is reaped. Signal only its process group on that path, as after an exit. On POSIX the Host's own stops end the supervisor by signal, so an exit code without `completed` can only be a supervisor fault. Report it as a lost result there, rather than as the command exiting with that code. Windows keeps only a clean exit, since taskkill also ends with a code. The Windows ownership step now asserts its TAP counts, like the other gates in that job, so a widened POSIX-only skip cannot leave it green while it tests less.
Astro-Han
left a comment
There was a problem hiding this comment.
Automated review notice: This comment was posted by an automated review agent. It is not an independent human review and does not replace one.
Incremental review of b587b768 → a2ff3fb2 (one commit, same merge-base 3597abe8). Verdict: COMMENT. No P0, P1 or P2. Two prior P3s are fixed, one is partly fixed, and two remain open by agreement.
Prior findings
- Tree walk on
failed(owned-child-process.ts:130): fixed.failednow only signals the supervisor's POSIX group. That is sufficient, because the supervisor sendsfailedonly when the command's spawn errors, so nothing else exists to kill. The tree walk is kept for the startup timeout and launch send errors, while the supervisor is alive. - Windows test count (
windows-recovery.yml:256): fixed. The step uses TAP plus exact 18/10/8 assertions, the same as the owner-death step, and it is green on windows-latest at a2ff3fb. - Supervisor fault reported as exit 1 (
:186): partly fixed. See inline. The new branch only catches exit codes, butloseOwnerends the supervisor by SIGKILLing its own group. - Stop-before-spawn (
owned-process-main.ts:119): open, declined. SIGKILL escalation bounds it, and the command is never left running. Accepted. - Windows detached console flash (
:80): open. It still needs a manual check on packaged Desktop with Windows 11 and Windows Terminal before release. - The lease ending at root exit stays deferred. A tracking issue would help.
New code. No false positives found in the POSIX lost widening. The Host's own stops all end the supervisor by signal or after completed, and timeout or send errors set failureReported. One side effect: a supervisor that fails to boot now also emits a lost its result error after ready rejects. That is benign.
Status. MERGEABLE, BLOCKED on review only. test, windows_recovery and the addon builds pass. Build immutable tarball still fails only on the unrelated npm audit finding (GHSA-6qxp-vccf-f47h), so the packed owned-process-main.js is still not exercised in CI. Main moved to 72afbf1 with no overlap, and the merge is clean. No protocol change, so no epoch claim is needed.
An uncaught exception or rejection in the supervisor stopped its tree with its own group's SIGKILL, so the Host saw a signal exit it could not tell from a forced stop, and reported it as the command being killed. While the lease is still up, report the fault as a failure first, then stop the tree. A one-second fallback stops it even if the report hangs. The test plays the Host against the real supervisor, injecting the fault through a preload rather than a product hook. The Windows ownership step now expects 19 tests, 11 passed, 8 skipped.
The bounded runner started its timeout when it spawned the supervisor, so the supervisor's startup consumed part of the command's budget. On a loaded Linux runner a 100 ms timeout fired before the command had run at all, and the escalation test found no heartbeat file. With a direct spawn the budget belonged to the command, and the background runner already arms its timeout after admission. Start the timer once the supervisor admits the command. A supervisor that never admits it still fails through its own startup timeout.
Astro-Han
left a comment
There was a problem hiding this comment.
Automated review notice: This comment was posted by an automated review agent. It is not an independent human review and does not replace one.
Incremental review of a2ff3fb2 → 816dc0d0 (two commits, same merge-base 3597abe8). Verdict: COMMENT. No P0, P1 or P2.
- Supervisor fault reported as a SIGKILLed command: fixed.
fault()reportsfailedwhile the lease is up, and the new preload-based test pins it against the real supervisor. One residual is noted inline: the Host's immediate group kill now races the supervisor's own tree walk. - Timeout starts at admission (
shell-exec.ts): looks correct. It is guarded against settle and abort, and an unadmitted supervisor still fails through its startup timeout. - Still open: stop-before-spawn (declined, accepted); the Windows console flash, which needs a manual packaged-Desktop check; the lease ending at root exit (deferred).
- Status: MERGEABLE, BLOCKED on review only.
test,windows_recovery(19/11/8) and the addon builds pass.Build immutable tarballstill fails only on the unrelated CLInpm auditfinding. The merge with mainf10a74d8is clean, and there is no protocol change.
Since a fault after admission is reported as `failed`, the Host's immediate group kill on that message hit the supervisor while its own tree walk was still reading the process table, so descendants that had left the group survived. On `failed` after admission the Host now only reports the error and leaves the stop to the supervisor. The group kill on its exit stays the backstop. A spawn failure before admission still signals the group at once. A fault while the `completed` report is in flight no longer sends `failed`, and the Host ignores a `failed` that follows `completed`, so a valid result is never turned into an error. Tests cover the Host leaving a reporting supervisor alive with the backstop still stopping the command, and the supervisor's walk reaching a setsid descendant on a fault. The Windows ownership step now expects 20 tests, 12 passed, 8 skipped.
Astro-Han
left a comment
There was a problem hiding this comment.
Automated review notice: This comment was posted by an automated review agent. It is not an independent human review and does not replace one.
Incremental review of 816dc0d0 → 0e242642 (one commit, same merge-base 3597abe8). No P0, P1 or P2.
- Host group kill racing the supervisor's tree walk on a post-admission
failed: fixed.owned-child-process.ts:174-184: a pre-admissionfailedstill kills the group. After admission, the Host only reports the error and leaves the walk to the supervisor, with the group kill on exit as the backstop (completedstays unset). The real-supervisor fault test now also pins asetsiddescendant, and a new Host-side test pins "no kill mid-walk, backstop on exit". - A fault while
completedis in flight no longer becomesfailed(owned-process-main.ts:54), and the Host ignores afailedthat followscompleted. - I checked the callers. shell-exec does not signal on
'error'. The background runner'shandleIntegrityFailuresends SIGTERM first (which the supervisor ignores after admission) and walks from the Host, so neither path reintroduces the race. - Still open: stop-before-spawn (declined, accepted) and the Windows console flash (manual packaged-Desktop check).
CI: test, windows_recovery (20/12/8) and the addons pass. Build immutable tarball still fails only on the unrelated CLI production npm audit (1 high). The merge with main is clean, and there is no protocol change.
Main renamed the filesystem worker bundle to .mjs; keep that name and this branch's owned-process-main.js entry in both release file checks. The supervisor runs in the Host's context, outside the Windows sandbox, so the module-type lookup that broke the worker does not apply to it.
Astro-Han
left a comment
There was a problem hiding this comment.
Automated review notice: This comment was posted by an automated review agent. It is not an independent human review and does not replace one.
Incremental review of #5986: 0e242642 to 81b05aba.
What changed: there is one new commit, 81b05aba "Merge upstream/main into fix/shell-owner-death". It contains no new author code. The merge-base moved from 3597abe8 to 6fa48516 (current main). The author's diff against the new merge-base is identical to the previous one except for the merge resolution below.
Merge resolution: scripts/release-cli-package.mjs conflicted with #6016, which renamed the filesystem worker bundle to .mjs. The resolution is correct in both validateStaging and validatePackedFiles:
- It keeps main's
filesystem-worker.mjs. - It keeps this branch's
owned-process-main.jsentry.
Checked via git show --remerge-diff. The .mjs rename was needed because the AppContainer sandbox denies the package.json module-type lookup. That reason does not apply to the supervisor. spawnOwnedProcess launches owned-process-main.js directly from the Host with process.execPath, so the sandbox only wraps the command the supervisor spawns. Keeping .js is fine.
Interaction with recent main merges:
- #5495 (session-catalog protocol, epoch 212) does not touch any file this PR touches.
- #5512 (Agent Graph results) does not touch any file this PR touches.
- This PR does not touch protocol files, so there is no epoch claim.
- #5977 is still open and shares no files with this PR.
- The only files that overlap with what main changed since the old merge-base are
release-cli-package.mjs(resolved above) and.github/workflows/windows-recovery.yml. The workflow change auto-merged: main's fork/schedule guard and this branch's lane are both present.
CI on 81b05aba: all green.
test: passwindows_recovery: pass- Addons ×4: pass
Build immutable tarball: pass. It was red at0e242642from the@modelcontextprotocol/clientaudit, which #5996 fixed; that fix is now merged in.- Validate installed CLI on Linux x64, Linux arm64, macOS and Windows: pass
- Qualify released State Roots: pass
- CLI Eval: skipped by design
Mergeable; blocked only on required review.
Prior findings:
- N-1 (faulting supervisor killed mid-walk): fixed in the previous round. Unchanged.
- P3-a stop-before-spawn: open, declined and accepted.
- P3-d Windows console flash (
owned-child-process.ts,windowsHideplus detached supervisor): open. A manual check on a packaged Desktop on Windows 11 with Windows Terminal is still recommended. CI cannot observe it. - Lease ends at root exit: deferred.
New findings: none.
Verdict: COMMENT. No P0, P1 or P2. The previous CI blocker is cleared by the main merge.
Astro-Han
left a comment
There was a problem hiding this comment.
Approved at @Astro-Han's explicit request. Our automated review of this exact head found no P0–P2 (remaining notes are P3 and listed in the earlier review comments), and CI is green.
Summary
Pipe-mode shell commands are spawned
detached, so on POSIX each one leads itsown process group. That makes timeout, Stop and abort work: the Host signals the
group and sweeps escaped descendants. It also means nothing stops them when the
Host itself dies without running cleanup (SIGKILL, crash, OOM kill, force quit).
The command is reparented to launchd/init and keeps running against the
workspace with no owner left to stop it or record its result. In a local live
run, a Bash command wrote its completion marker about 32 s after its Host was
SIGKILLed.
shell-owner-death.test.tsbelow reproducesthis on
mainfor both the foreground (bounded) and background (pipe driver)paths, 4 of 4 failing.
This PR starts each pipe-mode command through a small owning supervisor,
@maka/runtime/dist/owned-process-main.js. It is launched withprocess.execPath, which in packaged Desktop is Electron withELECTRON_RUN_AS_NODE, the same way the Runtime Host candidate itself is launched.into its own group with the Host's stdio and descriptor payloads inherited
directly, so output never passes through IPC.
pattern
candidate-launch-owner-guard.tsuses for Desktop → Host. When thechannel disconnects, the supervisor SIGKILLs its whole tree with the existing
terminateProcessTree, which also catches descendants that moved to anothergroup, and exits.
gets the signal directly. The supervisor does not forward it a second time
(tested: exactly one SIGTERM). The exit code or signal is mirrored back
unchanged.
group and never reports that as success.
Covered paths:
runShellWithBoundedTail/runProcessWithBoundedTail(foreground Bash) and
PipeProcessDriver(background pipe runs).Two details a supervisor has to get right:
shell_run.pidshown to users and the model stays the command's own PID.With a supervisor in front,
PipeProcessDriver.pidis the supervisor, whichignores a lone SIGTERM because group signals already reach the command. If
that PID were recorded, a model-issued
kill <pid>would silently donothing (measured: the command was still running 1.5 s later). The
supervisor now reports the command PID on admission, and
ShellRunProcessManagerrecords that aspid. Group signalling still usesthe supervisor PID.
uses SIGUSR1 to start its inspector, so a supervisor that simply re-raised
these never exited. A command killed by either stayed "running" until a
timeout, or forever for a background run. The supervisor now restores the
default disposition before re-raising, with a bounded exit fallback.
Root cause
spawn(..., { detached: true })puts the command in a new session and processgroup, and no OS mechanism ties its lifetime to the Host. Cleanup lived only in
Host callbacks (
manageChildProcessLifecycle,ShellRunProcessManager), andthose cannot run after SIGKILL.
Verification
New tests fail on
mainand pass with this change:mainshell-owner-death.test.ts:bounded/pipes×stop already requested: false/true— owner SIGKILL terminates an admitted command before its delayed writeshell-run-manager.test.ts: records the pipe command PID, not the supervisor that owns its process groupowned-child-process.test.ts: supervisor mirrors a command that dies from SIGPIPE / SIGUSR1Other new tests:
owned-child-process.test.tscovers argv, env, binary fd 3/7payloads and exit 23 preserved; missing executable rejects with
ENOENT;the command's
NODE_OPTIONSapplies to the command only; POSIX cancellationdelivers SIGTERM exactly once; unexpected supervisor death kills the command.
pipe-process-driver.test.tschecks that the reported command PID differs fromthe supervisor and that signalling it alone ends the command.
Suites and checks (macOS 27, arm64, Node 24.19.0):
npm --workspace @maka/runtime run test:dist: 3739 tests, 3725 passed, 0 failed, 14 skipped (Linux/Windows/pwsh gates).npm --workspace @maka/runtime-host run test:dist: 2240 tests, 2228 passed, 0 failed, 12 skipped.npm test(clean build, all 13 workspaces): 14362 node tests, 14324 passed, 0 failed, 38 skipped;evalPython tests 94 OK (13 skipped).check:release(minus the dist-freshnesscheck:stale): 214/214, includingwindows-package-source-closure. The Windows recovery path filter now listsowned-child-process.tsinstead ofshell-exec.ts/pipe-process-driver.ts, since the win32 branch moved;windows_recoverypassed on CI.npm run typecheck(14 workspaces),npm run lint,npm run format:check,npm run check:asf-headers: pass.node scripts/windows-test-inventory.mjs --write, thennpm run windows:inventory: pass. The three new POSIX-only skip declarations are classifiedplatform-contract.MAKA_CLI_DEVELOPMENT_PEER_TARGET=none node scripts/release-cli-package.mjs --developmentpasses
validateStagingandvalidatePackedFileswith the two new requiredentries. A command run from the extracted tarball's
@maka/runtimeran underthe packed
owned-process-main.js..asar: a probe packedowned-child-process.js,owned-process-main.jsand their two imports into an asar with
@electron/asarand ran it withElectron 43.4.1 in
ELECTRON_RUN_AS_NODEmode. Result: exit status 23 andsplit stdout/stderr preserved, and owner SIGKILL stopped the command before its
delayed write. No credentials are needed. The probe is not part of this PR (see below).
Windows: not run locally. CI's
testjob is Linux-only, andwindows_recoveryoriginally ran none of these suites. It now runsowned-child-process,shell-owner-deathandpipe-process-driveron windows-latest, and its path filter lists those test sources.shell-execis not included: its suite uses POSIX shell syntax throughout and fails on Windows independently of this PR. The first Windows run drove b587b76: detach the supervisor on Windows, and don't treat a taskkill exit code as a lost result. On b587b76,windows_recoverypasses: 18 tests, 10 passed, 0 failed, 8 POSIX-only skips. That includes thewrappedowner-death case, where acmd /croot and its child are both stopped.Cost
One extra Node (or Electron-as-Node) process per running pipe-mode command,
alive for exactly as long as the command.
Measured on Apple Silicon, 12 cores, macOS 27, with the machine quiet
(1-minute load about 6, Spotlight idle).
main's runtime build ("direct") wasinterleaved with this branch's ("supervised"). 10 warmup samples were discarded,
then n = 40 per cell (n = 20 for bursts). Values are p50 / p95 in ms.
/usr/bin/true, end to end (bounded path)true, end to end (foreground Bash path)ready/usr/bin/true(wall time)Memory per running command, from the supervisor process: RSS 46.6 MiB (Node) /
59.6 MiB (Electron). macOS physical footprint, which excludes pages shared with
other processes, is 13 MB / 15 MB.
So the CLI adds about 26 ms per command. Packaged Desktop, where the supervisor
is Electron in Node mode, adds about 72–84 ms per command and about 15 MB per
command that stays running (dev servers, watchers). That is small next to a
model turn, but noticeable in a turn that runs many short commands.
Alternative considered: one long-lived supervisor per Host
One leased "command broker" started with the Host would spawn every command and
kill all of its groups on disconnect. Per-command cost would drop to an IPC
round trip, and memory to one process in total. It is not a small change:
inherits them, plus raw
sourceFdpayloads. A broker's commands are its ownchildren, so output must either be relayed over IPC (extra copy, re-implemented
backpressure, and the retained-tail and live-emit caps seeing chunks one hop
later), or be handed over as fds. Node IPC can pass
net.Socket/net.Serverhandles only, not arbitrary fds, and Windows is more limited still.
manageChildProcessLifecycleandPipeProcessDriverarebuilt on a
ChildProcess(exit/close/error ordering, the output-drain fences).A broker yields a remote handle, so those semantics would need an emulating
adapter. That is the riskiest part to get subtly wrong.
supervision, restart, and rules for requests in flight.
detection (same disconnect lease).
A middle ground with identical semantics: keep one supervisor per command but
make it a tiny native executable instead of a Node process. The repo already
ships native artifacts. That cuts startup to about a millisecond and RSS to
about a megabyte, at the cost of per-platform build and packaging work (it must
live outside
app.asar). LinuxPR_SET_PDEATHSIGalone is not enough, becausemacOS has no equivalent. Given the Desktop numbers above (about 75 ms and 15 MB
per command), the native per-command supervisor is the follow-up I would do
first. It keeps every semantic and test in this PR and removes most of the cost.
The broker is only worth its complexity if process count itself becomes the
problem.
Review focus / risks
shell_run.pidis the command's own PID, as before. Agroup kill by the model (
kill -- -<pid>) now fails with ESRCH, because thecommand is no longer the group leader.
kill <pid>andkill -9 <pid>behaveas on
main.StopBackgroundTaskis unaffected.pipe ends until it exits, right after the command. A write that would hit
EPIPEwhen the command closes its stdin is now reported when the supervisorexits. The probe measured both at exit (348 ms vs 390 ms for a command that
closes stdin and exits at 300 ms). The IPC fd does not leak into the command
(checked from
/bin/sh).ready(Linux CI: a foreground Bash aborted on its first output failed startup with AbortError instead of returning a cancelled result). The client now pauses stdout/stderr until admission has been observed, and releases them in the check phase afterreadyresolves, or on startup failure or supervisor exit. That restores a direct spawn's "spawn before any output" order.owned-child-process.test.ts"command output never reaches the caller before admission" forces both to be pending at once and fails without the hold. The workspace executor abort test now aborts on the command's first output instead of after a fixed 100 ms, which also had to cover supervisor startup on a loaded runner.completedmessage as a lost result. Node emitsexitwithout ordering itagainst pending IPC reads; only
closewaits for the channel. In 60 of 60trials with the Host loop blocked across the command's exit,
completedarrived first. If this should not rest on that, the fix is to decide on the
channel's
disconnect(EOF) instead ofexit. I could not make a test failfor it, so it is not in this PR.
taskkill /Ton the reaped PID, because the PID may be reused. The orphaned command is therefore not stopped until the Host itself goes.maincannot reach a dead root's children either, so this is not a regression.completedis reported as a lost result. Other exits may be the Host's own forced stop; on Windows, taskkill ends with an ordinary code.main, Host death therefore kills acmd /croot but leaves the root's child running. The supervisor is detached on Windows too, so it outlives the Host, sees the lease drop andtaskkill /Ts its tree. The command stays in the supervisor's job. Thewrappedowner-death case covers exactly this.kill -HUP 0and similar reach only the command's handling, as onmain. The USR1 listener also keeps the Node inspector from starting.cmd &, a dev server started by a script that returns) are not owned, as onmain. Holding the lease past root exit needs its own design and is a follow-up.timeoutMsstarts at admission, as the background runner's already did, so the supervisor's startup doesn't use up the command's budget. A 100 ms timeout on a loaded Linux runner otherwise fired before the command ran (816dc0d).Deliberately left out
node-pty). They are not supervised. Closing the PTY masteron Host death hangs up the terminal, and the foreground group gets SIGHUP. That
ends most commands, but not ones that ignore SIGHUP.
runner), and double-forked daemons that leave the process tree entirely. These
are unchanged from
main..asarpackaging probe script. It ran clean locallywithout credentials, but nothing in CI or
package.jsonwould run it.It hardcodes the module's import list, so it would go stale silently. The
durable home for that check is a smoke step in the packaged-app verifiers,
next to the existing filesystem-worker and PTY probes. That can be a follow-up.
AI use
Select exactly one:
Tool(s) and scope: Codex wrote the supervisor on an experimental branch. Claude Code split it out, added the command-PID reporting and the SIGPIPE/SIGUSR1 signal mirroring with their tests, ran the verification and cost measurements above, and wrote this description. The commit carries
Generated-by: CodexandGenerated-by: Claude Code. Final review and merge remain with human maintainers.Checklist
Does this PR entail a change in behavior?