Skip to content

fix(test): settle the daemon's exit on its own deadline in the web shutdown lane - #2083

Merged
thymikee merged 3 commits into
mainfrom
claude/laughing-einstein-ce3b4f
Aug 27, 2026
Merged

fix(test): settle the daemon's exit on its own deadline in the web shutdown lane#2083
thymikee merged 3 commits into
mainfrom
claude/laughing-einstein-ce3b4f

Conversation

@thymikee

Copy link
Copy Markdown
Member

What

The daemon-exit assertion in the #1868 web shutdown lane (live web platform e2e daemon-shutdown browser cleanup) was an instantaneous liveness read placed right after settleManagedBrowserProcesses:

const after = await settleManagedBrowserProcesses(status);   // polls, 45s deadline
assert.equal(after.count, 0, ...);
assert.equal(isProcessAlive(daemonPid), false, ...);         // no polling at all

It silently borrowed the Chrome fleet's poll deadline for a condition it never polled, and passed only while the fleet happened to outlast the daemon.

Both endings now settle concurrently from the same SIGTERM, each against its own deadline, with no ordering asserted between them.

Why

Nothing enforces that order. The daemon awaits the agent-browser close CLI's return, never the disappearance of the Chrome pids that call reaps, and awaits it under a teardown-budget race (WEB_BROWSER_SESSION_TEARDOWN_BUDGET_MS + DAEMON_SESSION_TEARDOWN_TIMEOUT_MS) it walks away from on expiry. Either can finish first, and both orders have been observed:

The guarantee #1868 is actually about — the fleet closed because the daemon closed it, not because agent-browser's idle timer beat the test — is held by WEB_SHUTDOWN_IDLE_TIMEOUT_MS, not by any ordering between these two. So the ordering is asserted nowhere, on purpose, and the comment records why.

Notes for the reviewer

settleDaemonExit waits out the daemon's identity, not the bare pid. A pid the host recycles inside the 45s window is a different process, and reading it as "the daemon is still alive" would fail this lane for the one reason it must not — a false red. It polls isAgentDeviceDaemonProcess(pid, startTime) (already exported; this file already imports from that module, and the sibling test at the top of the file exists for the same hazard). That also makes WEB_SHUTDOWN_SETTLE_POLL_MS the right interval, since the identity probe shells out to ps like the fleet sweep. No new constant, no new machinery, no new module edge — a structural sibling of settleManagedBrowserProcesses sharing both existing constants.

No fixed sleep. The 45s is a failure bound, not a wait; the happy path returns as soon as the daemon is gone. There is no event-based alternative: the daemon is not a child of the test process (the CLI spawns it detached and exits), Node exposes neither kqueue NOTE_EXIT nor pidfd, and every daemon artifact (lock file, daemon.json, shutdown report) is written before exit() — so watching one would prove the daemon decided to exit, which is the weaker proxy claim this bug came from in the first place.

Residual limits, stated rather than hidden: the 45s is derived from the daemon's 35s web-session teardown budget and must grow with it; a zombie daemon would still read as alive (documented at src/utils/host-process.ts:64), unreachable here because the reparented daemon is reaped by launchd/init.

Verification — planted red, then green

Plant: await sleep(N) immediately before exit() in src/daemon/server/daemon-runtime.ts (reverted; not in this diff).

Daemon Assertion Result
sleep(25s) old ✖ fails at 18.2s — expected the daemon process itself to have exited after SIGTERM, the exact CI message
sleep(25s) new ✔ passes at 40.6s — waits it out instead of reading once
sleep(120s) new ✖ fails at the deadline — still alive 45128ms later
unmodified new ✔ ✔ 13.4s / 13.5s — unchanged from before the fix

Run with AGENT_DEVICE_WEB_E2E=1 pnpm test:smoke:web. Healthy-path wall clock is unchanged because the two settles run concurrently. pnpm lint and pnpm typecheck both clean.

Test-only change; #2074 was not touched.

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 2.48 MB 2.48 MB +700 B
JS gzip 834.0 kB 834.3 kB +277 B
npm tarball 957.2 kB 957.5 kB +240 B
npm unpacked 3.32 MB 3.32 MB +700 B

