# PR #23571 proof matrix (pass 1): dev supervisor stops when its parent dies

- Trees: `main` = `~/wt/main-8d3b061` (8d3b061121), `head` = `~/wt/pr-23571` (85ebfe32d2).
- Run every row on **main first, then head**, never both at once. `dev-electron.mjs` runs `pkill -f -- --t3code-dev-root=<that tree's apps/desktop>` (scoped per tree), but both trees would still fight over Xvfb, ports and CPU.
- Host facts checked during the audit: `/bin/sh -> /usr/bin/dash` (0.5.12-6ubuntu5). `sh -c 'node …'` **does** fork and stays resident (verified). `strace` and passwordless `sudo` exist. Unprivileged user namespaces work.

## Code facts the capture relies on

| Fact | Source |
|---|---|
| The supervisor prints **nothing** on shutdown. Its only observable effects are Electron getting SIGTERM, `pkill -P <sup>` of its children, a fixed 1.2 s grace, then `process.exit(code)`. Exit codes: SIGINT→130, SIGTERM→143, SIGHUP→129, parent-gone→**129** (the same as SIGHUP, so the exit code alone does not tell the parent check from SIGHUP). | `apps/desktop/scripts/dev-electron.mjs:240-286` |
| The shutdown marker is Electron main's `before-quit received` (Effect `consolePretty`, so it reaches the inherited stdout and appears in the `vp run` log). It is also a span `desktop.lifecycle.beforeQuit` in `<state>/logs/desktop.trace.ndjson`. Electron turns its own SIGTERM/SIGINT into `app.quit()`. | `apps/desktop/src/app/DesktopLifecycle.ts:110,122,257-260`, `DesktopObservability.ts:621,643` |
| Parent check: `parentPid = process.ppid` at module start. It is polled every 1000 ms, starting **after** `waitForResources` and `startApp()`. Skipped on win32. | `dev-electron.mjs:55-56,280-286` |
| Worst-case supervisor exit after the parent dies ≈ 1 s (poll) + Electron quit (≤1.5 s, then SIGKILL) + 1.2 s grace ≈ **≤3.7 s**, and never under 1.2 s. "Within 2 s" holds for Electron, not reliably for the supervisor. | same |
| tsdown runs string `onSuccess` via tinyexec with `nodeOptions.shell: true`, which Node turns into a hardcoded `/bin/sh -c`. It does **not** honour `$SHELL` or PATH. On every watched source change (`event === "update"`) and again after each rebuild it calls `ab.abort()`, then `treeKill(p.pid)` (SIGTERM to that pid and all its descendants), and spawns a fresh `onSuccess`. | `node_modules/.pnpm/@voidzero-dev+vite-plus-core@1.3.0_*/…/dist/tsdown/build-KEepWO_k-CTQ6VoLh.js:5726-5742,6396-6400,6464-6465` |
| No SIGTERM/SIGINT handler was found in tsdown's watch path. A SIGTERM to `vp pack` alone most likely kills it without forwarding, the same as SIGKILL. | same file |
| The dev entry is `vp run dev:desktop` → `node scripts/dev-runner.ts dev:desktop` → `vp run --filter=@t3tools/desktop --filter=@t3tools/web dev`. The desktop `dev` task is `node build-browser-secret && node build-preview-annotation-css && cross-env T3CODE_DESKTOP_DEV=1 vp pack --watch`. `vp` in a task is `node_modules/.bin/vp`, a sh shim that `exec`s `node …/vite-plus/bin/vp`. Electron gets `--no-sandbox` from the launcher automatically when chrome-sandbox is not root-owned. `--disable-gpu` **cannot** be injected through the supervisor (only `T3CODE_DESKTOP_REMOTE_DEBUGGING_PORT` is forwarded), so rely on Xvfb software GL. | `package.json:12`, `scripts/dev-runner.ts:84`, `apps/desktop/vite.config.ts:39-52`, `apps/desktop/scripts/electron-launcher.mjs:437-456` |

## Common setup (per tree; `T=main-8d3b061` or `T=pr-23571`)

