Skip to content

fix(host-kit): a killed command settles on its exit, and background exec cannot take a timeout - #2599

Merged
thymikee merged 8 commits into
mainfrom
fix/host-kit-exec-settle-on-exit-2522
Sep 15, 2026
Merged

thymikee merged 8 commits into
mainfrom
fix/host-kit-exec-settle-on-exit-2522

Conversation

@thymikee

@thymikee thymikee commented Sep 14, 2026

Copy link
Copy Markdown
Member

Summary

A command that exec.ts killed at its deadline or on request cancellation settled on close, which waits for the stdio pipes to drain. A descendant that inherited those pipes keeps them open after the direct child is gone, wedging the request and the device lock it owns. Foreground promises and background wait now settle on exit once this module asked for the kill, and still wait for a full drain on close when it did not. One finish() owns the timer clear, the abort-listener release and the trace emit, so the stdin-failure rejection stops bypassing them.

killProcessTree no longer signals a child Node already reaped: its pid, and with it the process-group id a detached spawn handed out, is reusable by then. execHostAdb spawns detached like execSerialAdb already does, so a deadline can signal the group instead of only the client. Background runs lose the timeoutMs field they never armed — ExecBackgroundOptions and AndroidAdbSpawnOptions omit it, the two app-log call sites that forwarded it are dropped, and the app-log adb command contract no longer offers it.

Closes #2522. Eleven files, one command family. A caller-initiated direct kill() still settles on close; that path is outside this issue's scope. No output cap was added, so buildRunnerEarlyExitError keeps its full capture.

Validation

Rebased on origin/main at e3cbc91a1a; tested at 0089fb123b. Every CI check on that head passed,
including Smoke, Coverage and Typecheck. The two scripts/ gates that check:affected does not select
pass locally (ratchet; eager-closure at 614 assertions).

Locally, pnpm check:affected --run finished with one failing test in each of two runs, a different test
each time — a wall-clock budget in runner-client.test.ts, a startup-threshold in
scripts/fuzz/harness.test.ts — with 5855/5856 and 10,603/10,605 passing. Both are green in isolation
(50 tests, 13 tests) and green in CI; the host was at load average 50+ from other sessions, which is the
condition AGENTS.md warns about for subprocess-backed suites. Nothing in those two tests touches this
PR's files.

The wedge tests were run red against origin/main's exec.ts first (3073 ms, 3036 ms). Removing the
group-kill branch, and removing the reaped-child guard, each turn one test red. The group-write double
answers the way process.kill does — true on delivery, ESRCH, EPERM — and rethrowing EPERM from
the seam's catch reddens the table test alone; replacing the invalid-pid refusal reddens the refusal
test through the guard's empty write list.

Honest gap: with a real Android adb on this host the client closed 1 ms after exit, so the adb-specific
pipe wedge is not reproduced here — the synthetic inherited-pipe holder is the evidence for the generic
defect.

@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.57 MB 4.57 MB +784 B
Package (unpacked) 4.56 MB 4.57 MB +784 B
Package (download) 1.36 MB 1.36 MB +353 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 21.9 ms 22.4 ms +0.5 ms
CLI --help 61.5 ms 62.8 ms +1.4 ms

Comment thread src/platform-runtime-android-adb-host.test.ts Fixed
@thymikee

Copy link
Copy Markdown
Member Author

Settling on exit fixes the wedge from #2522, but at 5b31383 it adds a hang for detached commands. If the child exits on its own while a descendant still holds its pipes, the exit handler in packages/host-kit/src/internal/exec.ts does nothing, because no deadline or abort has fired yet. When the deadline or abort fires later, killProcessTree returns early because the child is already reaped. Only close can settle then, and it waits for the descendant. On main, the group kill ended that descendant. Please settle right away when the timer or abort fires after the child has exited, and keep the group kill for detached (a process group id is not reused while the group still has members). The test a cancellation arriving after the child was reaped passes only because its holder is sleep 0.4; a holder that outlives the test timeout would catch this. The exit-driven settle also leaves the child's stdio streams open, so destroying them there avoids keeping pipes to a descendant that is never killed.

A smaller-design question: could the timer, the abort and exit all call one settle that does not depend on their order, instead of exit checking didTimeout? That would remove this kind of hang.