npm unpacked components

Component Base Current Diff
JS / dist source 2.63 MB 2.64 MB +700 B
Apple runner source/project 581.1 kB 581.1 kB 0 B
macOS helper source 54.8 kB 54.8 kB 0 B
Android helper artifacts 0 B 0 B 0 B
Other package files 45.3 kB 45.3 kB 0 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 30.6 ms 30.6 ms +0.0 ms
CLI --help 80.7 ms 81.2 ms +0.5 ms

Top changed chunks: no changes in the largest emitted chunks.

Top changed packed files

Packed file Base Current Diff
dist/src/daemon-process.js 642 B 1.3 kB +617 B
dist/src/daemon.js 3.6 kB 3.7 kB +83 B

@thymikee

Copy link
Copy Markdown
Member Author

[P1] Put identity-aware daemon exit waiting at the daemon-process owner and reuse it here.

The new test-local 45s poll correctly treats the original daemon identity disappearing as success, but duplicates lifecycle policy beside the owner it already imports. More importantly, stopProcessForTakeover checks identity only before SIGTERM, waits on the bare pid, and can then SIGKILL a recycled pid; the sibling daemon-stop path already re-verifies before escalation. Add a typed waitForDaemonExit(identity, timeout/poll) under daemon-process, consume it from this smoke and the stop/takeover paths, re-verify before any escalation, and add deterministic planted-red coverage for the pid identity changing during the grace wait. Replace the paragraph-long test rationale with the owning interface and invariant made obvious in code.

thymikee added a commit that referenced this pull request Aug 27, 2026
Review feedback on #2083: the smoke lane had grown its own identity-aware poll
beside the owner it already imported, and the owner itself was weaker than the
copy.

Add `waitForDaemonExit(identity, { timeoutMs, pollMs })` to daemon-process, the
module that owns daemon lifecycle policy, and consume it from the web shutdown
smoke, `stopProcessForTakeover` and `stopDaemon`. The test-local helper and its
paragraph of rationale are gone; the invariant now lives in the interface.

This closes a real defect in `stopProcessForTakeover`. It verified identity only
before SIGTERM, then waited on the bare pid: a daemon that exited and had its pid
reused read as "still running", and the takeover escalated SIGKILL onto whatever
now held that number. Escalation is no longer a rule each call site remembers —
`signalDaemonIdentity` re-reads identity immediately before every signal, so a
recycled pid cannot be signaled at all. `stopDaemon` already re-verified before
SIGKILL; it now also stops misreporting a recycled pid as "did not exit".

`classifyDaemonPid` names the four states a pid can be in, including the one a
bare liveness read cannot express: a process that has died but has not been
reaped answers kill(pid, 0) and still reports its original start time, while ps
shows its command as `<defunct>`. Reading that as a recycled pid would hand the
number back while it is still taken, so it counts as neither ending and the wait
keeps polling — preserving the guarantee callers had before.

Deterministic coverage in daemon-exit-wait.test.ts: identity changing mid-wait,
a zombie waited through to its reap, no SIGKILL after a recycled grace wait, and
escalation still reached for a daemon that genuinely survives SIGTERM. Verified
planted-red — reverting stopProcessForTakeover to the bare-pid wait fails the
recycled-pid test alone, on its assertion, inside the unit lane's clock budget.
@thymikee
thymikee force-pushed the claude/laughing-einstein-ce3b4f branch from 82aee9b to 029a10d Compare August 27, 2026 14:36
@thymikee

Copy link
Copy Markdown
Member Author

Addressed in 029a10dc, and you were right that the owner was the weaker of the two.

waitForDaemonExit(identity, { timeoutMs, pollMs }) now lives in daemon-process.ts and is consumed by the web shutdown smoke, stopProcessForTakeover and stopDaemon. The test-local helper and its paragraph of rationale are deleted — the smoke test's local identity type is now the owner's DaemonProcessIdentity too.

