# fix\(desktop\): typing no longer freezes while the renderer polls topology · PR \#23601 · pingdotgg/t3code

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

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

Visibility: public

State: open

Source repository: pingdotgg/t3code

Source branch: fix/issue-12689

Target branch: main

Head commit: fc736575710b11a7aa4c729aabee38d5eae3c907

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

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

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

## Description

---

## Problem

The desktop renderer read local-environment topology with `ipcRenderer.sendSync` on every 3 s platform poll (`getLocalEnvironmentBootstraps`, `getLocalEnvironmentEnabled`), and `getLocalEnvironmentEnabled` also ran on composer renders. Each call parks the renderer thread until the main process answers, so any main-process stall freezes typing, even in an empty chat. The browser UI on the same backend has no `desktopBridge` and stays smooth. Reproduced on the real Linux desktop build at `origin/main`: typing into a new empty chat while the main process runs a 1 s blocking task every 3 s, the renderer froze inside `sendSync` for up to 885 ms (long tasks 885/554/476/464/350 ms), the same signature as the reporters' Windows profiles.

## Change

- Main registers the existing bootstrap snapshot handler a second time as an async `invoke` handler on the same channel. The sync handler stays for the one startup read.
- The preload caches the bootstrap snapshot from that startup read and exposes `refreshLocalEnvironmentBootstraps`, which refreshes the cache over `invoke`. `getLocalEnvironmentBootstraps` now returns the cache.
- `getLocalEnvironmentEnabled` is read once per page. Changing it already relaunches the app.
- The platform poll awaits the async refresh before it reads the primary target and the secondaries, so a topology change still lands within one poll. When the bridge has no refresh method, the web reader falls back to the sync read.

This re-lands the approach of https://git.cafe/pingdotgg/t3code/pulls/19300, which https://git.cafe/pingdotgg/t3code/pulls/22222 reverted only because it had not been run on a real desktop build. The refresh now runs before the primary read.

## Scope and approval

Fixes #12689
GitCafe: https://git.cafe/pingdotgg/t3code/issues/12689
Triaged bug via the automated issue flow (labelled `accepted`, `bug`). The maintainer triage comment on the issue names this fix: stop polling the topology snapshot over `sendSync`.

## Verification

Focused tests:
- `apps/desktop`: `vp test run src/preload.test.ts src/ipc` passed 20/20. `polling topology reads do not block the renderer on synchronous IPC each time` failed on main with `expected 5 to be less than or equal to 1` for both channels, and passes now.
- `apps/web`: `vp test run src/connection` passed 30/30, including the new async refresh case (secondary appears, failed refresh keeps the snapshot, empty refresh clears it).

Real desktop build, Linux (Electron 44.9.3 on Xvfb, `--no-sandbox --disable-gpu`), with existing user state seeded by `vp run migrate-dev-db`. A CDP probe typed 300 keys at 40 ms into a new empty chat while CPU-profiling the renderer. "busy" means the main process ran a 1 s blocking task every 3 s.

| run | `sendSync` sampled | longest `sendSync` | renderer long tasks > 200 ms |
|---|---|---|---|
| main, idle | 69 ms | 13.9 ms | 228, 216 |
| main, busy | 888 ms | 428 ms | 885, 554, 476, 464, 350 |
| fix, idle | 0 | 0 | none (max 176) |
| fix, busy | 0 | 0 | none (max 81) |

- `desktop.trace.ndjson`: main made 693 sync topology calls in an 810 s session. The fix makes 2 sync calls at startup, then an async `invoke` on the same channel every ~3 s. The sync and async handlers on one channel coexist in the real build.
- Keystroke dispatch p99 under the busy main stays ~1.06 s both before and after, because Electron routes input through the main thread itself. The fix removes the renderer-side freeze, not main-process stalls.
- Full transcript: [probe-transcript.md](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/ea9148cf648375fc/probe-transcript.md)

The fixed build with existing user state connects to the local environment and loads the seeded threads. There is no visual change to compare, so these are after-only:

| New chat with seeded threads (fix) | Settings › Connections (fix) |
|---|---|
| ![after-newchat.png](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/60c79550151767b7/after-newchat.png) | ![after-connections.png](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/d840506f05daafd1/after-connections.png) |

Unverified here:
- Windows, where the issue was reported.
- macOS desktop.
- A WSL secondary backend appearing or disappearing mid-session.

Those paths run the same preload and web code. They are covered only by the unit tests above.

Claude Opus 5.5 (claude-opus-5-5) via Claude Code in T3 Code; reviews by Claude Opus 5.5 and Claude Sonnet 5.5.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---

## Independent review and proof audit (head `fc736575`)