timeoutMs can still reach a background app-log command. The kind: 'host' variant of AppLogProcessCommand in packages/contracts/src/app-log-runtime.ts carries a full HostCommandRequest, and launchLocalAppLogCommand now drops the field silently. #2522 asks for the field to be impossible to pass, so Omit<HostCommandRequest, 'timeoutMs'> there would finish that part.

The CodeQL alert on src/platform-runtime-android-adb-host.test.ts:175 is a false positive: the script embeds JSON.stringify of a temp path the test creates. Smoke Tests were still running, and there are no conflicts.

@thymikee

Copy link
Copy Markdown
Member Author

Review follow-up in ca2108d.

The hang the added exit settle could introduce is gone. Settlement no longer asks who fired first: createCommandKillSettlement owns the pair of facts (a kill was requested, the child is gone) and each one checks for the other, so a deadline or cancellation that arrives after the child exited on its own settles on the spot instead of waiting for a close its pipe-holding descendant never releases. didTimeout / abort.didAbort now only choose which error the settled command fails with.

The detached group is still killed. killProcessTree keeps signalling the group after the direct child is reaped — those group members are exactly who holds the pipes — but first probes with kill(-pid, 0) and skips the signal when the group no longer resolves, so a stale deadline cannot aim a SIGKILL at a recycled group id. Non-detached children keep the existing post-reap skip: their pid is a claim on a slot the kernel hands to someone else.

Settling closes our end of the pipes. finish() destroys the child's stdio so a kill that could not reach the holder does not leave this process holding the other end open.

Tests: command-kill-settlement.test.ts pins the order-independence at the seam, and exec-kill-settle.test.ts adds the two end-to-end shapes the old test could not reach — a non-detached child that exits and leaves a pipe holder behind settles at its deadline (2030 ms to 103 ms), and a detached deadline whose group is gone signals nothing while still settling. The group writes are intercepted at process.kill, the seam hermetic-signal-setup.ts points at for a real kill path, because a worker's authority over a pid ends when the child is reaped.

Mutation evidence: removing the settlement from the kill request turns 5 tests red, with the three deadline tests waiting the full 2 s; dropping the reachability probe turns the group-empty test red; restoring the post-reap early return turns the group-kill test red.

CodeQL improper sanitization on the fake adb: the marker path now arrives through the environment instead of being spliced into generated source, and that alert is closed.

Gate at ca2108d: pnpm check:affected --run passed, 770 test files.

@thymikee

Copy link
Copy Markdown
Member Author

Two follow-ups after the Coverage job came back red on ca2108da5d, plus a sync.

The new module was the problem, not the behavior. The eager-closure gate measured 27 entries evaluating one more module once command-kill-settlement.ts landed, and its own verdict names the remedy for exactly this shape: a small module every affected entry already evaluates belongs inside it rather than behind a new static edge. 787cb9a4d2 moves the factory into exec.ts, next to the two commands that construct it, and 82282a9a48 drops the export that made it dead code once nothing outside the file imported it. The settlement logic and its tests are unchanged; the three end-to-end cases still go red, at 2012/2017/2011 ms, when the kill request stops settling a child that already exited.

The branch was behind. Compatibility & Provenance failed on the Fallow audit run against the PR merge ref, where the diff carried files from main that this branch did not have yet — findings in src/daemon/session-store.ts and src/daemon/server/http-server.ts, neither of which this PR touches. a36dd2805d merges current main, which also brings in #2595's shellQuote dedupe that this branch's tests were written around.

Gate after the merge at a36dd2805d: pnpm check:affected --run passed, 765 test files. scripts/__tests__/eager-closure-budgets.test.ts and scripts/__tests__/test-file-size-ratchet.test.ts pass locally (615 assertions) — neither is inside check:affected's selection, which is why the first two repairs had to come from CI.

@thymikee

Copy link
Copy Markdown
Member Author

At a36dd28 the deadline, the abort, exit and close settle exactly once in any order, streams are destroyed on settle, and the deadline-after-exit test fails on the earlier head. That closes the detached-command hang from the last review.

One part of #2522 is still open. The kind: 'host' variant of AppLogProcessCommand in app-log-runtime.ts still takes a full HostCommandRequest, so a producer can pass timeoutMs and launchLocalAppLogCommand drops it silently. request: Omit<HostCommandRequest, 'timeoutMs'> would make it impossible to write.

isProcessGroupReachable copies isProcessGroupAlive from host-process.ts, and it does not do what the comment above killProcessTree says. If the group is gone, the SIGKILL already fails with ESRCH inside the existing catch. If a new group leader has that id, the probe says reachable and the SIGKILL still goes out. Please drop the probe and its comment and use the existing signalProcessGroupBestEffort. The two group-kill tests can then mock that helper; the "gone" test fakes ESRCH while sleep 2 is still in the group, which cannot happen for real.

Smoke Tests are still running, and there are no conflicts.

@thymikee

Copy link
Copy Markdown
Member Author

Pushed 3678353039. Both parts done, one of them in a different place than you pointed at.

AppLogProcessCommand host variant. Now request: Omit<HostCommandRequest, 'timeoutMs'>, with the reason on the field. Nothing needed loosening: both producers already omit it (platform-apple/src/logs/start.ts:132, platform-harmonyos/src/logs/runtime.ts:42 — the harmony pidof keeps its 5s because that one goes through host.commands.run, not the tail), and localHostCommand in src/platform-runtime-app-log-process.ts:183 feeds a runCmdBackground call that passes allowFailure, cwd, env and nothing else. The type says what the call site already did.

isProcessGroupReachable is gone and the detached branch is one signalProcessGroupBestEffort(child.pid, 'SIGKILL'). The probe was deciding nothing anyway: EPERM made it report the group reachable, and the write after it was refused all the same.

It could not stay in host-process.ts, though, and both reasons are gates rather than taste:

::error title=Layering drift (R4 value-import-cycle)::production value-import cycle:
  packages/host-kit/src/internal/exec.ts -> packages/host-kit/src/internal/host-process.ts ->
  packages/host-kit/src/internal/exec.ts

host-process.ts imports exec.ts for runCmd, so importing back is out. I then tried the shape you would expect next — a process-signal.ts below both, re-exported — and the eager-closure budgets rejected it, because a module that is reachable from exec.ts is reachable from every entry that evaluates it:

x 'packages/capture-kit/src/durable-capt…' evaluates no more modules than at the merge-base
x 'packages/capture-kit/src/recording/ou…' evaluates no more modules than at the merge-base
x 'packages/capture-kit/src/recording/vi…' evaluates no more modules than at the merge-base

So the seam moved down, into exec.ts — the module that owns the tree it is killing — and @agent-device/host-kit/process exports the same name from there. The six call sites outside host-kit (runner-disposal.ts, apple host.ts, snapshot-source/host.ts) are untouched, and one group-signal path is left in the package.

On mocking it. A vi.mock of the seam cannot bite inside exec.ts: vitest replaces an import binding, and killProcessTree calls a local one. So the two group-kill tests answer writes where they actually land, at a guard that records the target and refuses to deliver — which is the seam src/__tests__/hermetic-signal-setup.ts points a real kill path at — and the two unit tests for the seam itself moved over with it. Callers outside host-kit keep mocking the export, as runner-disposal.test.ts already did. To keep the deleted duplication from creeping back, one test reads the module and fails if a second process.kill(-…) shows up beside the seam:

x a group write in this module can only come from the seam
Tests  1 failed | 15 passed (16)

That was with the raw write restored; 16/16 with the seam.

Gate: pnpm check:affected --run green, plus check:layering, check:fallow, check:production-exports, and eager-closure-budgets / test-file-size-ratchet run explicitly. One unrelated note: the first gate run had packages/platform-android/src/__tests__/snapshot-helper-session.test.ts hit its 5s timeout under load (5033ms), and passed alone in 3.12s. Second full run was clean.

@thymikee

Copy link
Copy Markdown
Member Author

Verified at 3678353039:

  • The host app-log command no longer advertises a deadline it will not honour. kind: 'host' carries the request without timeoutMs, and the reason is on the type rather than in a comment a caller can miss — a streamed tail is stopped by its owner, so keeping the key out means a producer cannot hand over a budget that silently never fires.
  • The group-signal rule is one seam. isProcessGroupReachable is gone; the settle path calls signalProcessGroupBestEffort, which is also what the deadline path uses, so the "is it there, then signal it" race cannot come back and no caller has to decide what ESRCH means. exec-kill-settle.test.ts pins the seam, including the refusal to signal 0 or a negative pid.
  • The two group-kill tests now drive that seam instead of mocking process.kill per outcome, so the gone-process case no longer has to fake ESRCH while the sleep is still in the group.

