# Proof audit, pass 1 — browser release

Client request id: `lead-27224-r1-audit1-1`.

Reviewed [#27224](https://git.cafe/pingdotgg/t3code/pull/27224), branch `fix/issue-27136`, in `/home/ubuntu/wt/pr-27224`. Before is [dd14848153](https://github.com/pingdotgg/t3code/commit/dd148481531a0a090780a2ade20588566db099a2); After is [c17d32c8ea](https://github.com/pingdotgg/t3code/commit/c17d32c8eabe2b9a8148ba1df30a53e7968a640c). Local HEAD and forge metadata agree. The supplied merge-base is correct. No worktree modifications, runtime captures, tests, servers, browser launches, pushes, or PR comments were made in this pass. PR description excerpts about earlier runs are claims, not independently audited raw captures here.

## Findings first

1. **Proof is incomplete, as expected for pass 1.** Both browser callers and all release branches below need evidence. This is a server resource-lifecycle change; viewport/theme/client screenshot matrices do not apply. A returned PNG may be checked for successful capture, but screenshots are not the cleanup proof.
2. **The new regression test has ambiguous evidence.** [HtmlRender.test.ts](https://github.com/pingdotgg/t3code/blob/c17d32c8eabe2b9a8148ba1df30a53e7968a640c/apps/server/src/htmlRender/HtmlRender.test.ts#L335) records the same `asked to close` marker for input EOF and an explicit CDP `Browser.close`. A marker alone therefore does not establish which shutdown mechanism ran. This is an evidence limitation, not a claim that the current test actually passes on Before. Capture CDP method receipt and EOF separately, and show the focused regression failing Before and passing After.
3. **A signal after CDP shutdown is not automatically a failure.** The installed Effect platform-node/shared version is 4.0.5. Its spawner can signal the POSIX process group even after the leader exits successfully, to clear descendants. Attribute every syscall to server/wrapper/browser/zygote; require leader exit and descendant accounting. ESRCH after clean exit is consistent with success, and a successful kill from Chrome's zygote is different from a server kill ending the browser leader.
4. **Timing evidence is required for timeout and failure rows.** The new grace interval is at most 2 s before the existing release. The spawner then sends group SIGTERM, falls back to leader SIGTERM if group signalling errors, waits up to `forceKillAfter: 2 seconds`, may send SIGKILL and wait up to another 1 s, then awaits the leader. Thus about 3 s Before / 5 s After is a nominal worst-case bound when signals work and the remaining group consumes both waits, not a universal guaranteed bound. Signal failures can terminate release early; a perpetually live leader after successful signalling can outlive those bounded group waits. Record actual timestamps rather than claiming a fixed total from source alone.
5. **Exact reported environment cannot be verified here.** `/sys/module/apparmor/parameters/enabled` reports `N`; active LSMs are `capability,selinux`. This host cannot reproduce the AppArmor/LXC signal denial in [#27136](https://git.cafe/pingdotgg/t3code/issues/27136). A detached real Chrome behind a SIGTERM-ignoring wrapper proves independence from T3's group signals, but does not prove EPERM policy behavior or confined renderer/zygote teardown.

## Source-derived scope

- The only `launchBrowser` callers are [captureHtmlScreenshot](https://github.com/pingdotgg/t3code/blob/c17d32c8eabe2b9a8148ba1df30a53e7968a640c/apps/server/src/htmlRender/headlessChrome.ts#L549) and [measureHtmlHeights](https://github.com/pingdotgg/t3code/blob/c17d32c8eabe2b9a8148ba1df30a53e7968a640c/apps/server/src/htmlRender/headlessChrome.ts#L584). Screenshot capture uses a scoped browser and a 20 s outer operation timeout. Measurement shares one browser across concurrent width-specific pages, closes each target, then releases the browser.
- [HtmlRender.preview](https://github.com/pingdotgg/t3code/blob/c17d32c8eabe2b9a8148ba1df30a53e7968a640c/apps/server/src/htmlRender/HtmlRender.ts#L462) calls capture; [HtmlRender.publish](https://github.com/pingdotgg/t3code/blob/c17d32c8eabe2b9a8148ba1df30a53e7968a640c/apps/server/src/htmlRender/HtmlRender.ts#L412) calls measurement only when `PreviewBrowser.installed` finds a browser. Measurement has a 6 s outer timeout and falls back to publishing without heights on failure. No installed browser means no measurement launch and no new finalizer.
- The [MCP handlers](https://github.com/pingdotgg/t3code/blob/c17d32c8eabe2b9a8148ba1df30a53e7968a640c/apps/server/src/mcp/toolkits/html/handlers.ts#L22) map `html_preview` to preview and `html_render` to publish. `html_render` needs a thread scope. No separate public HtmlRender WebSocket RPC was found; use these MCP methods or a labelled service harness, not an invented RPC.
- The [new finalizer](https://github.com/pingdotgg/t3code/blob/c17d32c8eabe2b9a8148ba1df30a53e7968a640c/apps/server/src/htmlRender/headlessChrome.ts#L377) runs before the earlier spawner release and while its fd3 writer remains available. If `disconnected` is set, it skips CDP. Otherwise it enqueues browser-level `Browser.close` with no session id and awaits **child exit**, not the CDP response; timeout and exit-code errors are ignored before normal spawner cleanup continues.
- `disconnected` means [disconnect processing completed](https://github.com/pingdotgg/t3code/blob/c17d32c8eabe2b9a8148ba1df30a53e7968a640c/apps/server/src/htmlRender/headlessChrome.ts#L327), not merely that an fd closed: stderr drain and exit-code collection each have a 1 s allowance. The disconnected fixture must make this state deterministic; a just-closed pipe can still enter the connected branch and consume the 2 s grace interval.
- Cleanup is entered on success, a connected protocol/navigation error, and interruption/outer timeout. Before a child is acquired, proxy/spawn/setup errors cannot use the new finalizer; those are unchanged controls.

## Fixture matrix

All Before/After cells below are **expected from source, not observed captures**. `Required` identifies the minimum proof set; no cartesian product across every caller and failure mode is required because they share `launchBrowser`. Capture the distinct finalizer behaviors once, plus a successful real run through each caller. Status is per row.

| ID / named fixture | Needs and drive | Diff affected / minimum | Before | After | Transcript difference to prove | Timed? | Pass-1 status |
| --- | --- | --- | --- | --- | --- | --- | --- |
| P — real preview success | Real pinned chrome-headless-shell; isolated server `html_preview` preferred, or harness `HtmlRender.preview` | Yes / Required | Successful capture, browser released through signals | Successful capture; CDP shutdown precedes spawner release | Successful PNG/result; CDP receipt or correlated pipe trace; browser leader exit code and who ended it; tracked browser tree has no live orphans | Yes: final capture/result milestone → leader exit → service return | NEEDS CAPTURE |
| H — real publish measurement | Real installed shell; isolated server `html_render` in a scratch thread, or harness `HtmlRender.publish` with scratch config/MCP sessions | Yes / Required | Measures widths, closes page targets, signals browser | Same heights; target closes followed by one browser-level close and exit | Nonempty returned heights prove measurement actually ran; target-vs-browser close ordering, leader exit and remaining tree | Yes: measurement completion → release → publish return; watch interaction with 6 s outer timeout | NEEDS CAPTURE |
| U — real browser outside T3's signal group | Wrapper ignores SIGTERM; starts real shell in a **new session** preserving fd3/fd4; records both PIDs/PGIDs, waits/reaps shell and propagates its exit; drive preview via harness executable override | Yes / Required mechanism substitute | T3 signals wrapper group; detached shell survives release (record, then explicitly clean up its tracked PID) | CDP reaches shell; shell exits itself; wrapper reaps/exits before timeout; no live browser descendants | Demonstrate PGID separation; Before live survivor vs After zero survivors and clean shell exit without T3 signalling shell | Yes: final capture → CDP/TERM/KILL/wrapper exit → return; separate measured release from subsequent test cleanup | NEEDS CAPTURE; SUBSTITUTE, NOT EPERM |
| F — fake ignores SIGTERM, honors close | Executable NUL-JSON CDP fake like new test, but logs `Browser.close`, EOF, TERM and exit as distinct events; harness through `HtmlRender.preview` | Yes / Required focused regression pair; can share capture infrastructure with U | No close request; TERM ignored; usually SIGKILL ends fake, subject to actual pipe lifetime | Browser-level close received with no session id; fake exits 0 before fallback can end it | Explicit method receipt, not the shared EOF marker; focused test fails Before and passes After | Yes: close receipt → exit → spawner release; distinguish EOF-triggered exit | NEEDS CAPTURE / TEST PROOF |
| T — ignores close, accepts TERM | CDP fake serves successful capture, logs/ignores close, exits on SIGTERM; harness preview | Yes / Required | TERM at release, prompt exit | Close received, no exit; about 2 s later same TERM fallback ends it | Close → grace deadline → TERM ordering, signal source, no orphan; successful request remains successful | Yes, mandatory: release start / close receipt / first server TERM / exit / return | NEEDS CAPTURE |
| K — ignores close and TERM | CDP fake serves capture, logs/ignores both; harness preview; SIGKILL remains possible | Yes / Required | TERM then force-kill escalation after existing 2 s allowance | Added 2 s close grace, then unchanged TERM → wait → SIGKILL escalation | Server signal sequence and timestamps, exit signal, no live survivors; quantify added release latency rather than assuming exactly 5 s | Yes, mandatory: close, TERM, KILL, exit, return | NEEDS CAPTURE |
| D — already disconnected, leader exited | Fake closes CDP/stderr and exits nonzero before pending page command completes; harness preview awaits reported browser failure | Guard added; expected no behavioral change / Required | Browser error retains stderr/exit diagnostics; spawner release | `disconnected` set; no close request/grace wait; same diagnostics and release | Nonzero exit, pending operation failure, no Browser.close; any extra server kill on nonzero exit is spawner behavior | Yes: EOF/exit → error/return; exclude pre-existing disconnect diagnostic waits from new grace | NEEDS CAPTURE |
| L — already disconnected, leader still alive | Fake closes fd4 **and stderr**, keeps fd3 recorder open and stays alive beyond disconnect's exit-collection allowance; harness awaits resulting browser failure; TERM-responsive fake | Guard added; expected same signal cleanup / Required | Disconnect error then TERM cleanup | Completed `disconnected` bypasses close even with live child; TERM cleanup | Explicit EOF/diagnostic milestone, absence of close, TERM and exit; do not infer disconnected solely from fd EOF | Yes, mandatory: distinguish disconnect's existing waits from an added 2 s wait | NEEDS CAPTURE |
| E — connected failure / interruption | Fake keeps pipe open, returns Page.navigate `errorText`; second mode withholds a reply and signals harness readiness so driver can interrupt its fiber; fake honors close | Yes / Required, two triggers sharing one cleanup fixture | Error or interruption unwinds to signal release | Same error/interruption outcome; graceful CDP shutdown during unwind | Failure/cancellation outcome preserved, Browser.close after unwind starts, exit and no orphan. Outer capture/measurement timeouts reach this same finalizer | Yes: failure/cancel trigger → close → exit → return; no sleeps to coordinate cancellation | NEEDS CAPTURE |
| N — close received, nonzero exit | Fake completes preview then logs close and exits a chosen nonzero code; harness | Yes / Required for ignored `child.exitCode` failure branch | Released by signals | CDP shutdown occurs; exit-code result/failure is ignored; spawner may additionally signal group on nonzero exit | Successful preview remains successful; exit status captured, no leaked child; separate spawner's on-exit signal from finalizer behavior | Yes: receipt / nonzero exit / on-exit signal / return | NEEDS CAPTURE |
| X — refuses close and signals denied | Synthetic harness-only process-signal denial injection scoped to tracked browser group/leader; fake ignores close; retain an independent controller able to clean it up | Yes / Optional negative control; residual limitation, not required to claim graceful-shutdown fix | Signal denial can leave survivor | After 2 s, same denied fallback can still leave survivor | No claim of universal leak prevention: graceful request cannot stop an unresponsive, unsignallable browser. Label injection as synthetic, never a kernel EPERM reproduction | Yes: close timeout / denied group and leader attempts / return / survivor / independent cleanup | NOT RUN; OPTIONAL LIMITATION CONTROL |
| A — actual AppArmor inside LXC | Ubuntu/LXC host with relevant AppArmor profile, real browser and verified server-to-browser EPERM; same preview/publish routes | Yes / Environment-specific follow-up; substitute U is minimum mechanism proof here | Reported orphan despite attempted signals | Expected browser and confined descendants exit via CDP/IPC; actual policy interaction unproven here | Real EPERM transcript Before; After leader and every tracked renderer/zygote exit; policy label and kernel/container metadata | Yes: repeated releases and timestamped descendant accounting | CANNOT VERIFY HERE — APPARMOR DISABLED |
| B — no browser acquired | Publish with no installed browser; invalid executable/proxy failure as harness controls | No / Optional controls, not changed-finalizer proof | No measurement (missing install), or startup failure; no acquired browser to close | Same result; new finalizer absent | No browser PID/close; publish without heights remains distinct from successful measurement | No release-latency requirement | SOURCE-COVERED; OPTIONAL CONTROL |

## Getting the missing prerequisites

`which strace` returned `/usr/bin/strace` (6.8); `setsid` is `/usr/bin/setsid`. No `chrome-headless-shell` was found on PATH; no download or launch was attempted. The brief says it is not installed. Do not confuse a PATH check with an exhaustive filesystem search.

The preferred installer is [PreviewBrowser.makePreviewBrowser / executable](https://github.com/pingdotgg/t3code/blob/c17d32c8eabe2b9a8148ba1df30a53e7968a640c/apps/server/src/preview/PreviewBrowser.ts#L174) using a base directory under `/tmp/lead-27224-r1/`. The first isolated server `html_preview` starts installation and may return progress; keep that server service scope alive until installation completes. The added shebang-based regression fixture is skipped on Windows; POSIX signal/PGID timing below does not establish Windows `taskkill` behavior. No macOS or Windows runtime was exercised. Its pinned Linux x64 archive is version `156.0.8078.70`, 126,646,931 bytes, SHA-256 `a313da592261ff1e342c1e507d315f25b1809006c0508f77ce86151d464d4ccb`. Alternatively download that pinned [Chrome for Testing archive](https://storage.googleapis.com/chrome-for-testing-public/156.0.8078.70/linux64/chrome-headless-shell-linux64.zip) into scratch, verify size/hash, and extract there with adjacent libraries/resources intact. Keep all downloads and run output inside the shared scratch root.

`t3 browser setup --base-dir <scratch-home>` (or repository `node apps/server/src/bin.ts browser setup --base-dir <scratch-home>`) is the [host prerequisite setup](https://github.com/pingdotgg/t3code/blob/c17d32c8eabe2b9a8148ba1df30a53e7968a640c/apps/server/src/cli/browser.ts#L106): libraries, fonts, sandbox/AppArmor policy. **It does not download Chrome.** Browser installation happens on first use. Do not run host-changing setup merely to build this matrix; use an appropriate prepared host for actual AppArmor validation. If a harness needs the repo's documented no-sandbox opt-out, record it and do not describe that run as sandbox/AppArmor verification.

## Driving and recording the minimum captures

Prefer an isolated real server for P and H: `T3CODE_DEV_SEED=0 vp run dev --home-dir <scratch-home>` (confirm dev-runner forwarding/actual home before use), access its authenticated MCP endpoint, call `html_preview`, then `html_render` in a scratch thread after installation. Do not invoke tools against the current live T3 instance. Starting a dev server for capture belongs to the lead/integrated verification pass, not this read-only review sub-agent. No browser UI is needed.

For U/F/T/K/D/L/E/N, use a **labelled harness** run from `apps/server` (so Effect/workspace package resolution works), importing the actual checked-out `HtmlRender` service and providing its real NodeServices, scratch ServerConfig, McpProviderSessions and a PreviewBrowser layer with an explicit executable, following `layerHtmlRender` in the test. Prefer service-level preview/publish over copying cleanup logic or exporting the private `launchBrowser`. An external driver may use absolute imports/resolution anchored in the server package; keep the driver itself in scratch. Browser PID metadata and fake modes belong in files under each run directory, not stdout mixed with JSON CDP fd4.

Capture Before and After using actual code from the pinned commits. Do not change this read-only checkout; the lead can use its separate existing Before checkout or explicitly arrange an authorized sibling worktree. Keep the same browser build, HTML, host, fake and harness between sides. A before failure is expected for U/F and must be preserved, followed by controller cleanup of the exact tracked PIDs.

Each run gets a fresh timestamped directory under `/tmp/lead-27224-r1/`, never overwriting/deleting old evidence. Use `strace -f -ttt -T -e trace=kill` around the full server/harness, record wall-clock ISO timestamps plus monotonic offsets in the driver/fake, and preserve stdout/stderr, result/error JSON, environment/version metadata, and per-event PID/PPID/PGID/SID. `kill` tracing alone does not show CDP message content: add fake/wrapper receipt logs or a narrowly scoped pipe trace if needed; do not publish credentials or unrelated tool payloads.

Record: operation start; final capture/measurement or error/cancel milestone; explicit Browser.close receipt where observable; EOF independently; every server/browser signal and errno; leader exit code/signal; service return; tracked process tree after release. For descendants, snapshot both live and zombie states with PID start times to avoid reuse ambiguity. Track descendants at creation (e.g. process creation trace or wrapper instrumentation), not only by a disappearing temp-profile argument. Chrome's own zygote kills must be attributed separately. On Before, collect the orphan observation before controller cleanup; on After, require no live tracked browser descendants when asserting success.

T/K/L/E require time series, not a single process-list snapshot; P/H/U/F/N/D also need timestamps to substantiate prompt release or absence of the grace delay. Repetition of P/U captures may share one transcript with per-iteration results and final orphan counts; this supports the accumulation claim without inventing a separate fixture. Fixed elapsed-time assertions are not requested for automated tests; these are measured runtime transcripts.

Minimum deliverables: paired P/H/U/F/T/K/D/L/E/N evidence (E includes connected-error and deterministic interruption modes), with F's focused failing-before/passing-after test proof and method-vs-EOF disambiguation. X/B are optional controls. A stays explicitly unverified on this host; U proves only the CDP mechanism substitute, and cannot certify AppArmor descendant behavior.

## Handoff and friction

Inspection covered the full two-file diff, both browser callers and service/MCP entry points, finalizer ordering, disconnect processing, outer operation timeouts, regression fixture and installed spawner implementation. No runtime claims above are promoted to verified outcomes. Initial free disk was 17 GB; no process was started that requires shutdown. Source and diagnostics only; no 45-minute budget overrun.

FRICTION:
infra | Actual AppArmor/LXC row unavailable: AppArmor is disabled on this VM | Supply a prepared AppArmor/LXC host; retain U as a labelled mechanism substitute.

VERDICT: INSUFFICIENT P,H,U,F,T,K,D,L,E,N need paired capture/test proof; A cannot be verified here
