# fix\(desktop\): SSH launcher waits for desktop updates before starting a server · PR \#23007 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/23007)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

State: open

Source repository: pingdotgg/t3code

Source branch: fix/issue-22990

Target branch: main

Head commit: 87ac5b3a34fe2c12561bf16f6f823e0376fda004

[Discussion and reviews](https://git.cafe/pingdotgg/t3code/pull/23007)

[Commits](https://git.cafe/pingdotgg/t3code/pull/23007/commits?format=markdown)

[Files changed](https://git.cafe/pingdotgg/t3code/pull/23007/diffs?format=markdown)

## Description

---

## Problem

An SSH reconnect can start a competing server while the desktop is stopped to install an update, taking the T3 home lock before the updated desktop returns. Addresses [#22990](https://github.com/pingdotgg/t3code/issues/22990), mirrored as [#22996](https://git.cafe/pingdotgg/t3code/issues/22996).

## Change

The desktop atomically writes a separate `runtime/desktop-update-in-progress` JSON marker before stopping backends. It expires after 60 seconds and is removed on primary readiness or failed/interrupted installation. When ordinary reuse finds no server, the SSH launcher waits for the marker to expire or the desktop server to become ready, refreshing runtime metadata and checking again before spawning. Expired markers are removed; unknown or newer-format markers are preserved. Longer host TTLs are respected within the client’s bounded wait. The archive runner supports this without host Node. Locks, tunnel handoff, and auto-attach behavior are unchanged.

## Scope and approval

Fixes https://git.cafe/pingdotgg/t3code/pulls/22990

Triaged bug via the automated issue flow; GitCafe mirror https://git.cafe/pingdotgg/t3code/issues/22996.

Validation: 74 tests passed across the four whole affected test files; scoped lint/format and touched-package typechecks passed. `vp run check:changed` passed format, lint, knip, generated GraphQL, affected-package typechecks, and all four test files. The pre-fix generated Node script spawns at 0 ms; the final generated Node and archive scripts reuse the replacement at 8,000 ms without start/kill. Home ownership is inferred from preventing the competing launch, not directly tested against the real lock. Fake-clock tests cover TTL, stale/invalid markers, changed ports, marker cleanup, clock jumps, existing servers, and expiry fallback. **cannot-verify live**: no two-machine macOS update was exercised.

## Verification

The launch script comes from the connecting client. Both host and client need this fix; older clients still race until updated. Protection is bounded to 60 seconds and the default T3 home already used by SSH discovery; host clock jumps can also bypass it. This remains independent of the adjacent slow-shutdown fixes.

Independent final verdicts: GPT-6.1-Sol and Claude Opus 5.5 both returned MERGE for code and SUFFICIENT for evidence, each rerunning all 74 tests. The evidence is compositional; it does not execute a real client reconnect, desktop installer, or ownership-lock refusal.

<!-- gc2-pull-create:3043e914793b852ffa824cbcd3c3fd39c8ea70217fc8a2b52ec8c4e0374d4ebc -->

Implementation: GPT-6.1-Sol in Codex through T3 Code; code review and evidence audit by GPT-6.1-Sol and Claude Opus 5.5 through T3 Code.


## Evidence

Before = [`8d3b061121`](https://github.com/pingdotgg/t3code/commit/8d3b061121fe65669d5d43d011b48cd3027a4108) (merge-base on main). After = [`9f5ac28ef0`](https://github.com/pingdotgg/t3code/commit/9f5ac28ef06642278197a4e007322158497df8a1) (this branch with main merged in). Both were run as real processes (launcher script, `t3` server, Electron desktop) with an isolated `HOME` under `/tmp`, under the same conditions on both sides. Fixture matrix: [matrix.md](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/6d741ecfb27d4d05/matrix.md). The driver is committed in [`901c5dcbb5`](https://github.com/pingdotgg/t3code/commit/901c5dcbb5) and [`87ac5b3a34`](https://github.com/pingdotgg/t3code/commit/87ac5b3a34fe2c12561bf16f6f823e0376fda004) (helper inlined) as `scripts/evidence/pr-23005-ssh-update-race.sh <checkout> <row> <outdir>`.

| Row | Fixture | Before (`8d3b061121`) | After (`9f5ac28ef0`) |
|---|---|---|---|
| F1 | No marker | Starts its own server: node mode 7.1 s, archive mode 25.8 s ([log](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/7114f4bbf6c9ec6f/F1-before.txt)) | Same: 6.5 s / 22.6 s, no wait ([log](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/6e66a5e363a8b3c0/F1-after.txt)) |
| **F2** | Fresh marker; the replacement server comes up about 8 s later | Starts a **competing** server at 6.6 s; the replacement then fails ownership (https://git.cafe/pingdotgg/t3code/pulls/22990) | Waits, then adopts the replacement as `external` at 14.6 s. One server, no `start`. [recording (mp4)](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/29064f88c0ce8c4e/F2-before-after.mp4), [transcript](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/be1d9502414835d2/F2-before-after.txt) |
| F2b | Readiness deletes the marker between polls | Own server at 6.6 s ([log](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/50a2f0dab74e7aaa/F2b-before.txt)) | Re-adopts `external` at 14.7 s ([log](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e53e39a1012cb4b4/F2b-after.txt)) |
| **F3** | Fresh marker that never resolves | Launches at 6.6 s; marker left behind | Waits, launches at 65.9 s, marker deleted, one server. [recording (mp4)](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/f2527511db0f3ada/F3-before-after.mp4), [transcript](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/51f3f1386e6c8de4/F3-before-after.txt) |
| F3b | Marker expires in 1 s | 5.7 s, marker kept ([log](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/abef8bddb0be9dd7/F3b-before.txt)) | 7.0 s, marker deleted ([log](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/15f396eaa572f71b/F3b-after.txt)) |
| F3c | Host TTL of 600 s | 5.9 s ([log](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/b294d4ba07eb4481/F3c-before.txt)) | Wait capped at 60 s, launches at 73.1 s, marker kept ([log](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/7464a7c616566860/F3c-after.txt)) |
| F4 | Stale marker | Immediate, marker kept ([log](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/04352100286f9204/F4-before.txt)) | Immediate, marker deleted ([log](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/374c0d13a4409104/F4-after.txt)) |
| F5 | Malformed, future, `{}` and numeric-string markers | Immediate, kept ([log](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/cc3e86e13f4679d0/F5-before.txt)) | Immediate, kept ([log](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/547a2c5ad83b0c49/F5-after.txt)) |
| F6 | Ready server and a fresh marker | Reused as `external` in 0.50 s ([log](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/025f40ee267ea4dd/F6-before.txt)) | Reused as `external` in 0.27 s, no wait ([log](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/80a08ef62754ef62/F6-after.txt)) |
| F7 | Managed reuse, no marker | Same PID reused in 0.30 s ([log](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/d39b885b22a5864b/F7-before.txt)) | Same ([log](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/7e2ae1e9b1174ac4/F7-after.txt)) |
| F8 | `t3 __ssh-helper update-remaining` | rc=1, usage text (unknown subcommand) ([log](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/9a3c5cdbdf812287/F8-before.txt)) | Fresh marker 55767, partial 10952, expired is deleted, future or malformed is kept ([log](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/44935975e1a43077/F8-after.txt)) |
| F8 archive | Archive runner reads the marker | Marker kept ([log](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/28c385610e13c38e/F8-archive-before.txt)) | Marker deleted, so the subcommand ran ([log](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/1126e7dcd9ba7df0/F8-archive-after.txt)) |
| F9 | Node-mode SSH launch timeout | 90 s | 150 s (`packages/ssh/src/tunnel.ts:57`); archive mode stays 900 s |
| F13 | Desktop backend readiness clears the marker | Backend ready at 11:15:19.084; marker **still present** at 11:15:27 ([log](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/abde961b895cd553/F13-before.txt)) | Backend ready at 11:16:05.490; marker **gone** at 11:16:06.746 ([log](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/d64c4da949dea32b/F13-after.txt)) |
| F14 | Desktop and launcher share one HOME (partial) | Launcher takes `server-runtime.json`; desktop never becomes ready | Marker honoured and cleared at readiness; no second server takes ownership. Adoption fails only because the dev desktop answers `/` with a 302. [recording (mp4)](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/711f8e114b30015c/F14-before-after.mp4), [transcript](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/3b727954122e7f7a/F14-before-after.txt) |

| F13 before | F13 after |
|---|---|
| ![F13 before: dev desktop window on the splash screen after backend ready, marker still present](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/d70ca9e75dbb80ec/F13-before-still.png) | ![F13 after: dev desktop window on the splash screen after backend ready, marker removed](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/a24fc670b4341db5/F13-after-still.png) |

**Environment** (every capture): Linux 6.1.102 x86_64 VM. Node v24.22.1 ran the driver, launchers and servers; the desktop dev runner ran on Node v26.12.1. Electron 44.9.3 dev build with `--no-sandbox` under `headless-desktop` on Xvfb 1920x1080x24. Recordings were made with asciinema 2.4.0, agg 1.9.0 and ffmpeg (real time). Both sides used the same two untracked build stubs: a one-line `apps/web/dist/index.html` (the launcher's readiness probe needs a 2xx) and `/bin/true` as `t3-browser-secret` (no libsecret headers on the VM).

**What else changed:** nothing relevant. `git diff 8d3b061121 9f5ac28ef0` contains only this PR's 12 files. The 112 main commits brought in by the merge are already in the before build. The stills show the same splash window on both sides.

<details><summary>Verification log</summary>

- **Proof audit pass 1** (Claude Opus 5.5 via T3 Code): built the [fixture matrix](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/6d741ecfb27d4d05/matrix.md). Tests already covered the logic; it flagged F2, F8 routing and F14 as needing real-process runs.
- **Proof audit pass 2** (Claude Opus 5.5 via T3 Code): **SUFFICIENT** ([report](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/8a2d5fada3c3a6de/audit2.md)). SHAs match on every transcript. It independently rebuilt F4 from the committed driver: before 5997 ms with the marker present, after 6595 ms with the marker absent.
- **Tests** on `9f5ac28ef0`:
  - `apps/desktop` (DesktopBackendPool, DesktopUpdates): 34 passed.
  - `apps/server` sshHelper: 13 passed.
  - `packages/ssh` tunnel: 26 of 27 passed. "archive launcher reuses the replacement without host Node" exceeds its 5 s timeout on this VM (6.4–6.6 s, identical on the unmerged `abd576be9e`). It passes in CI.
- **CI** on `9f5ac28ef0`: 29/29 checks green. On `901c5dcbb5`, the standalone driver helper failed the scripts typecheck. It is inlined in `87ac5b3a34` (scripts `tsc` passes locally; F4 was re-run with the same result). CI on `87ac5b3a34` was pending when this was written.
- **Unverified:**
  - F10 and F11 (marker written at install, removed when the install fails): updates are disabled in an unpackaged build, and no packaged Linux build plus mock feed was made.
  - F10b and F12 (atomic-write failure, interrupted install): not reachable from the UI; covered by tests only.
  - F3d (host clock jump): would need the system clock to change; covered by tests only.
  - Real `quitAndInstall` binary replacement: macOS, Windows or AppImage only.
  - F14 end-to-end adoption: needs a packaged desktop. F2, F2b and F13 together cover the mechanism.
- **Follow-up, not blocking:** with the after launch script and an *older* remote `t3` that lacks `update-remaining`, the usage text on stdout is read as time remaining. The launcher then waits about 677 s ([log](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/9d19d0b707ecc35c/F8b-after.txt)). Shipped builds can't hit this, because `archiveVersion` is pinned to the desktop's own version (`apps/desktop/src/main.ts:116`). Accepting only digits at `packages/ssh/src/tunnel.ts:742` would harden it.
- Evidence lead, capture and audits: Claude Opus 5.5 in Claude Code through T3 Code.

</details>

Fixes #22990
GitCafe: https://git.cafe/pingdotgg/t3code/issues/22990