@thymikee

Copy link
Copy Markdown
Member Author

Smoke status, since the lane has been unstable rather than consistent:

attempt failing step message
1 Run iOS Settings replay smoke test step 6 wait for About || "Software Update" timed out, "No readable snapshot capture completed before the wait timeout"
2 (rerun) Run fixture-backed iOS simulator E2E smoke AssertionError: step: wait for Agent Device Tester
3 (rerun) Run targeted iOS runner XCTest regressions testAlertAcceptDoesNotActivateAReplacementWithASharedButtonALERT_DEADLINE_EXCEEDED: alert accept exhausted its deadline, alongside IOS_TREE_CAPTURE_TIMEOUT system-modal probe aborts

Three attempts, three different steps, all in timing-sensitive device areas, and the third one is a Swift runner XCTest this branch cannot reach — it changes packages/host-kit exec settlement and the AppLogProcessCommand host variant, no Apple runner code. #2598 failed its first attempt on the same fixture-backed E2E step (id="automation-longpress" did not become visible after scrolling) and went green on a plain rerun, which is the pattern I expect here. Queue conditions support it too: several iOS lanes were running concurrently on the same runner pool the whole time.

Rerun 3 is queued. If it lands red again on yet another step I'll stop burning the lane and we should treat the alert-observation XCTest's behaviour under load as its own issue rather than a signal about this diff.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 3678353. The host app-log command now takes Omit<HostCommandRequest, 'timeoutMs'> with every producer updated, and the detached kill goes through the one group-signal seam, so both points from the last review are fixed. Kill, exit and close still settle a command exactly once.

Two of the new tests do not check what their names say. In exec-kill-settle.test.ts#L262, guardGroupWrites returns false for every negative-pid write and never throws, but the real process.kill throws ESRCH or EPERM for a group that is gone. So the catch in signalProcessGroupBestEffort never runs, and the "cannot be signalled" case is not exercised; the test is the one above it with a different runner. Could the guard throw ESRCH here, with the test asserting that the timeout rejection still settles? Otherwise the test can go.

exec-kill-settle.test.ts#L204 regex-matches process.kill(- in the source of exec.ts. A group kill written as process.kill(0 - pid, ...) or added in another file would pass it, and a reformat could break it. The first group-kill test already checks the observable SIGKILL. Could this become a behavioral check through the exported seam, or be dropped?

CI is green on 3678353, and there are no conflicts. The next step is to fix or remove these two tests.

@thymikee
thymikee force-pushed the fix/host-kit-exec-settle-on-exit-2522 branch from 3678353 to f4b87f9 Compare September 15, 2026 05:55
@thymikee

Copy link
Copy Markdown
Member Author

Both tests fixed at f4b87f90a5 (rebased on current main, rebase clean, pnpm check:affected --run passes).

The "cannot be signalled" case now really is exercised. guardGroupWrites takes how the guarded group answers: no-group-reached records the write and returns false, as a group with no reachable members does; no-such-process throws ESRCH the way a vanished group does. The second detached-deadline test uses no-such-process, so the write now reaches signalProcessGroupBestEffort's catch, and the test asserts the command still settles on its timeout inside the fast window.

Proof it bites now: deleting the try/catch from signalProcessGroupBestEffort fails two tests —

× group signaling reports a vanished group and never signals an invalid pid
× a detached deadline whose group cannot be signalled still settles

Before this change that same mutation was green, which is the hole you found.

The source-shape test is gone. You are right about what it failed to check: process.kill(0 - pid, …) or a group kill added in another file would have satisfied it, and a reformat would have broken it. The invariant it was reaching for is already checked behaviorally by the first group-kill test — the detached deadline path produces exactly one SIGKILL write addressed at -childPid, and that write can only have come from the seam, since the seam is the only thing the hermetic guard intercepts. The seam stays exported because production consumes it: packages/platform-apple/src/snapshot-source/host.ts and packages/platform-apple/src/core/runner-host.ts both reach group kills through it.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at f4b87f9. I found one small problem left in the reworked kill-settle test.

exec-kill-settle.test.ts:192 uses a default no-group-reached mode where the mocked process.kill returns false, and the comments at lines 170-175 say this models a group with no reachable members. Real process.kill never returns false: it returns true on delivery and throws ESRCH or EPERM otherwise. So this mode is really a delivered signal that does nothing. The assertions still pass because the seam ignores the return value, but the comment describes kernel behavior that does not exist, and later tests can copy it. Can the mode return true and get a name like delivered, so the comment matches what process.kill does? The EPERM branch also has no test of its own; the catch treats it like ESRCH.

