Skip to content

test: catch daemon lifecycle and durable-state leaks (#1781 B1) - #1859

Open
thymikee wants to merge 8 commits into
mainfrom
test/1781-b1-daemon-leak-oracle
Open

test: catch daemon lifecycle and durable-state leaks (#1781 B1)#1859
thymikee wants to merge 8 commits into
mainfrom
test/1781-b1-daemon-leak-oracle

Conversation

@thymikee

@thymikee thymikee commented Aug 18, 2026

Copy link
Copy Markdown
Member

Summary

Adds a daemon lifecycle and durable-state leak oracle around the three existing real-subprocess daemon lanes.

After session close or daemon shutdown it detects:

  • a daemon that survived a best-effort stop;
  • an unfinalized capture descriptor or legacy app-log marker whose owning session is gone;
  • torn .tmp publishes, empty unswept directories, stale daemon metadata, and unknown state-dir artifacts.

The phase and its required identity are one discriminated shape: an after-close checkpoint must name at least one closed session, so the vacuous form that accepts every open capture handle is not expressible. The standalone CLI enforces the same rule at runtime.

This PR deliberately no longer infers daemon-owned processes from a host-wide ps snapshot. #1882 owns that work at the correct boundary: persist exact process identity at the real owners (including the already-durable capture descriptor and agent-browser's indirect daemon lifecycle), then assert the record during shutdown/recovery. Until that lands, this PR makes no claim to catch #1109/#1324 process leaks.

Refs #1781 B1, #1431, #1882.

Validation

The surviving-daemon rule is red-proven against this branch's pre-fix behavior: a live daemon after shutdown was previously reported clean and now fails.

The provider-backed close regression drives a live screen recording through the real daemon close route. Reverting production finalization leaves screen-recording.resource.json open and makes the oracle fail; the pre-fix classifier stayed green on the same fault.

The standalone CLI regression proves --phase after-close without --closed-session is refused before a report can certify the state dir.

Local evidence on 919da440a:

  • 21/21 pure lifecycle/residue model cases;
  • four real-subprocess daemon/CLI regressions;
  • provider-backed close-route regression;
  • full pnpm check:affected --run runnable set.

Current scope: 9 files, 728 additions / 16 deletions. No production, workflow, user-facing docs, or skills files changed. The Web smoke is no longer modified.

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 2.33 MB 2.33 MB 0 B
JS gzip 765.0 kB 765.0 kB 0 B
npm tarball 888.6 kB 888.6 kB 0 B
npm unpacked 3.10 MB 3.10 MB 0 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 29.5 ms 29.4 ms -0.1 ms
CLI --help 71.9 ms 73.9 ms +2.0 ms

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

@thymikee

Copy link
Copy Markdown
Member Author

CI note: every job is green except Coverage, which fails on test-file-size-ratchet for src/daemon/handlers/__tests__/snapshot-handler.test.ts (2654 lines vs pin 2652) — a file this PR does not touch. That is a main-side merge skew: #1843 pinned the file at 2652 while #1847, merged concurrently, grew it to 2654; main's own CI run on b12a3e3cb fails the same way (https://github.com/callstack/agent-device/actions/runs/32166867194). The Integration Tests job — the one that runs the three lanes this PR instruments — is green. Keeping this PR in draft until main is fixed, then gh run rerun (no no-op commits).

@thymikee

Copy link
Copy Markdown
Member Author

Not ready at exact 84c81b63.

  • P1: B1 requires checks after session close and after daemon shutdown, but every wired oracle call occurs only after shutdown. The after-close classifier also lacks the closed-session identity and accepts all capture descriptors/markers, so it cannot detect a handle that survives close but is cleaned during shutdown. Add a live-daemon checkpoint scoped to the closed session.
  • P1: The oracle has no automated leak-producing/model regression. These device-free green lanes create no owned child/capture leak, so breaking the ownership or residue classifier can leave CI green. Add focused red regressions for process ownership, capture state, temporary/unknown residue, settle, and cleanup behavior.
  • P2: Directory entries are filtered out entirely, allowing empty stray or .tmp directories despite the every-entry claim.
  • P2: The 364-line support module exceeds the repository’s 300-line context tripwire and combines process ownership, artifact classification, polling/reporting, and CLI concerns. Split it with matching test topology.

The Coverage failure is inherited main-side ratchet skew, not caused here. The PR remains draft and unready.

@thymikee
thymikee force-pushed the test/1781-b1-daemon-leak-oracle branch from 84c81b6 to dac78ed Compare August 19, 2026 05:59
@thymikee
thymikee marked this pull request as ready for review August 19, 2026 06:11
@thymikee

Copy link
Copy Markdown
Member Author

CI note: rebased onto current origin/main (fda81c512) after #1860 fixed the snapshot-handler.test.ts ratchet-pin skew that made the earlier Coverage run red. Fresh CI on the actual head dac78edc6: 28/28 checks pass, including Integration Tests (the job that runs the three lanes this PR instruments), Coverage, and the four device Smoke Tests. The earlier "Smoke Tests fail" on this PR was a cancelled superseded run, not a failure. Undrafting.

thymikee added a commit that referenced this pull request Aug 19, 2026
…ules

Review of #1859 found the oracle reported "clean" when the daemon itself
outlived shutdown: daemon pids were excluded from ownership, liveDaemonPids
never reached hasDaemonLeaks, and a live daemon even flipped daemon.json/lock
from stray to expected. stopProcessForTakeover is best-effort void, so only
smoke-daemon-clean independently asserted death.

- a live daemon pid at phase 'after-shutdown' is now itself a leak, and its
  metadata files stay stray; a live daemon remains legitimate at 'after-close'
- split the pure ownership/residue rules into daemon-leak-model.ts and pin them
  with daemon-leak-model.test.ts, using the real ps rows captured during the
  #1109 and #1324 red-proofs (the lanes' daemons own no children, so the fixture
  test is what guards those shapes in CI)
- exempt the managed tools/ install tree before the .tmp rule, so agent-browser's
  own download temporaries are no longer a false LEAK
- report empty directories as residue (an unswept session scaffold leaves no file)
- reuse src/utils/host-process.ts (expandProcessTree, uniquePositivePids) and its
  /bin/ps convention instead of re-deriving them
- assert in smoke-daemon-http on the success path, not in finally, so the settle
  window cannot replace a primary assertion's diagnostic

Refs #1781 #1431
@thymikee

Copy link
Copy Markdown
Member Author

Review round addressed — pushed as 09ac16db1; PR body rewritten (the Summary no longer claims the lanes assert an owned-process set they cannot observe).

Blocker 1 — surviving daemon reported clean: fixed. A live daemon pid at after-shutdown is now itself a leak, and its daemon.json/daemon.lock stay stray instead of being excused by its own survival; a live daemon remains legitimate at after-close. Red-proof of the exact case you reproduced is in the PR body (live: 60176daemons that outlived shutdown: 1, exit 1; same pid at --phase after-close → clean; after a real shutdown → clean).

Blocker 2 — red-proof does not transfer to the lanes: stated, not papered over. The PR body now carries a per-arm table: the surviving-daemon and state-dir-residue arms are genuinely guarded by the three lanes in CI; the owned-process arm asserts 0 == 0 there and is structurally incapable of rediscovering #1109/#1324 from device-free lanes. What guards those shapes in CI is now daemon-leak-model.test.ts, which replays the real ps rows captured during both red-proofs. Wiring the oracle into a lane that opens a real session is named as the next step and noted on #1431 — deliberately out of B1's scope rather than silently missing.

Should-fix, all done. (3) tools/ is exempted before the .tmp rule, so agent-browser's own download.tmp is no longer a false LEAK — verified end-to-end on a real state dir. (4) empty directories are reported (sessions/<name>/artifacts/pending/ is flagged). (5) after-close is now exercised by the model test rather than dead. (6) reuses expandProcessTree/uniquePositivePids from src/utils/host-process.ts and its absolute /bin/ps; the second ps for pgid+environment stays local rather than adding columns to production for a test-only need. (7) daemon-leak-model.test.ts, 20 fixture cases, registered in unit-core — each rule proven red by reverting it (one fixture was vacuous under revert and was replaced; details in the body).

Nits. smoke-daemon-http now stops and asserts on the success path, so the settle window cannot replace a primary assertion's diagnostic, and it no longer skips silently when daemon.json is gone; the pgid comment states the pid-reuse caveat; the ownership regexes are hoisted per snapshot.

Recorded as you asked: both #1109 arms pinned AGENT_DEVICE_WEB_IDLE_TIMEOUT_MS=15000 + --settle-ms 40000; with the default 5-minute window that scenario is red on main, so a future web lane must pin the idle timeout. The web-shutdown asymmetry is linked as #1868 rather than re-described.

@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact 09ac16db: the surviving-daemon fix, model/oracle split, directory classification, planted proofs, and exact-head CI are sound.

P1 remains: no real lane performs an after-close checkpoint, and the model cannot identify the session just closed. In after-close, every capture descriptor/legacy marker is accepted, so a still-open descriptor from the closed session is indistinguishable from another legitimately live session. Pass closed-session identity into the observation/model, refuse that session’s open descriptor/marker while allowing other sessions, and add a real route regression immediately after close that goes red when the closed session resource remains open.

thymikee added a commit that referenced this pull request Aug 19, 2026
…closed

Re-review of #1859: `after-close` accepted every capture descriptor and legacy
marker, so the closed session's unfinalized handle was indistinguishable from
another session's legitimately live one — the phase could not fail, the same
shape as the surviving-daemon blocker, and it left half of B1's stated scope
undelivered.

- the observation carries the session directories closed at the checkpoint; a
  capture handle must be finalized once its owning session is gone (every
  session after shutdown, only the closed ones after close), while another
  session's live handle stays expected and a legacy pid marker is never a
  finish record
- the oracle accepts `closedSessions` and a `sessionsDir` override, normalizing
  entries to the canonical `sessions/<name>/…` shape so an in-process harness
  rooted directly at its own sessions dir is classified the same way
- add a real route regression: a provider-backed session with a live screen
  recording is closed through the daemon route, and the oracle refuses a
  descriptor left `lifecycle: "open"`. Reverting the close-route finalization
  (session-close-lifecycle-teardown.ts, the #1325 fix) turns it red naming
  sessions/default/screen-recording.resource.json; with the pre-fix model it
  stays green, which is the P1 in one line

Refs #1781 #1431
@thymikee

Copy link
Copy Markdown
Member Author

P1 fixed in f48d5d697after-close can now fail, and does.

The rule. The observation carries the session directories closed at the checkpoint. A capture handle must be finalized once its owning session is gone: every session at after-shutdown, only the closed ones at after-close. Another session's live handle stays expected; a legacy app-log.pid marker is never a finish record, so it is stray whenever its session is gone. The oracle also takes a sessionsDir override and normalizes entries to the canonical sessions/<name>/… shape, so an in-process harness rooted directly at its own sessions dir is classified identically.

The route regression. test/integration/provider-scenarios/session-close-leak-oracle.test.ts starts a real screen recording on a provider-backed session and closes it through the daemon route, then runs the oracle at after-close naming that session. It runs in the existing provider-integration project — no new CI job.

Red proof, and the vacuity check you warned about:

oracle rules production close-route finalization (#1325, session-close-lifecycle-teardown.ts:139) route regression
pre-fix (after-close accepts everything) reverted — descriptor stays lifecycle: "open" passes ← the P1 in one line
this PR reverted fails
this PR intact passes
=== #1325 close-route recording finalization REVERTED:
   × Provider-backed integration closing a recording session leaves no unfinalized capture handle
Caused by: Error: daemon leak oracle: LEAK (after-close)
  stray state-dir entries: 1
    sessions/default/screen-recording.resource.json
=== restored:   Tests  1 passed (1)

Both reverts were temporary (git diff src/ is empty). Worth recording: my first attempt at this regression was vacuous in the way you flagged — I reverted the recording step in session-teardown.ts and the test still passed, because the close route finalizes through session-close-lifecycle-teardown.ts instead. The proof above is against the line this route actually depends on.

Both phases now have a lane that can fail: after-shutdown on the three CLI daemon lanes (surviving daemon + residue), after-close on this regression. The PR body's coverage table is updated to say so, and no allowlist was widened.

Also added four model cases for the new rule (24 total), and re-ran the whole provider-integration project: 158/158.

@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact f48d5d697: the provider-backed regression now exercises the real close handler and the #1325 finalization owner; its red/vacuity proof is sound, and exact-head CI is green.

One blocker remains: after-close identity is still optional at the owning interface. closedSessions?: ... defaults to [], so any caller—or the standalone CLI with --phase after-close but no --closed-session—silently restores the old vacuous behavior and accepts every open capture. Make the options/observation phase-discriminated so after-close requires a non-empty closed-session set (and reject an empty CLI invocation); keep after-shutdown independent. Then this is ready.

thymikee added a commit that referenced this pull request Aug 19, 2026
… interface

Re-review of #1859: `closedSessions` was optional and defaulted to empty, so any
caller — or the standalone CLI invoked with `--phase after-close` and no
`--closed-session` — silently restored the vacuous checkpoint that accepts every
unfinalized capture handle and reports clean.

- phase and the identity that phase needs are now one discriminated shape:
  { phase: 'after-close'; closedSessions: NonEmpty<string> } | { phase:
  'after-shutdown' }, so the empty case is not expressible and 'after-shutdown'
  is unchanged
- the CLI refuses the same invocation with a typed INVALID_ARGS error and a hint
  naming what the missing identity would have cost, instead of degrading
- daemon-leak-oracle-cli.test.ts drives the real CLI: the unnamed after-close
  invocation must fail without reporting, the named one finds the planted
  unfinalized handle, and after-shutdown is unaffected. Reverting the guard makes
  it print 'clean (after-close)' over that same handle and turns the test red

Refs #1781 #1431
@thymikee

Copy link
Copy Markdown
Member Author

Done in 3a5b9bef3 — the after-close identity is now required at the owning interface, not passed by convention.

Type shape. Phase and the identity that phase needs are one discriminated shape:

export type NonEmpty<T> = readonly [T, ...T[]];

export type DaemonLeakPhaseSelection =
  | { phase: 'after-shutdown' }
  | { phase: 'after-close'; closedSessions: NonEmpty<string> };

DaemonLeakOracleOptions and DaemonLeakObservation are both intersections with that union, so { phase: 'after-close' } and closedSessions: [] are type errors — the vacuous mode is no longer expressible, and after-shutdown is untouched (no extra field, same call sites). The model reads the set through one closedSessionsOf narrowing rather than a defaulted option. The fixture test's own observe() helper had to be split for the same reason, which is the guard proving itself: its old default (closedSessions: []) stopped compiling.

CLI refusal. parseDaemonLeakOracleArgs is the CLI's only path from strings to options; --phase after-close with no --closed-session throws a typed AppError('INVALID_ARGS', …) whose hint names what the missing identity would have cost. It never reaches a checkpoint, so it cannot print a verdict.

Refusal regression (test/integration/daemon-leak-oracle-cli.test.ts, real subprocess invocations, ~0.7 s, in the existing Integration Tests job). Against a state dir holding a closed session's unfinalized handle: (1) the unnamed after-close invocation exits non-zero, matches the refusal message, and prints no report; (2) the same invocation with --closed-session closed-one exits 1 and names sessions/closed-one/screen-recording.resource.json; (3) after-shutdown is unaffected.

And the refusal claim is itself red-proven — reverting the guard to the old optional/empty-tolerated behaviour:

=== CLI guard REVERTED (identity optional, empty tolerated):
✖ the leak oracle CLI refuses an after-close checkpoint that names no session
  AssertionError: expected a refusal, got:
  daemon leak oracle: clean (after-close)
=== restored:  ℹ pass 1  ℹ fail 0

That failure message is the exact vacuous mode the guard exists to prevent: clean (after-close) printed over a state dir that contains an unfinalized capture handle.

All four node lanes pass (4/4), the model test 24/24, the provider-backed close regression 1/1, pnpm check:affected --run green. Coverage table in the PR body updated with the guard's own row.

@thymikee

Copy link
Copy Markdown
Member Author

Re-review of 3a5b9bef: clean. The after-close identity is now required at the type boundary and the standalone CLI refuses the corresponding untyped invocation; the provider-backed close-route regression proves an open descriptor for the named closed session fails, while another live session remains permissible. Exact-head CI is green. Please correct the Validation cost from “~700 LOC” to the current 1,030 additions / 15 deletions (all test/support) so the harness-cost record stays accurate.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 19, 2026
@thymikee

Copy link
Copy Markdown
Member Author

Trimmed in ce9f0eeb2. The hypothesis held, with one correction worth stating plainly: the lane-path harness shrinks by a third, but the repo total is roughly flat — the ownership reconstruction can be relocated, not deleted. That is itself the evidence for the follow-up.

Per file, before → after:

File Before After
daemon-leak-model.ts 315 178
daemon-leak-oracle.ts 259 172
daemon-leak-model.test.ts 278 198
lane-path subtotal 852 548 (−36%)
daemon-owned-process-probe.ts (new, manual) 218
daemon-owned-process-probe.test.ts (new, fixtures) 74
daemon-leak-oracle-cli.test.ts 61 61
session-close-leak-oracle.test.ts 76 76
total 989 977

What moved, and what was deleted. Moved into the probe: the four ownership rules and their ps reconstruction (findOwnedProcesses, the state-dir matchers, the pid/pgid/env reader, ancestorsOf), plus the process half of the report. Deleted outright: previewCommand, escapeRegExp and formatPids' owned-process branch from the model, the HostProcess/OwnedProcess/OwnershipReason types and processes/excludedPids fields from the observation (the oracle no longer shells out at all — its capture is now synchronous), and the report's owned-process block. Nothing else changed shape.

Nothing was dropped. The three ownership fixture cases did not die with the arm — they moved to daemon-owned-process-probe.test.ts, because with no lane able to run the probe those fixtures are its only CI guard. Keeping them was also what fallow demanded: after the move it flagged findOwnedProcesses as untested complexity (CRAP 132), which is the honest signal, so I split the function and covered it rather than suppressing.

Re-run #1324 red-proof through the moved code — pre-fix a84caa81823f8c4fc03bd92f1dd51e583e046e99, iPhone 16 simulator, open → record startkill -TERM:

$ node --experimental-strip-types test/integration/support/daemon-owned-process-probe.ts --state-dir $SD --daemon-pid 88907
daemon-owned processes: LEAK
    pid 91862 ppid 1 pgid 88907 [process-group] …/simctl io 416440AE-… recordVideo <state-dir>…
exit=1          (recording.mp4: 0 bytes)
--- control, once the orphan is SIGINT-reaped:
daemon-owned processes: clean          exit=0          (recording.mp4: 54230 bytes)

Every retained rule still reds under revert. Model (21 cases): surviving-daemon, empty-directory, tools-before-.tmp ordering, torn-publish, closed-session identity, daemon.json liveness gate, capture finalization — each reverted in turn produced a failure, restored 21/21. Probe (3 cases): pgid, state-dir-env, state-dir-argv, descendant propagation — same, restored 3/3. The CLI refusal and the provider-backed close regression are unchanged and still pass.

Follow-up filed: #1882 — the daemon should record owned child pids the way it records captures. Then the arm becomes "read the record, assert they are dead": one rule instead of four, assertable in every lane including the device-free ones, immune to pid reuse and to macOS hiding Apple binaries' environment, and it doubles as the startup reaper's input. Linked from the PR body; #1431 wants it for the same reason.

Four node lanes 4/4, provider scenario 1/1, unit fixtures 24/24, pnpm check:affected --run green.

@thymikee thymikee removed the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 19, 2026
@thymikee

Copy link
Copy Markdown
Member Author

Two blockers at exact head ce9f0eeb:

  1. [P1] Keep the daemon-owned-process arm enforceable in CI. This split makes the two failures B1 is meant to prevent (web: agent-browser Chrome fleets leak and can spin at 100% CPU for days — add idle lifecycle/reaper #1109 and iOS simulator recorder leaks on session/daemon teardown while recording is active (0-byte recording.mp4, "Host recording is already in progress") #1324) detectable only by a manually invoked probe; the fixture test proves regexes recognize synthetic rows, not that the shipped route reaps the process it spawned. The paragraph-long rationale for why no lane can execute this is evidence that the ownership invariant is at the wrong boundary. Record owned child PIDs at the daemon/process-launching interface, or exercise an existing device/browser route that creates the child, and make the post-shutdown oracle assert it in CI. A manual red-proof can remain supplemental, not the regression guard.

  2. [P2] Preserve cleanup on the replacement-daemon failure path. daemon-replace-exit-flush.test.ts sets info = null before assertNoDaemonLeaks. If the stop fails and the assertion finds the live daemon, finally skips its retry and removes the state directory while leaving the daemon running. Clear info only after the oracle succeeds.

@thymikee
thymikee force-pushed the test/1781-b1-daemon-leak-oracle branch from d793687 to ddc4307 Compare August 19, 2026 15:13
@thymikee

Copy link
Copy Markdown
Member Author

Both blockers fixed in ddc4307fb.

P1 — resolution (b), and it can really fail. I took the reviewer's second option: exercise an existing route that actually creates a child. That route already exists in CI — smoke-web-platform.test.ts (the Web Platform Smoke job) installs the real managed agent-browser and launches a real Chrome fleet on ubuntu-latest. It is the #1109 route itself.

I chose (b) over (a) because (a) is the wrong size for this PR, not because it is wrong: recording owned pids means touching the daemon's process-launching interface — production surface, in a test-only PR, on a Hard-Rule module (src/utils/exec.ts) — and it would still need this lane to prove the record is written and honoured. (b) needed ~35 lines in one lane and is provable today; (a) is filed as #1882 and, once landed, deletes four heuristics rather than adding to them.

The arm is back in the shipped oracle — git diff 3a5b9bef3 over the three files is empty, so the code that produced the #1109/#1324 red-proofs is the code that ships, and the manual probe is gone (one code path, used by CI and by hand).

The checkpoint is #1109's acceptance criterion. After the normal smoke the lane reopens a session, stops the daemon with that session still open, and requires that nothing owned outlives the browser idle window. The unclosed session is the whole point: my first attempt put the checkpoint after close, planted a leak, and the lane stayed green — because close reaps the fleet, so only an orphaned session can strand it. That is how #1109 was found, and it is now what the lane reproduces.

Proof it goes red on a leaked child, same lane, browser idle window raised past the settle budget:

=== PLANTED LEAK (browser idle 10 min, settle 20 s):
✖ live web platform e2e smoke (43.6 s)
  Error: daemon leak oracle: LEAK (after-shutdown)
    owned processes still alive: 15
      pid 34028 ppid 1 pgid 34028 [state-dir-env,state-dir-argv] <state-dir>/…/agent-browser-darwin-arm64
      pid 34031 ppid 34028 pgid 34031 [state-dir-env] …/chrome-152.0.7977.42/Google Chrome for Testing.app/…
      … 13 more Chrome helper/renderer processes …

Unmodified: green in 59 s. Cost: ~35 s added to a 30-minute job. The checkpoint also refuses to skip silently — missing daemon.json or a missing pid is an assertion failure, not an early return.

P2 — fixed. daemon-replace-exit-flush now clears info only after assertNoDaemonLeaks passes, so a failed best-effort stop still reaches the finally retry instead of having the state dir removed from under a live daemon.

LOC: 989 in the harness (852 shipped + 137 in the three regression tests), against 977 at ce9f0eeb and 989 at 3a5b9bef3 — the trim is reverted, as you preferred over an unguarded invariant, and the real reduction is #1882's to make. All retained rules still red under revert (24 model cases, verified rule by rule); four node lanes 4/4; provider close regression 1/1; pnpm check:affected --run green.

@thymikee

Copy link
Copy Markdown
Member Author

The prior P1 and P2 fixes are sound at ddc4307: Web Smoke now kills a daemon with a real managed browser session and asserts the owned-process oracle through the idle window, and the replacement-daemon test retains cleanup authority until the oracle passes. Not g2g yet: this head conflicts with current main in smoke-web-platform.test.ts and vitest.config.ts, and its full CI/Size evidence predates the head. Resolve/rebase without dropping main’s screenshot-dimensions coverage, then rerun the affected Integration/Web smoke, provider/unit/Coverage, and Size jobs on the resulting exact head.

@thymikee
thymikee force-pushed the test/1781-b1-daemon-leak-oracle branch from ddc4307 to 888a479 Compare August 19, 2026 15:26
thymikee added a commit that referenced this pull request Aug 19, 2026
…ules

Review of #1859 found the oracle reported "clean" when the daemon itself
outlived shutdown: daemon pids were excluded from ownership, liveDaemonPids
never reached hasDaemonLeaks, and a live daemon even flipped daemon.json/lock
from stray to expected. stopProcessForTakeover is best-effort void, so only
smoke-daemon-clean independently asserted death.

- a live daemon pid at phase 'after-shutdown' is now itself a leak, and its
  metadata files stay stray; a live daemon remains legitimate at 'after-close'
- split the pure ownership/residue rules into daemon-leak-model.ts and pin them
  with daemon-leak-model.test.ts, using the real ps rows captured during the
  #1109 and #1324 red-proofs (the lanes' daemons own no children, so the fixture
  test is what guards those shapes in CI)
- exempt the managed tools/ install tree before the .tmp rule, so agent-browser's
  own download temporaries are no longer a false LEAK
- report empty directories as residue (an unswept session scaffold leaves no file)
- reuse src/utils/host-process.ts (expandProcessTree, uniquePositivePids) and its
  /bin/ps convention instead of re-deriving them
- assert in smoke-daemon-http on the success path, not in finally, so the settle
  window cannot replace a primary assertion's diagnostic

Refs #1781 #1431
thymikee added a commit that referenced this pull request Aug 19, 2026
…closed

Re-review of #1859: `after-close` accepted every capture descriptor and legacy
marker, so the closed session's unfinalized handle was indistinguishable from
another session's legitimately live one — the phase could not fail, the same
shape as the surviving-daemon blocker, and it left half of B1's stated scope
undelivered.

- the observation carries the session directories closed at the checkpoint; a
  capture handle must be finalized once its owning session is gone (every
  session after shutdown, only the closed ones after close), while another
  session's live handle stays expected and a legacy pid marker is never a
  finish record
- the oracle accepts `closedSessions` and a `sessionsDir` override, normalizing
  entries to the canonical `sessions/<name>/…` shape so an in-process harness
  rooted directly at its own sessions dir is classified the same way
- add a real route regression: a provider-backed session with a live screen
  recording is closed through the daemon route, and the oracle refuses a
  descriptor left `lifecycle: "open"`. Reverting the close-route finalization
  (session-close-lifecycle-teardown.ts, the #1325 fix) turns it red naming
  sessions/default/screen-recording.resource.json; with the pre-fix model it
  stays green, which is the P1 in one line

Refs #1781 #1431
thymikee added a commit that referenced this pull request Aug 19, 2026
… interface

Re-review of #1859: `closedSessions` was optional and defaulted to empty, so any
caller — or the standalone CLI invoked with `--phase after-close` and no
`--closed-session` — silently restored the vacuous checkpoint that accepts every
unfinalized capture handle and reports clean.

- phase and the identity that phase needs are now one discriminated shape:
  { phase: 'after-close'; closedSessions: NonEmpty<string> } | { phase:
  'after-shutdown' }, so the empty case is not expressible and 'after-shutdown'
  is unchanged
- the CLI refuses the same invocation with a typed INVALID_ARGS error and a hint
  naming what the missing identity would have cost, instead of degrading
- daemon-leak-oracle-cli.test.ts drives the real CLI: the unnamed after-close
  invocation must fail without reporting, the named one finds the planted
  unfinalized handle, and after-shutdown is unaffected. Reverting the guard makes
  it print 'clean (after-close)' over that same handle and turns the test red

Refs #1781 #1431
@thymikee

Copy link
Copy Markdown
Member Author

Rebased onto current origin/main (67ce19b50, incl. #1880) — head is now 888a479f0, conflicts resolved.

Two files conflicted, both kept from both sides: vitest.config.ts (main's registrations plus my daemon-leak-model.test.ts entry) and smoke-web-platform.test.ts (main's assertPngDimensions import plus my assertNoDaemonLeaks one; my orphan checkpoint re-applied on top of main's version, not over it).

Checked #1880 against this PR's subject matter, since stricter device selection could plausibly change what a session owns: it is admission-layer only — identity-conflict policy evaluated before dispatch — and adds no new state-dir artifact, so the allowlist needs no new classification. The device-backed paths still behave: the provider-backed close regression passes (its close names the same device the session is bound to, so the new identity-conflict rule does not fire), all four node lanes pass, the 24 model cases pass, and the live web smoke with the orphan checkpoint is green on the rebased tree in 52 s.

The enforceability decision stands and is unchanged by the rebase — it landed before this message: resolution (b), the web smoke lane, already proven red on a real stranded fleet. #1880 neither helps nor hurts it: that lane's sessions are web, so device-identity selection is not on its path.

@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact head 888a479f: the conflict resolution preserves main’s Web viewport/screenshot-dimension coverage and all SETUP_FILES, while the live Web owned-process assertion and replacement-daemon cleanup fix remain sound. Exact-head Web Smoke, Integration, and Size (0 B unpacked) are green; Coverage and Android/Linux/iOS Smoke are still running.

Two readiness cleanups remain: update daemon-leak-oracle.ts/vitest.config.ts comments that still say #1109 is only fixture/manual guarded and all relevant lanes are device-free—the new live Web smoke now exercises #1109—and refresh the PR body’s stale “no new CI minutes”/~700 LOC claims to the current 1,088 additions/16 deletions and ~35s Web cost, itemizing the design growth. Not merge-ready until those and pending lanes settle.

… B1)

Adds test/integration/support/daemon-leak-oracle.ts and calls it at the end of
smoke-daemon-clean, smoke-daemon-http and daemon-replace-exit-flush. After
shutdown the oracle asserts that no daemon-owned process survives (ownership:
PPID descendant, PGID = daemon pid, AGENT_DEVICE_STATE_DIR env, state-dir
argv — never global counts) and that the isolated state dir holds only
classified artifacts (no *.tmp, no daemon.json/lock without a live daemon, no
open capture descriptor).

Red-proofs: pre-fix #1324 (a84caa8) leaves simctl recordVideo with ppid 1 /
pgid = dead daemon; pre-fix #1109 (be4bd09) leaves the agent-browser daemon
plus its Chrome fleet. Both are clean on main.

Refs #1781 #1431
…ules

Review of #1859 found the oracle reported "clean" when the daemon itself
outlived shutdown: daemon pids were excluded from ownership, liveDaemonPids
never reached hasDaemonLeaks, and a live daemon even flipped daemon.json/lock
from stray to expected. stopProcessForTakeover is best-effort void, so only
smoke-daemon-clean independently asserted death.

- a live daemon pid at phase 'after-shutdown' is now itself a leak, and its
  metadata files stay stray; a live daemon remains legitimate at 'after-close'
- split the pure ownership/residue rules into daemon-leak-model.ts and pin them
  with daemon-leak-model.test.ts, using the real ps rows captured during the
  #1109 and #1324 red-proofs (the lanes' daemons own no children, so the fixture
  test is what guards those shapes in CI)
- exempt the managed tools/ install tree before the .tmp rule, so agent-browser's
  own download temporaries are no longer a false LEAK
- report empty directories as residue (an unswept session scaffold leaves no file)
- reuse src/utils/host-process.ts (expandProcessTree, uniquePositivePids) and its
  /bin/ps convention instead of re-deriving them
- assert in smoke-daemon-http on the success path, not in finally, so the settle
  window cannot replace a primary assertion's diagnostic

Refs #1781 #1431
…closed

Re-review of #1859: `after-close` accepted every capture descriptor and legacy
marker, so the closed session's unfinalized handle was indistinguishable from
another session's legitimately live one — the phase could not fail, the same
shape as the surviving-daemon blocker, and it left half of B1's stated scope
undelivered.

- the observation carries the session directories closed at the checkpoint; a
  capture handle must be finalized once its owning session is gone (every
  session after shutdown, only the closed ones after close), while another
  session's live handle stays expected and a legacy pid marker is never a
  finish record
- the oracle accepts `closedSessions` and a `sessionsDir` override, normalizing
  entries to the canonical `sessions/<name>/…` shape so an in-process harness
  rooted directly at its own sessions dir is classified the same way
- add a real route regression: a provider-backed session with a live screen
  recording is closed through the daemon route, and the oracle refuses a
  descriptor left `lifecycle: "open"`. Reverting the close-route finalization
  (session-close-lifecycle-teardown.ts, the #1325 fix) turns it red naming
  sessions/default/screen-recording.resource.json; with the pre-fix model it
  stays green, which is the P1 in one line

Refs #1781 #1431
… interface

Re-review of #1859: `closedSessions` was optional and defaulted to empty, so any
caller — or the standalone CLI invoked with `--phase after-close` and no
`--closed-session` — silently restored the vacuous checkpoint that accepts every
unfinalized capture handle and reports clean.

- phase and the identity that phase needs are now one discriminated shape:
  { phase: 'after-close'; closedSessions: NonEmpty<string> } | { phase:
  'after-shutdown' }, so the empty case is not expressible and 'after-shutdown'
  is unchanged
- the CLI refuses the same invocation with a typed INVALID_ARGS error and a hint
  naming what the missing identity would have cost, instead of degrading
- daemon-leak-oracle-cli.test.ts drives the real CLI: the unnamed after-close
  invocation must fail without reporting, the named one finds the planted
  unfinalized handle, and after-shutdown is unaffected. Reverting the guard makes
  it print 'clean (after-close)' over that same handle and turns the test red

Refs #1781 #1431
…path

Maintainer asked whether the harness can be trimmed. The two arms differ in kind:
the daemon already records captures and state-dir artifacts, so those rules are
short and fire on every lane run; nothing records what a daemon spawned, so the
process arm reconstructs ownership from the OS four ways — and the three CLI
daemon lanes are device-free, so it only ever compared an empty set to an empty
set.

Move that arm to test/integration/support/daemon-owned-process-probe.ts, the
manual script that produced the #1109/#1324 evidence, and keep its rules pinned
by a fixture test (no lane can run the probe, so its fixtures are the only CI
guard on those shapes). The shipped oracle keeps every guarantee it had:
surviving daemon at after-shutdown, the closed-session finalization rule, the
state-dir allowlist, the CLI refusal, and the phase-discriminated types.

Lane-path harness 852 → 548 LOC (-36%); repo total roughly flat, because the
ownership reconstruction can be relocated but not deleted. That is the argument
for the follow-up: once the daemon records owned child pids the way it records
captures, the arm collapses to "read the record, assert they are dead" and moves
back into the oracle.

Re-proved after the move: #1324 against pre-fix a84caa8 on an iPhone 16
simulator still goes red through the probe (simctl recordVideo, ppid 1,
pgid = the dead daemon, 0-byte mp4), and clean once the orphan is reaped.

Refs #1781 #1431
Re-review of ce9f0ee: moving that arm to a manual probe left the two failures
B1 exists to prevent detectable only by hand, and a fixture test proves regexes
recognize synthetic rows, not that the shipped route reaps what it spawned.

Restore the arm to the shipped oracle (the three files return byte-identical to
3a5b9be) and give it a lane that can execute it: smoke-web-platform is the one
CI route whose daemon owns real children — the managed agent-browser daemon and
its Chrome fleet. After the normal smoke it reopens a session, stops the daemon
with that session still open (the #1109 shape: an ordinary close reaps the fleet,
so only an unclosed session can strand it), and requires that nothing owned
outlives the browser idle window.

Proven both ways locally: green in 59s, and red when the fleet is stranded
(browser idle window raised past the settle budget) with the oracle naming 15
owned processes — the #1109 signature, in a lane that runs on every PR.

Also: daemon-replace-exit-flush cleared `info` before the oracle ran, so a failed
stop would skip the `finally` retry and remove the state dir while the daemon was
still alive. Clear it only once the checkpoint passes.

Refs #1781 #1431 #1882
@thymikee
thymikee force-pushed the test/1781-b1-daemon-leak-oracle branch from 888a479 to c717e56 Compare August 19, 2026 16:03
@thymikee

Copy link
Copy Markdown
Member Author

Not ready at c717e560: the owned-process check still infers ownership from PPID/PGID/env/argv and a host-wide ps snapshot. The long explanation of PID reuse, hidden environments, and why fixture/manual proofs stand in for #1324 is evidence that this is the wrong boundary. Record { pid, startTime, command, purpose } at each daemon-owned spawn/reap path and have the oracle assert that durable record is empty/dead after close/shutdown. Fold #1882 into this change or narrow this PR to the non-process leak arms. Exact-head Web Smoke and Integration are green; remaining CI is still running. No ready-for-human.

@thymikee thymikee changed the title test: daemon leak oracle around the real-subprocess daemon lanes (#1781 B1) test: catch daemon lifecycle and durable-state leaks (#1781 B1) Aug 19, 2026
@thymikee

Copy link
Copy Markdown
Member Author

Addressed at 919da440a by taking the explicitly allowed narrow option.

  • removed the owned-process arm entirely: no PPID/PGID/env/argv rules, no host-wide ps, and no process rows in the report;
  • removed the orphan-process checkpoint from Web Smoke, so that lane is back to its pre-PR scope;
  • retained only the independently enforceable arms: surviving daemon, closed-session capture finalization, torn/unknown/unswept state-dir residue, and the typed after-close identity requirement;
  • reduced model + oracle + model test from 852 to 562 LOC.

The PR title/body now make the boundary explicit: this change does not claim #1109/#1324 process coverage. #1882 owns the cohesive durable process-identity design; the trace here confirmed Apple recording already persists exact identities in its capture descriptor, while agent-browser creates its daemon indirectly, so a quick central spawn hook would still be incomplete or duplicative.

Local exact-head evidence: 21 model cases, four real-subprocess daemon/CLI regressions, the provider-backed close-route regression, and the full runnable check:affected set are green. Fresh CI is running; not claiming ready-for-human until it settles.

@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact 919da440: code-clean. The host-wide process-ownership inference and Web workaround are removed; the narrowed lifecycle/durable-state oracle keeps the non-vacuous after-close identity and real close-route regression, and the relevant exact-head core, Integration, Coverage, and Web checks are green. Please update the body scope to GitHub’s current 9 files, 741 additions / 15 deletions, then rerun the unrelated red lanes: iOS Smoke observed only .test in the existing typed-email scenario, and Linux Smoke timed out installing desktop dependencies before setup. No ready-for-human until those reruns are green.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant