# Evidence for #23601: topology polling without sendSync (Linux desktop build)

- before: `8d3b061121fe65669d5d43d011b48cd3027a4108` (worktree `~/wt/pr-23601-before`, `git rev-parse HEAD`)
- after: `fc736575710b11a7aa4c729aabee38d5eae3c907` (worktree `~/wt/pr-23601`, `git rev-parse HEAD`)
- Electron 44.9.3 (both), Xvfb 1920x1080 via `headless-desktop`, `--no-sandbox --disable-gpu`. The entry is `dist-electron/main.cjs`, the same as `apps/desktop` `start`.
- Build: `vp run build:desktop` in each worktree succeeded. No libsecret failure, so no manual packing was needed. Electron was fetched with `node install.js` in `apps/desktop/node_modules/electron`.
- State: `vp run migrate-dev-db --base-dir /tmp/ev-23601/home-{before,after}` in each worktree. Each kept 21,444 events and showed "Migrations: already current".
- Launch: `T3CODE_HOME=/tmp/ev-23601/home-<v>`, `XDG_CONFIG_HOME`/`XDG_CACHE_HOME` under that home (isolates the Chromium userData and the single-instance lock), `--inspect-brk` on main plus `--remote-debugging-port` for the renderer. `~/.t3` was never written to. Every process was stopped by the process group captured at spawn.
- Driver: `ev-23601-driver.tgz` sha256 `ce5a568d5250d512745f23e1585cc48147d307268bc0bc270e10b094e3e1253a` (`driver/driver.mjs` sha256 `f4b30c952194eb2c0b85f82f71db725ac5ae8fe75410b07294861bedd14a9eea`). No tracked files were edited; all probing is runtime injection.
- Topology IPC counter: while main is paused on its first line (`--inspect-brk`), the driver wraps `ipcMain.on`/`ipcMain.handle` for `desktop:get-local-environment-bootstraps` with pass-through timestamp recorders. `desktop.trace.ndjson` logs both kinds as `desktop.ipc.method` and cannot tell them apart.

## Startup topology IPC (bootstrap channel, first 10 s after the first call)

| build | sync (`sendSync`) | async (`invoke`) | cadence |
|---|---|---|---|
| before | 10 | 0 | sync at +0, +408, +431, +1129 ms, then 2 sync per ~3 s poll |
| after | 1 | 4 | 1 sync at +0, then 1 invoke every ~3.0 s (+273/280, +3426, +6434, +9437 ms...) |

The after session's totals (103 s, including all probes) were **1 sync / 34 invoke**. Before totals are polluted by the driver's own bridge reads, so only the startup window is reported.

## R0: app starts with seeded state

Both builds load the seeded threads, connect to the local environment, and open a new empty chat with no offline banner. Screenshots: `before-r0-newchat.png`, `after-r0-newchat.png`.

## R1: topology change within one poll

Injection: through the main inspector, `Runtime.queryObjects(Map.prototype)` finds the `DesktopBackendPool` instances `Map` (key `primary` → `{_tag:"Active", instance}`). That Map is what `pool.list` returns to `readLocalEnvironmentBootstrapSnapshot`, which both the sync and async handlers call. The driver `set`s a `probe-secondary` entry, then later `delete`s it. The fake instance is the primary's instance with `id: "probe-secondary"`, `label: succeed("Probe Secondary")`, and `currentConfig: succeed(Option.none())`, which is the snapshot's not-configured secondary path (`httpBaseUrl: null`). `succeed` is built by swapping the payload of a bundled `Effect.succeedSome` primitive, and `none` is the bundled `Option.none`.

Timing, from the change applied in main (`Date.now()` in main) until:
- after: the renderer's `window.desktopBridge.getLocalEnvironmentBootstraps()` (the preload cache, filled only by the poll's async refresh) contains or drops the secondary. Polled every 25 ms over CDP.
- before: the renderer's first topology read after the change. On before, the bridge read is itself a live `sendSync`, so polling it from CDP would measure the probe and not the app. The driver does not poll it and records the first renderer-originated sync call on the channel, which returns the new state.

| build | add #0 | remove #0 | add #1 | remove #1 |
|---|---|---|---|---|
| before (first renderer sync read) | 2269 ms | 2496 ms | 677 ms | 2485 ms |
| after (first invoke) | 2518 ms | 2481 ms | 678 ms | 2481 ms |
| after (renderer cache reflects it) | **2535 ms** | **2501 ms** | **692 ms** | **2489 ms** |

Every change landed on the next ~3 s poll in both builds. On after, the cache updated 8–20 ms after that poll's invoke. Before and after each add, the primary entry stayed in the bridge at `http://127.0.0.1:3774/`, the renderer could reach the server (`/.well-known/t3/environment` → HTTP 200), and no offline banner appeared.

Primary field change (after only): replacing the primary instance's `label` Effect in the same Map with `succeed("Primary Relabeled")` reached the renderer's cached primary entry in **≤ 2490 ms** (shell-loop upper bound), then was restored.

## R2: failed refresh keeps the snapshot (after only; before N/A)

Before has no `refreshLocalEnvironmentBootstraps` (`typeof` is `undefined`), so R2 is N/A there.