```bash
T=main-8d3b061   # then repeat everything with T=pr-23571
EV=/tmp/ev-23571/$T; H=/tmp/ev-23571/home-$T; mkdir -p "$EV" "$H"
cd ~/wt/$T
for v in $(env | sed -n 's/^\(T3CODE_[A-Za-z0-9_]*\)=.*/\1/p'); do unset "$v"; done; env | grep ^T3CODE_ || echo "no T3CODE_ vars"
[ -x apps/desktop/node_modules/electron/dist/electron ] || (cd apps/desktop && node node_modules/electron/install.js)
ts() { date +%T.%3N; }
# Descendants of a pid, forest view, with timestamps. Discovery is by parentage from a pid we spawned, never by name.
snap() { { echo "== $(ts) $1"; ps -o pid,ppid,pgid,sid,etimes,stat,cmd --forest --sid "$SID"; } >> "$EV/$2.tree.txt"; }

setsid headless-desktop vp run dev:desktop --home-dir "$H" --no-seed > "$EV/run.log" 2>&1 &
ROOT=$!; echo "$ROOT" > "$EV/root.pid"
sleep 1; SID=$(ps -o sid= -p "$ROOT" | tr -d ' '); echo "ROOT=$ROOT SID=$SID"   # expect SID == ROOT
```

Wait until Electron is up (≤180 s). Then derive the PIDs **only from the tree under `$SID`** and verify cwd before signalling anything:

```bash
for i in $(seq 180); do grep -q "before-quit\|\[desktop\]\|runtime logging configured" "$EV/run.log" && break; sleep 1; done
# Supervisor: the node process (not `sh -c`) running scripts/dev-electron.mjs, inside our session only.
SUP=$(ps -o pid=,cmd= --sid "$SID" | awk '$2 !~ /(^|\/)sh$/ && /scripts\/dev-electron\.mjs/ {print $1}')
readlink /proc/$SUP/cwd     # must be ~/wt/$T/apps/desktop, otherwise STOP
P1=$(ps -o ppid= -p $SUP | tr -d ' '); ps -o pid,cmd -p $P1
# VPPACK = the `vp pack --watch` process: P1 on head; on main P1 is `/bin/sh -c node scripts/dev-electron.mjs`, so take its parent.
case "$(ps -o cmd= -p $P1)" in /bin/sh*|sh\ -c*) VPPACK=$(ps -o ppid= -p $P1 | tr -d ' ');; *) VPPACK=$P1;; esac
ps -o pid,cmd -p $VPPACK      # must show `…vite-plus/bin/vp pack --watch`; record the whole ancestry chain up to ROOT
ELEC=$(ps -o pid= --ppid $SUP | head -1 | tr -d ' '); ps -o pid,cmd -p $ELEC
echo "SUP=$SUP P1=$P1 VPPACK=$VPPACK ELEC=$ELEC" | tee "$EV/pids.txt"
```

To see the exit code even after the supervisor is orphaned, attach strace before each signalling step (plain user ptrace works here; fall back to `sudo` if it does not):
`strace -tt -e trace=exit_group,kill -p $SUP -o "$EV/<row>.strace.txt" & STRACE=$!`

Teardown after each row: if anything survives, kill only PIDs from `pids.txt`/`snap` output whose `sid == $SID` and whose cwd you checked, Electron first, then the supervisor, then `kill -TERM -- -$SID`. Record what had to be cleaned up; on main that is part of the evidence. Remove nothing under `/tmp/ev-23571`.

## Matrix

