# PR #23005 fixture matrix: SSH launcher waits for a desktop update (proof audit, pass 1)

- Head under audit: `9f5ac28ef0` (origin/main merged into the PR branch). The PR's own diff is `680152e27b..abd576be9e`.
- Before (for captures): `git merge-base 9f5ac28ef0 origin/main` = `8d3b061121`.
- Paths are at head `9f5ac28ef0`. Abbreviations: T = `packages/ssh/src/tunnel.ts`, U = `apps/desktop/src/updates/DesktopUpdates.ts`, P = `apps/desktop/src/backend/DesktopBackendPool.ts`, H = `apps/server/src/cli/sshHelper.ts`, C = `packages/contracts/src/desktopBootstrap.ts`.
- Marker path: the desktop writes `<baseDir>/runtime/desktop-update-in-progress`, where baseDir honours `T3CODE_HOME`. The launcher reads `$HOME/.t3/runtime/desktop-update-in-progress` (T:546, `DEFAULT_SERVER_HOME="$HOME/.t3"`).

## Launcher (remote host side): node mode and archive mode

| # | Fixture | Surface | Branch (file:line) | Proof medium | Before (8d3b061121) | After (9f5ac28ef0) | How to drive it for real on this VM | Existing tests | Verdict |
|---|---|---|---|---|---|---|---|---|---|
| F1 | No marker → launches immediately | other (ssh) | T:737–743 (`update_remaining` is empty, so the loop breaks at once); T:760 `pick_port` | Transcript with `date +%s.%N` timestamps | Starts a server at once | Same: one `start`, with no delay | Write the launch script to `/tmp/ev-23005/launch.sh` (see "Driving the launcher" below). Run `HOME=$FAKE sh -s -- k1 < launch.sh` with no marker and time it. Run once in node mode and once in archive mode. | tunnel.test "starts immediately without a marker (archive: false/true)" | SUFFICIENT |
| F2 | Fresh marker; the replacement default server comes up partway through → launcher adopts it, and no second server starts | other (ssh) | T:741–751 loop; T:746 `adopt_default_server` returns 0 (T:677 or T:699) | **Recording** or a timestamped transcript (timing matters) | Starts a competing managed server at t≈0. Two servers share `$HOME/.t3`, which is the #22990 bug. | Waits about N seconds, then prints the replacement's `remotePort` with `serverKind:"external"`. No `start`. | Write `$FAKE/.t3/runtime/desktop-update-in-progress` with `{startedAt:now, expiresAt:now+60000}`. Run the launcher in the background. After about 8 s, start `node apps/server/src/bin.ts --home-dir $FAKE/.t3` so it writes `$FAKE/.t3/userdata/server-runtime.json`. Capture the launcher's stdout and `ps` for server processes. | tunnel.test "waits across the eight-second installer gap…", "refreshes the runtime port while waiting…", "waits the full marker lifetime despite…stale-port probes" (simulated with fake commands and a fake clock) | INSUFFICIENT for real-process proof. The logic is covered with fake `node`/`sleep`/`date`, but no test runs a real server becoming ready. |
| F2b | Readiness deletes the marker between polls → final re-adopt after the loop | other (ssh) | T:743 breaks on an empty marker; T:754–758 final `adopt_default_server` | Transcript | n/a | Adopts the replacement instead of falling through to launch | Same as F2, but `rm` the marker when the server becomes ready (this mimics P:173–181) | tunnel.test "refreshes runtime after readiness removes the marker between polls" | SUFFICIENT (logic); real proof comes with F2 |
| F3 | Fresh marker is never resolved → waits ≤60 s, deletes the expired marker, then launches one server | other (ssh) | T:741, T:745 (`WAITED -lt 60`); the expired branch deletes the marker (node-inline T:654–655, archive H:147–148); T:760 | **Recording** or a timestamped transcript (60 s) | Starts at once | Starts after about 60 s. The marker is gone. Exactly one server. | Write a fresh marker. Run the launcher and time it with `time`. `ls` the marker afterwards. | tunnel.test "starts a server when the marker expires…" (60–61 s on the fake clock, marker removed, one start) | SUFFICIENT for logic; real wall-clock not shown |
| F3b | Short explicit `expiresAt` is honoured, so there is no fresh 60 s window | other (ssh) | T:652 / H:157 `min(expiresAt-now, TTL)` | Transcript | Starts at once | Waits about 1 s | Marker with `expiresAt = now+1000` | tunnel.test "honours the explicit expiresAt…"; sshHelper.test "shorter lifetime" | SUFFICIENT |
| F3c | Host TTL longer than 60 s is capped; the marker is preserved | other (ssh) | T:652 / H:157 cap; the loop bound at T:741/745 | Transcript | Starts at once | Waits exactly 60 polls, launches, and leaves the marker | Marker with `expiresAt = now+600000` | tunnel.test "caps a longer host TTL…"; sshHelper.test "long host TTL" | SUFFICIENT |
| F3d | Host clock jumps (backward or forward) → the wait is bounded by the sleep count, not the clock | other (ssh) | T:741 `UPDATE_WAITED` counter (comment at T:738) | Transcript | n/a | ≤61 polls | Hard to do for real without changing the system clock. Approximate with `faketime` if it is installed; otherwise leave it to the test. | tunnel.test "bounds a host clock jump to -30000 / 120000" | SUFFICIENT (cannot reasonably be reproduced for real) |
| F4 | Expired (stale) marker → deleted, immediate launch | other (ssh) | node-inline T:654–655; archive H:147–148 | Transcript (`ls` before/after plus timing) | Marker is ignored and stays on disk; immediate launch | Marker is deleted; immediate launch | Write `{startedAt: now-120000, expiresAt: now-60000}` and run the launcher | tunnel.test "ignores and removes a stale marker without waiting"; sshHelper.test "expired", "reversed", "expired missing start" | SUFFICIENT |
| F5 | Malformed, future, missing-field, or numeric-string marker → ignored and **preserved**, immediate launch | other (ssh) | T:648–656 (`catch {}`; no branch matches); H:141–143 `Effect.option`, H:149–155 guards | Transcript | Immediate launch | Immediate launch; the marker file stays | Write `broken`, a future-dated marker, `{}`, and `{"startedAt":"0",...}` | tunnel.test "preserves %s markers without waiting" (4 cases); sshHelper.test (malformed/future/missing/numeric strings) | SUFFICIENT |
| F6 | A reusable server is already up (recorded managed/external, or a ready default) → no update wait even with a fresh marker | other (ssh) | T:708 `adopt_default_server`, T:709–735 sets REMOTE_PORT, which skips the T:737 block | Transcript | Reuses it | Reuses it, at t=0 | Start the server first, write a fresh marker, then run the launcher | tunnel.test "reuses recorded %s server without runtime metadata…", "reuses an already ready server without waiting…" | SUFFICIENT |
| F7 | `adopt_default_server` refactor regression: wait-ready failure re-reads the recorded state (old inline code moved into a function with `return 0/1`) | other (ssh) | T:662–707 (new `return 0` at T:677/T:699 and `return 1`) | Transcript | Baseline behaviour | Identical when there is no marker | Run the existing reuse scenarios with no marker | Existing pre-PR `ssh tunnel scripts` tests plus F1/F6 | SUFFICIENT (no behaviour change without a marker); worth one real reuse run alongside F2 |
| F8 | Archive mode reads the marker via `run-t3.sh __ssh-helper update-remaining` (no host Node) | server + other | T:640–642 → H:137–162 (`updateRemaining`), H:163–176 subcommand, H:179 registration | Transcript | `update-remaining` is an unknown subcommand. Before has no wait, so it is not exercised. | Prints the remaining ms, or empty | Direct: `node apps/server/src/bin.ts __ssh-helper update-remaining /tmp/ev-23005/m.json` for fresh/partial/expired (the file is deleted)/future/malformed/missing. Archive launcher: capture with `captureLauncherFor({archiveVersion})`. A real archive run needs a release archive download (~70 MB) or a pre-seeded `run-t3.sh` in `$FAKE/.t3/ssh-launch/<key>/`. | sshHelper.test (12 table cases plus missing); tunnel.test "archive launcher reuses the replacement without host Node" (fake runner) | SUFFICIENT for helper logic. INSUFFICIENT for proof that the real CLI dispatches the subcommand: the tests call `updateRemaining` directly and never run the `__ssh-helper` command parser. |
| F8b | Version skew: an older remote `t3` without `update-remaining` → the helper errors, `2>/dev/null \|\| true` gives an empty result, and it launches immediately (old behaviour) | other | T:742 | Transcript | n/a | Falls back gracefully | Seed `run-t3.sh` pointing at the merge-base `bin.ts` and run archive mode with a fresh marker | none | INSUFFICIENT (not tested; low risk) |
| F9 | Launch SSH timeout budget grows to 150 s (node mode only); archive stays 900 s | other (ssh) | T:57–58; used at T:950–952 | Static/code read; a recording only if you prove the SSH command survives a 60 s wait | 90 s. With a 60 s wait plus 60 s readiness, a launch could time out. | 150 s | Real: run F3 over `ssh localhost` (sshd is on :22) with a long-lived test, and confirm the client does not hit the timeout. In practice this is a constant read. | none (constant not asserted) | INSUFFICIENT (untested constant; trivially verifiable by reading) |