All checks pass on f4b87f9, including Smoke Tests, which runs every device command through the exec.ts settlement this PR changes. There are no conflicts. The next step is the small test-only fix above.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at ad1e409, as a follow-up to the review at f4b87f9.

One small test problem is left. The vanished-group ESRCH loop test at exec-kill-settle.test.ts#L236 repeats the case that line 218 already covers, with a second hand-written process.kill spy instead of the guardGroupWrites double. The delivery test at line 201 builds its own spy the same way. One seam now has two test doubles, so the next change can drift from what production returns. Can you build the delivery test on guardGroupWrites('delivered'), fold the ESRCH/EPERM loop and the invalid-pid checks into the vanished-group test on that double, delete the vi.spyOn copies, and drop the "only the first of them was tested" line, which stops being true on merge?

Typecheck & Package fails with TS2353 on installArgs in packages/platform-android/src/__tests__/snapshot-helper-install.test.ts. This PR does not touch that file, and main fails the same way at 2cafab3 because #2618 removed the field while that test still sets it, so the failure is unrelated to this PR. A rebase will not clear it until main is fixed.

Smoke Tests was still running at review time. It exercises this change's route, because every device command spawns through runSpawnedCommand and runCmdBackground in exec.ts, so a red result there needs a look.

Next: the test-double cleanup and a green Smoke Tests run.

@thymikee
thymikee force-pushed the fix/host-kit-exec-settle-on-exit-2522 branch from ad1e409 to 85b4f11 Compare September 15, 2026 10:36
@thymikee

Copy link
Copy Markdown
Member Author

Reviewed again at f4b87f9; answered at 85b4f11408, rebased on origin/main at 5f6481cafb.

The mode is delivered and returns true. You had it exactly: the default answered a write the kernel accepted with false, which is not one of the three answers process.kill gives a negative pid, and the comment above it described a group state the seam cannot observe. The assertions were indifferent because signalProcessGroupBestEffort reads the throw and ignores the return value — which is precisely why leaving it would have been the damaging version of the mistake: the next test written against this helper would have copied an answer that does not exist.

The EPERM branch has a test of its own now, and it is paired with ESRCH in one case rather than split, because the fact worth pinning is that the seam gives both errno values the same answer — nothing was reached, so the caller must not keep waiting on a pipe holder it just asked to be killed. Only ESRCH was ever exercised; the comment claimed both.

Mutation: making the catch in signalProcessGroupBestEffort rethrow EPERM turns the new test red and nothing else in the file — 1 failed, 15 passed. That is the evidence that the new case is the one holding that branch, not the existing ESRCH integration test by proximity.

Gate at 85b4f11408: pnpm check:affected --run passed, 778 test files, 5843 tests, typecheck, lint, layering and the Fallow audit clean. scripts/__tests__/test-file-size-ratchet.test.ts and scripts/__tests__/eager-closure-budgets.test.ts, neither of which is inside check:affected's selection, pass locally, the second with 614 assertions.

thymikee and others added 7 commits September 15, 2026 14:33
…ot take a timeout

A command killed by its own deadline or by request cancellation settled on
'close', which waits for the stdio pipes to drain. A descendant that inherited
those pipes keeps them open after the direct child is gone, wedging the request
and the device lock it owns. Both the foreground promise and the background wait
now settle on 'exit' once this module asked for the kill, and still wait for a
full drain on 'close' when it did not. One finish() owns the timer clear, the
abort-listener release, and the trace emit, so the stdin-failure rejection stops
bypassing them.

killProcessTree no longer signals a child Node already reaped: its pid, and with
it the process-group id a detached spawn handed out, is reusable by then.

execHostAdb spawns detached like execSerialAdb already does, so a deadline can
signal the group instead of only the client.

Background runs lose the timeoutMs field they never armed:
ExecBackgroundOptions and AndroidAdbSpawnOptions omit it, the two app-log call
sites that forwarded it are dropped, and the app-log adb command contract no
longer offers it.
…command