| # | Scenario | Commands (after setup) | Expected on main 8d3b061 | Expected on head 85ebfe32 | Evidence to record |
|---|---|---|---|---|---|
| 0 | Baseline tree shape | `snap baseline r0` | `vp pack --watch` → `/bin/sh -c node scripts/dev-electron.mjs` → `node scripts/dev-electron.mjs` → electron (+ zygote/gpu/renderer children, + backend `server/dist/bin.mjs`) | `vp pack --watch` → `node scripts/dev-electron.mjs` (`P1 == VPPACK`, no `sh`) → electron … | `r0.tree.txt`; `cat /proc/$SUP/status \| grep PPid`; also the chain between ROOT and VPPACK (cross-env, `vp run`, dev-runner) |
| 1 | `vp pack` SIGKILL | strace on SUP; `snap pre r1; date +%T.%3N > $EV/r1.t0; kill -KILL $VPPACK`; `for s in 1 2 3 4 6; do sleep 1; snap +$s r1; done`; then `touch apps/desktop/dist-electron/main.cjs; sleep 3; snap after-touch r1` | `sh` (P1) reparented to a subreaper (PPID ≠ VPPACK, not necessarily 1). SUP and Electron stay alive. No new `before-quit received` after t0. The touch kills Electron and starts a **new Electron PID** under SUP. No `exit_group` in strace. **Bug reproduced.** | ≤1 s after t0 SUP sees `ppid ≠ parentPid`. Electron logs `before-quit received` (after t0) and is gone by ~+3 s. SUP is gone by ≤+4 s (expected ~2.3–3.7 s). strace shows `kill(<ELEC>, SIGTERM)` then `exit_group(129)`. No Electron after the touch. No process with `dev-electron.mjs` or `--t3code-dev-root=…/$T/apps/desktop` remains in `$SID`. | `r1.t0`, `r1.tree.txt` (all snapshots), `r1.strace.txt`, `grep -n "before-quit received" run.log` with surrounding timestamps, and the time of the first snapshot where SUP and ELEC are absent. Also note what `vp run`/web/dev-runner did after the kill (they may tear down or keep running; informational). |
| 1b | Same as 1, after a rebuild | Run row 3 first, re-derive SUP/VPPACK, then repeat row 1 | as row 1 | as row 1. This proves `parentPid` is captured fresh for each tsdown-spawned supervisor. | same files with prefix `r1b` |
| 2a | Ctrl-C (SIGINT to the process group) | Fresh start. `PG=$(ps -o pgid= -p $SUP \| tr -d ' ')`, record PGIDs of VPPACK and ELEC as well (a real terminal hits the foreground group; if they differ, note it). strace on SUP; `kill -INT -- -$PG`; snapshots every 1 s for 6 s | SUP `exit_group(130)` (unless the group SIGINT kills the `sh`/vp pack first; record which). Electron logs `before-quit received` and exits. The whole tree is gone within ~3–4 s. | **Same as main**. Exit code 130 rather than 129 (SIGINT arrives before the 1 s poll can notice the dead parent). The same number of `before-quit received` lines. Exit timing within ±1 s of main. | `r2a.tree.txt`, `r2a.strace.txt`, the count of `before-quit received` lines after t0, time to empty tree. A strace line `exit_group(129)` here would mean the parent check raced the signal handler; record it, it is harmless. |
| 2b | SIGTERM to `vp pack` only | Fresh start. strace on SUP; `kill -TERM $VPPACK`; snapshots every 1 s for 6 s | **Probably not clean.** tsdown has no SIGTERM handler that aborts `onSuccess`, so vp pack dies without forwarding and the result looks like row 1 (SUP survives). If vite-plus does forward, SUP gets SIGTERM via tree-kill → `exit_group(143)`. Record which happened. | If vp pack forwards: identical to main (143). If not: the parent check fires → `exit_group(129)`, the tree is gone ≤4 s. In the second case this row is an **improvement**, not "unchanged"; say so. | `r2b.tree.txt`, `r2b.strace.txt`, whether VPPACK printed anything in `run.log` |
| 2c | SIGTERM to the whole session (what `vp run`/dev-runner teardown does) | `kill -TERM -- -$SID`; snapshots | Everything exits. SUP 143 (or killed with sh). | **Same as main** | `r2c.*` |
| 3 | Rebuild replaces the supervisor | Fresh start, record SUP/P1/ELEC. strace on SUP; `touch apps/desktop/src/main.ts`; wait for the tsdown rebuild line in `run.log` (≤30 s); snapshot; re-derive `SUP2`/`P1_2`/`ELEC2` | Old SUP gets SIGTERM from tree-kill (`exit_group(143)`, or killed). A new chain appears: VPPACK → **new `sh -c`** → SUP2 → ELEC2. Exactly one supervisor and one Electron main in `$SID`. | Old SUP `exit_group(143)`. SUP2 has **`PPid == VPPACK`** (no `sh`). Exactly one supervisor and one Electron. VPPACK is unchanged. | `r3.tree.txt` before/after, `r3.strace.txt`, `grep PPid /proc/$SUP2/status`, `ps -o cmd= -p $(ps -o ppid= -p $SUP2)`, the count of `dev-electron.mjs` processes in `$SID` (must be 1) |
| 4a | `/bin/sh` = dash (host default) | Rows 0, 1 and 3 above already run under dash. Confirm with `readlink -f /bin/sh` → `/usr/bin/dash` | (see rows) | (see rows) | `readlink` output |
| 4b | `/bin/sh` = bash, system untouched | tsdown hardcodes `/bin/sh` (Node `shell: true`), so `SHELL=`/PATH overrides do **nothing**; do not use them as evidence. Use a private mount namespace instead: `sudo unshare -m --propagation private bash -c 'mount --bind /usr/bin/bash /usr/bin/dash && echo $$ > '"$EV"'/root4.pid && sh -c "echo sh=\$BASH_VERSION" > '"$EV"'/r4-shcheck.txt && exec setpriv --reuid=$(id -u ubuntu) --regid=$(id -g ubuntu) --init-groups env -i HOME=/home/ubuntu PATH='"$PATH"' DISPLAY= setsid headless-desktop vp run dev:desktop --home-dir '"$H"'-bash --no-seed' > "$EV/run-bash.log" 2>&1 &`. Then `ROOT=$(cat $EV/root4.pid)`, `SID=$(ps -o sid= -p $(ps -o pid= --ppid $ROOT \| head -1))` (or `ROOT` if setsid kept the pid), and run rows 0, 1 and 3. Outside the namespace, `readlink -f /bin/sh` must still be dash and `sh -c 'echo $BASH_VERSION'` empty. | `r4-shcheck.txt` shows a bash version. Bash execs a single `-c` command, so **there is no `sh` even on main**: VPPACK → SUP directly. Row 1 still reproduces the bug (SUP survives, no watcher). Row 3 replaces the supervisor with a direct child. | VPPACK → SUP (the `exec` prefix makes no difference under bash). Row 1 passes as under dash. Row 3 passes. | `r4-shcheck.txt`, outside-namespace `readlink`, `r4-*.tree.txt`, `r4-*.strace.txt`. If `sudo unshare` is refused, mark 4b **not run** and say so; do not substitute SHELL/PATH tricks. |
| 4c | macOS `/bin/sh`, Windows | Not reproducible on this host. | — | — | Write "not verified" explicitly |
| 5 | The 1 s timer does not keep the process alive | (a) Static: `grep -n 'parentWatchIntervalMs).unref()' apps/desktop/scripts/dev-electron.mjs`. (b) Behavioural analogue, same snippet: `/usr/bin/time -f %e node -e 'const p=process.ppid; setInterval(()=>{ if (process.ppid!==p) process.exit(129) },1000).unref()'` → exits 0 in ≪1 s. Control without `.unref()`, run under the coreutils `timeout 3` (Linux only; the macOS caveat does not apply here) → exit 124. (c) In the real supervisor, unref is not load-bearing (fs watchers and the Electron child keep the loop alive, and every shutdown ends in `process.exit`). The observable proxy is row 2a/2c: exit time after a signal is the same on head and main (within ±1 s; dominated by the 1.2 s grace). | (a) no match (b) n/a | (a) 1 match (b) 0 / ≪1 s, control 124 (c) parity with main | grep output, the two `time` lines, the 2a/2c timing comparison |
| 6 | Unit test | head: `cd ~/wt/pr-23571 && vp test run apps/desktop/scripts/electron-launcher.test.mjs`. main: the new test does not exist there. The PR's red commit is 9984f378a3. Optional: `git -C ~/wt/pr-23571 show 9984f378a3 --stat` to confirm it only adds the test. | n/a (the test is absent) | 8 passed, including `dev supervisor stops Electron and exits once its parent process goes away` | test output |
| 7 | Known limitation (informational) | Fresh start; `kill -KILL` the **grandparent** above VPPACK (the cross-env node or `vp run` task process, derived by ancestry) | VPPACK and SUP likely survive | **Same as main**: the check only watches the direct parent, so VPPACK's own death is what triggers it. Note whether VPPACK survives. | `r7.tree.txt`. Not a blocker; it marks the scope of the fix. |

Pass criteria for head: rows 1, 1b and 3 behave as in the head column; 2a and 2c are equivalent to main; 2b is either equivalent or improved; 5(a,b) hold. Main must reproduce row 1. If main does **not** reproduce row 1, the whole matrix is invalid: stop and report.
