Glad to hear I am not the only one having issues. Just started using T3 Code. It also really wanted to open the dev url as a file.
[Bug]: Remote preview stays on "Connecting to browser…" when the viewer's first still capture never answers#24852
Area
apps/server (ServerBrowser.attachViewer, ServerBrowserStream), apps/mobile preview sheet.
What happens
A remote viewer (iPhone app, via T3 Connect relay) opened an agent-owned tab and stayed on "Connecting to browser…" for four minutes, until the agent closed the tab. Host is the Windows desktop app 0.1.2 with the headless server browser (no desktop-hosted webview involved), so this is not the off-window Electron guest in #22461 / #23015 / #24514.
Why
ServerBrowserStream calls attachViewer before upgrading the WebSocket, and attachViewer awaits an initial still (Page.captureScreenshot) with no deadline. If the page's renderer does not answer, the handshake never completes: the phone's socket never opens, and the UI has no timeout for that state. The pending still also holds tab.captureLock, so every later agent snapshot on the tab queues behind it (#23022 describes that half).
Host trace (server.trace.ndjson, UTC 2026-10-25):
| 1 | 09:02:27 preview_open https://www.costco.co.uk/ ok, 4.3 s |
| 2 | 09:02:28 GET /api/preview-stream/ws → ServerBrowser.attachViewer 244,877 ms |
| 3 | 09:02:29… preview_snapshot / evaluate / navigate on that tab all time out (15 s) |
| 4 | 09:04:22 second viewer attach (full screen) 131,158 ms |
| 5 | 09:06:33 t3_preview_close → both attachViewer spans end, GETs 499 |
A second tab on the same site worked normally. Why the first renderer stopped answering is not reproduced; the T3 fault is that one unanswered capture blocks the viewer handshake and the tab.
Expected
The viewer's socket opens as soon as the screencast is attached; the still is best-effort. A client that gets no frame after a reasonable time shows an error with a reconnect, not a permanent spinner.
Reproduction
Unit level: stub Page.captureScreenshot to never resolve on the viewer's CDP session and call attachViewer; it never returns. Test and fix on robrab2000/t3code@investigate/mobile-preview-connecting-hang, PR to follow.
Refs #23022, #23515 (bounds captureViewport), #19420, #22461.
Investigated with Claude Fable 5.1 in T3 Code.
Still reproducing on 0.1.8-nightly.20261105.4218 (macOS arm64, desktop app host, viewer via T3 Connect relay). Today's source still has the unbounded pushStill before the upgrade (ServerBrowser.ts:2683, captureViewport in ServerBrowserPage.ts:469 has no deadline).
Host server.trace.ndjson, 2026-11-05 UTC:
| 1 | 15:20–15:30 tab_4 ServerBrowser.attachViewer 35–53 ms ok |
| 2 | 15:31:19 tab_5 ServerBrowser.attachViewer 55,474 ms GET /api/preview-stream/ws interrupted |
| 3 | 15:31:31 tab_5 ServerBrowser.attachViewer 43,539 ms interrupted |
| 4 | 15:31:40 tab_5 ServerBrowser.attachViewer 34,270 ms interrupted |
The relay connector logs Incoming request ended abruptly: context canceled for each preview-stream request at the same moments (15:31:31, 15:31:34, 15:37:51, 15:38:42), so the viewer gives up while the server is still waiting on the still. A freshly opened tab works until its renderer stops answering. Open fixes: #27663, #25381, #25862.
Triaged with Claude Opus 5.5 in T3 Code.
I reproduced the trigger on the headless server browser. A viewer's still capture that is pending when the tab commits a navigation to a different site never gets an answer. pushStill then holds tab.captureLock until the tab closes, and the tab wedges as this issue describes. This answers "Why the first renderer stopped answering is not reproduced" above. The renderer did not stop. Chromium dropped the request when it swapped renderer processes.
Standalone reproduction
The script below does not use T3. It uses playwright-core 1.60.0 and the chrome-headless-shell 156.0.8078.70 that T3 downloads. A second CDP session plays the viewer. It starts Page.captureScreenshot while page script is busy, and then the page navigates. 127.0.0.1 and localhost are different sites.
| Navigation while the capture is pending | Pending capture | New capture on the new page |
|---|---|---|
| Same site | answered | answered in 35 ms |
| Different site | no answer after 30 s (180 s in a longer run) | answered in 12 ms |
Different site, then CDPSession.detach() | rejected at the detach | answered in 8 ms |
On the same busy page, Page.stopScreencast and Page.startScreencast answered in under 5 ms, so the viewer's pause and resume are not the hang. Page.captureScreenshot, Runtime.evaluate and key presses waited until the page script finished.
node lost-capture-repro.cjs <playwright-core dir> <chrome-headless-shell path>
| 1 | // node lost-capture-repro.cjs <playwright-core dir> <chrome-headless-shell path> |
| 2 | // A viewer-style CDP session starts Page.captureScreenshot while the page is busy, then the |
| 3 | // page navigates. 127.0.0.1 and localhost are different sites, so Chromium swaps processes. |
| 4 | const http = require("node:http"); |
| 5 | const { chromium } = require(process.argv[2]); |
| 6 | const busy = (ms) => `<script>setTimeout(() => { const e = Date.now() + ${ms}; while (Date.now() < e); }, 200)</script>`; |
| 7 | const server = http.createServer((req, res) => { |
| 8 | res.setHeader("content-type", "text/html"); |
| 9 | res.end(req.url === "/busy" ? `<h1>busy</h1>${busy(5000)}` : "<h1>idle</h1>"); |
| 10 | }); |
| 11 | const settle = (p, ms) => { |
| 12 | const t0 = Date.now(); |
| 13 | return Promise.race([ |
| 14 | p.then(() => `answered after ${Date.now() - t0} ms`, (e) => `rejected after ${Date.now() - t0} ms: ${e.message.split("\n")[0]}`), |
| 15 | new Promise((r) => setTimeout(() => r(`no answer after ${ms} ms`), ms)), |
| 16 | ]); |
| 17 | }; |
| 18 | (async () => { |
| 19 | await new Promise((r) => server.listen(0, "127.0.0.1", r)); |
| 20 | const port = server.address().port; |
| 21 | const browser = await chromium.launch({ executablePath: process.argv[3] }); |
| 22 | const context = await browser.newContext(); |
| 23 | for (const [label, target, detach] of [ |
| 24 | ["same site", `http://127.0.0.1:${port}/idle`, false], |
| 25 | ["other site", `http://localhost:${port}/idle`, false], |
| 26 | ["other site, then detach", `http://localhost:${port}/idle`, true], |
| 27 | ]) { |
| 28 | const page = await context.newPage(); |
| 29 | await page.goto(`http://127.0.0.1:${port}/busy`); |
| 30 | const viewer = await context.newCDPSession(page); |
| 31 | await new Promise((r) => setTimeout(r, 600)); |
| 32 | const still = viewer.send("Page.captureScreenshot", { format: "jpeg" }); |
| 33 | await new Promise((r) => setTimeout(r, 500)); |
| 34 | await page.goto(target); |
| 35 | if (detach) setTimeout(() => viewer.detach().catch(() => {}), 8000); |
| 36 | console.log(`${label.padEnd(24)} pending capture: ${await settle(still, 30000)}`); |
| 37 | const fresh = await context.newCDPSession(page); |
| 38 | console.log(`${"".padEnd(24)} new capture on new page: ${await settle(fresh.send("Page.captureScreenshot"), 5000)}`); |
| 39 | await page.close(); |
| 40 | } |
| 41 | await browser.close(); |
| 42 | server.close(); |
| 43 | })(); |
How it wedges the tab (main @ 4f0b74e190)
pushStill(ServerBrowser.ts:2625) awaitscaptureViewport(ServerBrowserPage.ts:469) insidewithCaptureLock. The capture has no deadline.- The capture waits while page script is busy. On a heavy page, this window is several seconds wide.
- A navigation to a different site in that window loses the capture. The agent's
preview_navigate, a click on an off-site link, or a redirect can cause it. - The next
preview_snapshotwaits for the lock insideSessionControl.agent, before its 10 s bound starts. It never answers. - Every later action on the tab queues behind that snapshot. The deadline check runs only when an entry starts, so these entries never start. Each one costs its full timeout plus 1 s, and a host eviction. Only
preview_status,preview_dialogandt3_preview_closestill work.
pushStill runs when a viewer attaches before its first frame. It also runs 200 ms after screencast motion or a dropped frame. So the trigger needs a viewer, a busy page and a navigation to another site at the same time. This explains why the failure is intermittent, and why it hits one thread's tab while other tabs keep working.
Field evidence
Host: desktop app 0.1.6-nightly.20261031.3977 with the headless server browser, macOS 27.0 arm64, Codex agent. Provider log, UTC start times:
| 1 | 2026-11-09, tab_11, one thread |
| 2 | 13:32:47 preview_navigate 967 ms ok, visible:false |
| 3 | 13:33:04 preview_snapshot 8,026 ms "The page was too busy to capture a snapshot in time" (bot-check page) |
| 4 | 13:33:18 preview_evaluate 23 ms ok |
| 5 | 13:33:28 preview_navigate 832 ms ok, to a different site, visible:true (a viewer attached since the last navigate) |
| 6 | 13:33:34 preview_snapshot 16,003 ms timed out, host evicted |
| 7 | 13:33:54 preview_evaluate 16,009 ms timed out, host evicted |
| 8 | 13:34:57 preview_navigate 21,010 ms timed out, host evicted |
| 9 | tab_12 in another thread worked normally on the same host the whole time |
Two more wedges on 2026-11-06 follow the same pattern: a snapshot with no answer, then evaluate and navigate with no answer, preview_status instant, and a new tab that works at once. One of them had visible: true. The logs for those two do not show the navigation.
For the open fixes
The lost request never settles by itself. A bound on captureViewport, as in #23515 and #25862, covers this trigger. Without a bound, only a session detach or a tab close frees the lock. The viewer's finalizer detaches its session, so a viewer that disconnects also frees a wedged tab. I did not test that path in the app.
Investigated with Claude Opus 5.5 in T3 Code.
Note
Posted by Claude Code (Fable 5.2) on behalf of the account owner.
Still reproduces on desktop Nightly 0.1.9-nightly.20261110.4478 (macOS 26.6.2, desktop app host, iOS viewer over T3 Connect), with a trigger that links this to #23139.
- An agent opened a tab while the host window was off screen, and a phone attached as a viewer. The phone stayed on "Connecting to browser…".
- The tab stayed stuck for over two hours. That included time when the host window was on screen and the tab was showing in the desktop browser panel.
- During that time,
Page.captureScreenshotsent straight to the same guest over the DevTools port answered in 26 ms. So the renderer was healthy and only the tab's viewer path was wedged. - A second tab opened in the same thread streamed to the phone normally.
So one capture that hangs because of #23139 leaves the tab unusable for viewers until it is closed, even after the window is visible again.