A deadline that fired after the child had already exited left the command waiting for a
`close` its pipe-holding descendant would never release: the group kill that ends that
descendant cannot run through a child Node already reaped. Kill requests and child exits
now report to one settlement that does not care which arrives first, a detached process
group is still killed while its members are reachable, and settling closes our end of
the pipes instead of holding them open for a stranger.

The fake adb reads its marker path from the environment instead of having it spliced
into the source it is generated from.

Co-authored-by: Apex by Callstack <noreply@callstack.com>
…rs already pay

The Coverage job's eager-closure gate measured 27 entries evaluating one more module once
`command-kill-settlement.ts` landed, and its verdict names the remedy: a small module that
every affected entry already evaluates belongs inside that module rather than behind a new
static edge. The factory now sits in `exec.ts` next to the two commands that construct it;
the ordering behavior and its tests are unchanged.

Co-authored-by: Apex by Callstack <noreply@callstack.com>
Co-authored-by: Apex by Callstack <noreply@callstack.com>
… will not honour

`killProcessTree` probed a group with `process.kill(-pid, 0)` and then wrote to it by hand, so
host-kit had two group-signal paths and the one every runner-tree kill already used was the one
this file could not mock. The probe also decided nothing: EPERM made it report the group
reachable and the following write was refused all the same. It is gone, and the detached branch
is one call to `signalProcessGroupBestEffort`.

That seam now lives in `exec.ts`, below the module that already reached it. `host-process.ts`
imports `exec.ts` for `runCmd`, so importing its signal helper back up would close a production
value-import cycle, which `check:layering` R4 rejects outright; and giving the two of them a new
shared module below both is a module every one of those entries starts evaluating, which the
eager-closure budgets reject. `@agent-device/host-kit/process` exports the same name from the new
home, and nothing outside host-kit noticed.

Two tests moved with the function. The two group-kill tests now answer writes at a guard which
records what the kill aimed at and refuses to deliver it — the seam the hermetic signal setup
points real kill paths at — and a source-shape test fails if a second `process.kill(-…)` ever
appears in this module beside the seam. Reverting the detached branch to a raw group write turns
that one red.

`AppLogProcessCommand`'s host variant takes `Omit<HostCommandRequest, 'timeoutMs'>`. The
background exec it feeds passes `allowFailure`, `cwd`, and `env` and cannot pass a timeout, so a
producer that wrote one was writing a budget that never fires.

Co-authored-by: Apex by Callstack <noreply@callstack.com>
The default mode returned `false` for a write the kernel took, and the comment above it described that
as a group with no reachable members. `process.kill` has three answers to a negative pid and none of
them is `false`: `true` once the write is accepted, `ESRCH` when no member is left, `EPERM` when a
member belongs to someone else. The assertions survived because the seam reads the throw and ignores
the return value, so nothing was wrong with the behavior — but the next test written against this
helper would copy an answer the kernel never gives, and the comment would keep promising a state the
seam cannot observe.

The mode is now `delivered` and returns `true`, and `EPERM` is a mode of its own, which is also the
test that branch never had: `signalProcessGroupBestEffort` has one catch for both errno values, and
only `ESRCH` was ever exercised. Making that catch rethrow `EPERM` turns the new test red and nothing
else.
@thymikee
thymikee force-pushed the fix/host-kit-exec-settle-on-exit-2522 branch from 85b4f11 to 725a225 Compare September 15, 2026 12:33
@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 85b4f11. This is the ad1e409 patch rebased onto main, with every commit unchanged in the range-diff, so the reply above answers the f4b87f9 review, which ad1e409 already addressed. The question from the review at ad1e409 is still open: the delivery test at exec-kill-settle.test.ts#L202 and the vanished-group test at L217 still use hand-written vi.spyOn(process, 'kill') doubles beside guardGroupWrites, so one signal seam has two test doubles that can drift apart. The comment at L240 still says "only the first of them was tested", which is no longer true.

All checks passed at 85b4f11 and there are no conflicts. The newer head 725a225 is again the same patch rebased onto main, so this applies to it unchanged. Next: build both tests on guardGroupWrites, fold the invalid-pid checks into the ESRCH/EPERM loop, and drop the stale clause.

@thymikee

Copy link
Copy Markdown
Member Author

Rebased on origin/main at e3cbc91a1a; head is 725a22531d. Clean rebase, no code change — main's four new commits are Apple close-finalization work and share no file with this branch, so under docs/agents/pull-requests.md this rebase was not required; it is here because the wave wanted one base while #2598 genuinely needed one.