After: with `probe-secondary` present, the driver called `ipcMain.removeHandler(ch)` and then `ipcMain.handle(ch, () => { throw new Error("probe") })` for 13.1 s, and restored the original from `ipcMain._invokeHandlers` afterwards.
- Rejected polls at +502, +3505, +6508, +9511, +12514 ms (5 failures).
- Sampled every 1 s during the failures (13 samples): the renderer snapshot stayed `[primary @ http://127.0.0.1:3774/, probe-secondary "Probe Secondary"]` the whole time. No offline/reconnect banner appeared, and the secondary was never dropped.
- Recovery: after the restore, the driver removed the secondary from the pool, and the first successful poll applied the removal **2472 ms** after the restore. The page stayed online.
- An earlier R2 run in the same session gave the same result: 4 rejected polls, snapshot retained, recovery in 1000 ms.

## R3: typing probe (300 keys at 40 ms, new empty chat `#/draft/<uuid>`, renderer CPU profile at 500 µs, longtask observer)

"busy" is the author's method: main runs `setInterval(() => block 1000 ms, 3000)` through the main inspector, starting at a random phase. "aligned" is an extra I added: the same block and period, but the first block starts 300 ms before the next observed topology poll, so the overlap does not depend on phase. Keystroke latency is the CDP `Input.dispatchKeyEvent` keyDown+keyUp round trip. All runs typed 300/300 chars.

| build | run | sendSync sampled | longest sendSync | renderer long tasks > 200 ms | keystroke p99 |
|---|---|---|---|---|---|
| before | idle | 11.2 ms | 2.9 ms | none (max 0) | 32.0 ms |
| before | busy #1 | **332.6 ms** | **314 ms** | **317** | 993.3 ms |
| before | busy #2 | 19.5 ms | 6.3 ms | none (max 50) | 1000.1 ms |
| before | busy #3 | 19.2 ms | 12.2 ms | none (max 0) | 1008.7 ms |
| before | busy #4 | 14.0 ms | 4.6 ms | none (max 0) | 1006.2 ms |
| before | busy #5 | 34.7 ms | 20.1 ms | none (max 119) | 1006.4 ms |
| before | aligned #1 | **1718.7 ms** | **1009.9 ms** | **1005, 702** | 1012.3 ms |
| before | aligned #2 | **1708.1 ms** | **999.9 ms** | **1000, 702** | 997.9 ms |
| after | idle | 0 | 0 | none (max 0) | 39.2 ms |
| after | busy #1 | 0 | 0 | none (max 0) | 1009.4 ms |
| after | busy #2 | 0 | 0 | none (max 0) | 1002.0 ms |
| after | busy #3 | 0 | 0 | 569 (no sendSync; see note) | 1010.2 ms |
| after | aligned #1 | 0 | 0 | none (max 67) | 1011.6 ms |
| after | aligned #2 | 0 | 0 | none (max 0) | 1011.5 ms |

sendSync callers on before (CPU-profile parent frames): `getLocalEnvironmentEnabled@preload.cjs:320` (busy #1: 311.3 ms; aligned: ~1698 ms each) and `getLocalEnvironmentBootstraps@preload.cjs:314`. On after: none.

Notes:
- Random-phase busy reproduced the freeze in 1 of 5 before runs. The 3 s injector and the 3 s poll share a period, so each run either overlaps every poll or none. Phase-aligned runs freeze before every time (~1 s per block) and never freeze after.
- The after busy #3 569 ms long task has 0 sendSync. In its longest busy stretch (421 ms), the profile attributes 234.5 ms to GC and 177 ms to ComposerPromptEditor `dispatchTransaction` (keystrokes queued behind a main block arrive as a burst). That is not topology IPC.
- Keystroke p99 under a busy main is ~1 s in both builds because CDP input dispatch goes through the browser main thread, as the author noted.

## Deviations from the plan

1. **First R1 attempt (discarded).** The fake secondary was a plain clone of the primary instance, so it had the same URL and auth. After it was added and removed, the renderer showed "fs-17 is offline: T3 Code on fs-17 did not accept this device" (`after-postprobe-offline.png`). A fresh launch reproduced this after R1 alone. With the non-aliasing secondary (`httpBaseUrl: null`), the banner never appeared in R1 or R2. This is an artifact of the alias injection and is not attributed to the PR. All R1/R2/R3 numbers above come from clean sessions using the non-aliasing secondary. The after-busy run from the contaminated session is excluded.
2. **Before busy #1 transcribed.** Its JSON and cpuprofile were overwritten by run 5 because the driver only numbered runs 2–5. `r3-before-busy-1.json` is transcribed from the console output and marked as such.
3. **Long-task duplication (fixed).** On the before page, the aligned runs were the 7th and 8th R3 runs. Earlier driver revisions registered a new PerformanceObserver per run, so each long task appeared once per observer. The table lists the de-duplicated values; max values are unaffected. The driver now guards to one observer per page.
4. **Different before SHA.** Before is `8d3b061121`, as assigned, not the author's `07d38b8d17`.
5. **First before launch exited.** It exited silently right after the inspector attached, probably because it overlapped the after app's teardown. The second launch was normal.
6. **Not shown in the DOM.** The secondary's label did not appear in the sidebar/new-chat DOM for the URL-less secondary. R1 is measured at the bridge/IPC level.