**Reviews** (read-only, full diff against main, with [#19300](https://git.cafe/pingdotgg/t3code/pulls/19300) and [#22222](https://git.cafe/pingdotgg/t3code/pulls/22222)):
- Claude Sonnet 5.5: `VERDICT: MERGE`. Found no concrete defect; the new tests pass. A topology change reaches both the primary and the secondaries within one poll, now that the refresh runs before the primary read. The sync and async handlers on one channel don't conflict. The fallback for older desktop shells is the old sync read.
- Claude Opus 5.5: `VERDICT: NEEDS_JULIUS`. Found no blocking defect. Its reason is the re-land bar from https://git.cafe/pingdotgg/t3code/pulls/19300 (a real build with user state on every platform it touches): only Linux has been run. Between polls, readers outside the poll see data up to one poll old. The bootstrap token can't go stale in that time because the backend accepts a 3-window, 12 h range. A newer desktop paired with an older web build would never refresh, but the desktop always loads its own bundled web.

**Proof audit:** pass 1 [`INSUFFICIENT 1,2,3,5`](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/70229da382f8ed92/audit1.md). The runs weren't pinned to a SHA, the topology change had never been run on a real build, and the driver wasn't published. After new captures, pass 2 [`VERDICT: SUFFICIENT`](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/41b9559453762d90/audit2.md).

**New rows.** Real Linux desktop build (Electron 44.9.3, Xvfb, `--no-sandbox --disable-gpu`) with state seeded by `vp run migrate-dev-db` into an isolated home. before = `8d3b061121` (2 commits behind main, with no topology changes between them); after = `fc736575`.

| Row | before `8d3b061121` | after `fc736575` |
|---|---|---|
| Startup, topology IPC in the first 10 s | 10 sync | 1 sync, then async `invoke` every ~3 s |
| Existing user state | threads load, connected ([screenshot](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/cc0533a115513faa/before-r0-newchat.png)) | threads load, connected ([screenshot](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/c735ce5665b500aa/after-r0-newchat.png)) |
| Secondary added, removed, added, removed in main; when the renderer sees it | 2269 / 2496 / 677 / 2485 ms | 2535 / 2501 / 692 / 2489 ms (within one poll). A primary label change took ≤ 2490 ms. |
| Async refresh rejects for 5 polls (13 s) | N/A (no refresh method) | Snapshot kept the primary and the secondary throughout, with no offline banner. The first good poll applied a change 2472 ms after the handler was restored. |
| Idle typing: `sendSync` ms / longest / long tasks > 200 ms / key p99 | 11.2 / 2.9 / none / 32 ms | 0 / 0 / none / 39 ms |
| Busy typing, random phase (5 before runs, 3 after) | 1 of 5 runs froze: 332.6 / 314 / [317] / 993 ms | 0 / 0. One run had a 569 ms long task with no `sendSync` (GC and editor work). |
| Busy typing, 1 s block aligned to the poll (2 runs each) | 1718.7 / 1009.9 / [1005, 702]; 1708.1 / 999.9 / [1000, 702] | 0 / 0 / none (max 67) |

The busy rerun confirms the body's table in mechanism: the same two preload `sendSync` frames, on before only. The size depends on the phase. A random-phase run hits a poll only sometimes; when the block is aligned to the poll, the before build froze for about 1 s in every run.

Driver [ev-23601-driver.tgz](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/f473ecb612b290c2/ev-23601-driver.tgz), sha256 `ce5a568d5250d512745f23e1585cc48147d307268bc0bc270e10b094e3e1253a` (`driver.mjs` alone: `f4b30c952194eb2c0b85f82f71db725ac5ae8fe75410b07294861bedd14a9eea`). Results: [captures.md](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/2cd0f8bf461b54c1/captures.md), raw JSON and CPU profiles: [ev-23601-data.tgz](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/7540e12b155dae0c/ev-23601-data.tgz).

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

- 2026-10-22, fs-17, head `fc736575710b11a7aa4c729aabee38d5eae3c907`. Evidence only: no code changes and no pushes.
- Reviews: Claude Sonnet 5.5 MERGE, Claude Opus 5.5 NEEDS_JULIUS.
- Proof audit: pass 1 INSUFFICIENT 1,2,3,5; pass 2 SUFFICIENT.
- Captures used the real Linux desktop build (`vp run build:desktop` succeeded; no libsecret workaround needed).
- How the rows were injected: the secondary was injected into the main backend pool's live instance map through the main inspector, and the failed refresh replaced the async `invoke` handler with one that throws.
- Caveats:
  - The first R1 attempt cloned the primary's URL and auth, which caused an offline banner. That run was discarded; the reported runs are from clean sessions.
  - The before data file for busy random-phase run 1 was transcribed from console output after its profile was overwritten.
  - The published driver is a later revision than the one that ran the before idle and busy runs.
- Audit pass 2 noted that two sentences in the body above don't match the transcript. The "885 ms inside `sendSync`" was the longest long task; the longest `sendSync` was 428 ms. The keystroke p99 under busy main was 1167 ms before and 1064 ms after, not ~1.06 s both ways. Left for the author to correct.
- Unverified: Windows (where https://git.cafe/pingdotgg/t3code/issues/12689 was reported), macOS desktop, and a real WSL secondary appearing or disappearing mid-session.
- Model and harness: Claude Opus 5.5 (claude-opus-5-5) via Claude Code in T3 Code (lead, capture, audits); reviewers Claude Sonnet 5.5 and Claude Opus 5.5.

</details>