Gate re-run on the new head: pnpm check:affected --run passed, 780 test files, 5857 tests, typecheck, lint, layering and the Fallow audit clean, and the two scripts/ gates that check:affected does not select pass with 617 assertions. Nothing new to answer: the outstanding thread here — the CodeQL one — has been resolved since 3678353, and no comment has arrived since the 85b4f11408 reply. Earlier evidence stands where it was produced.

`guardGroupWrites` had been introduced as the seam every group write is answered at, and then two tests
beside it went on answering the same question with hand-written `vi.spyOn(process, 'kill')` doubles. Two
doubles for one seam is a slow disagreement waiting to happen: the spy said delivery and the guard said
delivery differently, and only one of them was checked against what `process.kill` really answers.

Both are built on the guard now, and the three answers it can give — `true` for a delivered write,
`ESRCH`, `EPERM` — are one table with the report each must produce, so the delivered case and the two
refusals cannot drift apart or be edited separately. The invalid-pid checks moved into the same shape
rather than staying beside it, and their proof got stronger: the guard records every write it is asked
about, so "nothing was signalled" is now an empty list rather than a spy call count, which is a claim
about what the seam did rather than about how the spy was wired. The comment clause claiming only one
of the three answers was tested is gone, since it was written before the third arrived.

Mutations, one at a time: making the catch in `signalProcessGroupBestEffort` rethrow `EPERM` reddens the
table test and nothing else; replacing its pid refusal with a NaN check reddens the invalid-pid test
through that empty list. Running the second is safe precisely because the guard answers a negative pid
itself and never forwards one to the kernel.
@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 5680257759; answered at 0089fb123b.

One double now answers every group write in that file. guardGroupWrites was introduced as the seam the hermetic signal setup points a real kill path at, and then the delivery test and the vanished-group test beside it went on answering the same question with hand-written vi.spyOn(process, 'kill') doubles. Both are built on the guard now, so the file has one answer to what process.kill does with a negative pid, and it is stated once: true for a delivered write, ESRCH, EPERM, each paired with the report the seam must give. Three tests became two, and the drift the split allowed is gone rather than merely small.

The invalid-pid checks folded into the same shape, and got a stronger proof on the way. They used to assert killSpy.mock.calls.length === 1, which is a claim about how the spy was wired; the guard records every write it is asked about, so the assertion is now writes == [], a claim about what the seam did. A non-integer pid joined 0 and -1 there, since it reaches the same refusal for the same reason.

The stale clause is gone, along with the vi import that only the deleted spies needed.

Mutations, one at a time: rethrowing EPERM from the catch in signalProcessGroupBestEffort reddens the table test and nothing else, 1 failed 14 passed; replacing its pid refusal with a NaN check reddens the invalid-pid test through that empty list. The second is safe to run only because the guard answers a negative pid itself and never forwards one to the kernel — which is also what makes it a real double rather than a loaded gun.

Scope of the unification, stated exactly: exec-kill-settle.test.ts contains no spyOn(process, 'kill') now. host-process.test.ts and owned-process-record.test.ts keep theirs, and those are different seams — a liveness probe and a record's own signal path — not two doubles for the one group write this file owns.

Gate at 0089fb123b: every CI check passed, including Smoke, Coverage and Typecheck. Two local
check:affected --run runs each finished with exactly one failing test, a different one each time — a
wall-clock budget in runner-client.test.ts, a startup threshold in scripts/fuzz/harness.test.ts — at
5855/5856 and 10,603/10,605 passing, both green in isolation and in CI. The host was at load average 50+
from other sessions, which is the condition AGENTS.md warns about for these lanes; neither test touches
this PR's files, and the body says the same.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 0089fb1, as a follow-up to the earlier review. Both group-signal tests now use guardGroupWrites as their only double, one invalid-pid test covers 0, -1 and 1.5 against the guard in exec.ts, and the stale comment is gone. All checks pass and there are no conflicts, so this is ready for human review.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 15, 2026
@thymikee
thymikee merged commit 354d351 into main Sep 15, 2026
18 checks passed
@thymikee
thymikee deleted the fix/host-kit-exec-settle-on-exit-2522 branch September 15, 2026 15:23
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-15 15:24 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(host-kit): a killed command settles on exit, background exec cannot take a timeout

2 participants