The takeover defect is fixed. Rather than re-verifying at each call site, signalDaemonIdentity re-reads identity immediately before every signal, so a recycled pid cannot be signaled at all — escalation safety is a property of the call instead of a rule to remember. stopDaemon kept its explicit pre-SIGKILL guard (its throwing signal semantics matter there) and additionally stops misreporting a recycled pid as "did not exit".

One thing your review surfaced that I had wrong too. Waiting on identity alone regressed daemon-process-takeover.test.ts. A daemon reaped by its own parent lingers as a zombie: kill(pid, 0) still succeeds and the start time still matches, but ps reports the command as <defunct> — which my first pass classified as a recycled pid and handed the number back while it was still taken. classifyDaemonPid now names all four states, and the zombie counts as neither ending, so the wait keeps polling and callers keep the guarantee they had before. That is the reason the classifier exists rather than a one-line predicate.

Coverage (src/daemon/__tests__/daemon-exit-wait.test.ts, host reads mocked since a live process cannot be made to change identity mid-wait): identity changing mid-wait, a zombie waited through to its reap, no SIGKILL after a recycled grace wait, and escalation still reached for a daemon that genuinely survives SIGTERM. Planted-red verified — reverting stopProcessForTakeover to the bare-pid wait fails the recycled-pid test alone, on its assertion, inside the unit lane's clock budget. Budgets are injected so a regression fails on the assertion rather than on the slow-test gate.

Branch rebased onto main. pnpm typecheck, pnpm lint, src/daemon (2341 tests) and the live AGENT_DEVICE_WEB_E2E=1 pnpm test:smoke:web lane all pass. The one red in src/daemon is gesture-admission-parity.test.ts tripping the wall-clock gate; it is not in this diff and is over budget in isolation on an idle checkout (3.2s–5.0s depending on load).

🤖 Addressed by Claude Code

@thymikee

Copy link
Copy Markdown
Member Author

[P2] Remove the new implementation-history/control-flow comments. Current main’s AGENTS.md rejects comments that narrate a workaround or preserve review history, yet the daemon-process helper/type blocks, daemon-exit-wait test comments, and edited smoke comment still do exactly that. The identity type, classifier, one owning wait helper, and planted-red assertions now make the invariant evident in code; retain only necessary public API documentation. The implementation and exact-head CI/live-web evidence are otherwise clean.

…utdown lane

The daemon-exit assertion in the #1868 shutdown lane was an instantaneous
liveness read placed right after settleManagedBrowserProcesses, so it silently
borrowed the Chrome fleet's poll deadline for a condition it never polled. It
passed only while the fleet outlasted the daemon.

Nothing enforces that order. The daemon awaits the `agent-browser close` CLI's
return, never the disappearance of the Chrome pids that call reaps, and awaits
it under a teardown-budget race it walks away from on expiry — so either can
finish first, and both have been observed: macOS reaps the fleet ~13s after the
daemon, while CI once had the daemon still alive ~4.8s after the fleet and
failed the lane. A re-run of the same commit passed.

Settle both endings concurrently from the same SIGTERM, each against its own
deadline, and assert no ordering between them. The guarantee #1868 is actually
about — the fleet closed because the daemon closed it, not because
agent-browser's idle timer beat us — is held by WEB_SHUTDOWN_IDLE_TIMEOUT_MS,
not by any ordering here.

settleDaemonExit waits out the daemon's identity rather than the bare pid: a
pid the host recycles inside the 45s window is a different process, and reading
it as "the daemon is still alive" would fail this lane for the one reason it
must not — a false red. It reuses isAgentDeviceDaemonProcess and both existing
settle constants, and reports elapsed time so a deadline failure says how long
the daemon outlived its SIGTERM.
Review feedback on #2083: the smoke lane had grown its own identity-aware poll
beside the owner it already imported, and the owner itself was weaker than the
copy.

Add `waitForDaemonExit(identity, { timeoutMs, pollMs })` to daemon-process, the
module that owns daemon lifecycle policy, and consume it from the web shutdown
smoke, `stopProcessForTakeover` and `stopDaemon`. The test-local helper and its
paragraph of rationale are gone; the invariant now lives in the interface.