## Desktop side (host that is updating)

| # | Fixture | Surface | Branch (file:line) | Proof medium | Before | After | How to drive it for real | Existing tests | Verdict |
|---|---|---|---|---|---|---|---|---|---|
| F10 | Install writes the marker atomically (`.tmp` then rename) with `{startedAt, expiresAt=startedAt+60000}` before the backends stop | desktop + contracts | U:602–625 (write), U:775 (call site, after the restart marker at U:774); C:47–48 constants | Transcript (`cat` marker with timestamps) | No marker file | Marker present, with a 60 s window | **Needs a packaged AppImage or .deb plus a feed**: `getAutoUpdateDisabledReason` U:324 disables updates when `!isPackaged`. Option: build an AppImage, run `scripts/mock-update-server.ts` with `T3CODE_DESKTOP_MOCK_UPDATES=true`, launch under `headless-desktop … --no-sandbox --disable-gpu` with `T3CODE_HOME=/tmp/ev-23005/home/.t3`, click Install, and `cat` the marker before the process exits. Heavy; quitAndInstall then really replaces the AppImage. | DesktopUpdates.test "publishes a 60-second update-in-progress marker before stopping the backend" | SUFFICIENT for logic. Real-app path **UNVERIFIED** unless a packaged Linux build is produced. |
| F10b | Atomic write fails → the `.tmp` is cleaned up, a warning is logged, and the install proceeds | desktop | U:617–624 | Transcript | n/a | `.tmp` removed | Not drivable for real without injecting a fs failure | DesktopUpdates.test "cleans the temporary marker when atomic publication fails" | SUFFICIENT |
| F11 | quitAndInstall fails (sync throw or the updater `error` event) → marker removed | desktop | U:699 in `recoverFailedInstall` (callers U:803, U:822, U:964) | Transcript | No marker existed | Marker removed | Same packaged-build requirement. Alternatively, approximate with the mock feed serving a corrupt artifact so the installer errors. | DesktopUpdates.test new case at ~line 295 (quitAndInstall failure clears the marker). The existing "restores only desired-running…" covers the error-event path but has no marker assertion. | SUFFICIENT for the sync-throw path; error-event path INSUFFICIENT (no marker assertion), though the shared `recoverFailedInstall` makes it low risk |
| F12 | Install interrupted (cancel) → marker removed | desktop | U:675–685 `resetInstallAction`, U:815 | Transcript | n/a | Marker removed | Not drivable through the UI; interruption is internal (scope close during the stop) | DesktopUpdates.test "clears the update-in-progress marker when an install is interrupted" | SUFFICIENT |
| F13 | Backend ready → marker removed (native primary and WSL); a replacement that is only starting does **not** remove it | desktop | P:173–181 `onReady` removes the marker, then `handleBackendReady` | Transcript (`ls` with timestamps before launch and after the window appears) or a still of the window plus a listing | Marker survives launch until it expires (60 s) | Marker deleted when the backend reports ready | **Approximable for real now**: `mkdir -p /tmp/ev-23005/home/.t3/runtime`, then write a fresh marker by hand. Run `T3CODE_HOME=/tmp/ev-23005/home/.t3 headless-desktop vp run dev:desktop` (or `start:desktop` after a build) with Electron flags `--no-sandbox --disable-gpu`. Poll `ls` every 0.5 s and grab the window with `import -window root`. Never point `T3CODE_HOME` at `~/.t3`. | DesktopBackendPool.test "%s primary retains the update marker through an eight-second boot gap and clears it on readiness" (native and WSL) | SUFFICIENT for logic; one real-app transcript would be cheap corroboration |
| F14 | End-to-end: the desktop on host A restarts for an update while a remote client SSH-launches to A → client adopts the new desktop backend | desktop + ssh | P + U + T together | **Recording** | Client spawns a second server | Client attaches to the updated backend | Full real path needs a packaged build (F10) **and** the desktop baseDir must equal `$HOME/.t3`, because the launcher hardcodes `$HOME/.t3` (T:546) while the desktop honours `T3CODE_HOME`. Approximate: run F13 (real desktop dev, `HOME=$FAKE`, `T3CODE_HOME` unset → baseDir `$FAKE/.t3`) with a hand-written marker, while running the launch script with the same `HOME=$FAKE`. | none end-to-end | INSUFFICIENT (no e2e). The quitAndInstall binary replacement is macOS/Windows/AppImage-only → **UNVERIFIED** on this VM without a packaged build. |