This closes a real defect in `stopProcessForTakeover`. It verified identity only
before SIGTERM, then waited on the bare pid: a daemon that exited and had its pid
reused read as "still running", and the takeover escalated SIGKILL onto whatever
now held that number. Escalation is no longer a rule each call site remembers —
`signalDaemonIdentity` re-reads identity immediately before every signal, so a
recycled pid cannot be signaled at all. `stopDaemon` already re-verified before
SIGKILL; it now also stops misreporting a recycled pid as "did not exit".

`classifyDaemonPid` names the four states a pid can be in, including the one a
bare liveness read cannot express: a process that has died but has not been
reaped answers kill(pid, 0) and still reports its original start time, while ps
shows its command as `<defunct>`. Reading that as a recycled pid would hand the
number back while it is still taken, so it counts as neither ending and the wait
keeps polling — preserving the guarantee callers had before.

Deterministic coverage in daemon-exit-wait.test.ts: identity changing mid-wait,
a zombie waited through to its reap, no SIGKILL after a recycled grace wait, and
escalation still reached for a daemon that genuinely survives SIGTERM. Verified
planted-red — reverting stopProcessForTakeover to the bare-pid wait fails the
recycled-pid test alone, on its assertion, inside the unit lane's clock budget.
…change

AGENTS.md allows only public API docs, tool directives, and a brief citation to
an external constraint. The identity type, the pid-state union, the single wait
helper and the planted-red assertions carry the invariant; the prose restating
them, and the review history behind them, belong in the PR.

Kept: the exported identity/result types and the wait helper's contract, plus a
two-line citation for the one thing code cannot encode — a terminated pid awaiting
reap answers kill(pid, 0), keeps its start time, and reports `<defunct>`.
@thymikee
thymikee force-pushed the claude/laughing-einstein-ce3b4f branch from 029a10d to f6bf3e2 Compare August 27, 2026 17:30
@thymikee

Copy link
Copy Markdown
Member Author

Removed in f6bf3e23 (branch also rebased onto 4454aef1).

Every comment this PR added is gone except four, all of which the rule permits:

  • DaemonProcessIdentity and DaemonExitWait.exited — docs on exported types.
  • waitForDaemonExit — the exported helper's contract (what counts as having left the host).
  • A two-line citation on classifyDaemonPid for the one thing the code cannot encode: a terminated pid awaiting reap answers kill(pid, 0), keeps its start time, and reports its command as <defunct>.

Dropped: the DAEMON_EXIT_POLL_MS cost note, the DaemonPidState paragraph, the signalDaemonIdentity rationale, the mock/planted-red narration in daemon-exit-wait.test.ts, and the settle comment I had edited in the smoke test. The pre-existing smoke-test comments are untouched — they are not part of this change. git diff origin/main...HEAD -U0 | grep '^+.*//' returns only the four blocks above.

Verified at f6bf3e2380ad3adfc4852682c273c76f88a16c39: pnpm typecheck, pnpm lint, pnpm check:layering, pnpm check:production-exports (none of the new exports flagged), the four affected daemon suites (18 tests), and the live AGENT_DEVICE_WEB_E2E=1 pnpm test:smoke:web lane — all green.

🤖 Addressed by Claude Code

@thymikee

thymikee commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

Re-reviewed exact head f6bf3e2380: identity-aware daemon exit waiting now lives at the daemon-process owner, is reused by smoke and stop/takeover paths, and planted tests cover recycled PIDs, zombies, timeout, and genuine escalation. Comment cleanup is complete, current-head live web shutdown evidence and all exact-head CI/native lanes are green, CLEAN/MERGEABLE. Merge-ready.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 27, 2026
@thymikee
thymikee merged commit d9c74c7 into main Aug 27, 2026
18 checks passed
@thymikee
thymikee deleted the claude/laughing-einstein-ce3b4f branch August 27, 2026 18:08
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-27 18:12 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.

1 participant