## Other
- `.gitignore` `/packages/ssh/.ssh-launcher-test-*/` is test hygiene for `launcherTestHarness.ts` scratch dirs. No fixture.
- `updatesTestHarness.ts` is test-only.

## Driving the launcher (shared recipe)
1. Capture the exact script with a scratch file **outside the worktree**. It imports `captureLauncherFor` from `packages/ssh/src/launcherTestHarness.ts`, which spawns nothing real; it records the stdin sent to `ssh`. Write the result to `/tmp/ev-23005/launch-node.sh` (`{nodeScriptPath: "<wt>/apps/server/src/bin.ts"}`) and `launch-archive.sh` (`{archiveVersion}`). Run the same capture at `8d3b061121` for Before.
2. `FAKE=$(mktemp -d /tmp/ev-23005/home.XXXX); HOME=$FAKE sh -l -s -- k1 < launch-node.sh`. `DEFAULT_SERVER_HOME` resolves to `$FAKE/.t3`, so nothing touches `~/.t3`.
3. Kill only the PIDs from `$FAKE/.t3/ssh-launch/k1/pid` and the PID of the server you started yourself.
4. Optional real transport: `ssh -o BatchMode=yes localhost 'sh -l -s -- k1' < launch.sh` (sshd on :22). Note that `HOME` is then the real home, so the launcher's `$HOME/.t3` paths point at the live install. **Do not use real-HOME ssh**: stick to the direct `HOME=$FAKE sh` run.

## headless-desktop
`~/.local/bin/headless-desktop <command> [args...]` runs the command under `xvfb-run -a` (1920x1080x24) inside `dbus-run-session` with an unlocked gnome-keyring. For Electron, pass `--no-sandbox --disable-gpu`. It has no `--help`; with no args it prints this usage and exits 2.
