[Bug]: Background viewer capture can hold captureLock and block agent calls past the 10s snapshot deadline (nightly 3457)#23022

opened16d agobySsankalpmukim

Summary

The 10-second snapshot deadline does not cover the full server browser capture path. A remote viewer's still capture can hold tab.captureLock, so an agent snapshot waits before its own timer starts. The snapshot then holds the tab's agent queue. Later calls time out, and the shared-host timeout policy can fail another thread's call too.

This still occurred on 0.1.2-nightly.20261021.3457, installed commit e64766c56ac6fc1678e6107052feb0047a35337f, on macOS arm64. The installed commit includes the 10-second inner snapshot deadline from #20446.

A controlled local test used Electron 44.4.5 and the exact relay, snapshot, capture-lock, and session-control functions from the installed bundles. A small static page was enough. Moving its guest view off-screen left Page.captureScreenshot pending while document.title still worked. Moving the same guest back inside the window released the pending screenshot and queued work.

The original incidents were in two Bob/ACP threads using Slack. The reproduction does not use Slack, credentials, workspace files, or a provider model. This is not a claim that Slack's whole renderer froze or that every recorded timeout has the same cause.

Before submitting and scope

  • Searched existing issues and read the related reports and maintainer replies.
  • Included the installed build, source links, reproduction, measured results, original trace evidence, and limits.
  • This is the remaining background-capture fault, not a request to reopen all of #16567. In the closing comment on 21 October, a maintainer said that the underlying screenshot capture remained unbounded and asked for a new issue for that part.
  • #16921 already tracks shared-host eviction. That behavior is included here only to explain the impact of the stalled capture; it is not presented as a newly discovered independent bug.
  • #21534 is an existing capture-fix proposal. Its head was not checked out or tested. This report is evidence against the installed official build, not validation or rejection of that PR.
  • A separate tab-ownership failure after Bob's idle stop is reported in https://github.com/pingdotgg/t3code/issues/23021. It does not explain the screenshot stall and is not part of the requested fix here.

Area and impact

  • Area: apps/server capture/queue handling, with the apps/desktop / apps/web native webview path as the frame source.
  • Impact: major degradation and frequent failure. A loaded page can become unusable for agent actions. Retries spend 16–31 seconds each without clearing the blocked work. An overlapping request in another thread can fail before its own deadline.
  • Expected: a snapshot either completes or fails within a bound that includes lock wait and viewer preparation. A stalled read must not keep later same-tab work blocked. Background desktop capture must request a usable frame. Unrelated calls should not be cancelled by one tab's stalled capture.
  • Actual: both recoverable 10-second inner snapshot failures and 16/31-second broker-deadline failures occur. In the latter case, the inner snapshot timer may not have started at all.

Verified environment

ComponentValue
Official appT3 Code Nightly 0.1.2-nightly.20261021.3457
Installed commite64766c56ac6fc1678e6107052feb0047a35337f from app.asar/package.json
OSmacOS 15.8.1, build 24H32, arm64
Electron44.4.5
Electron Chromium152.0.7977.130
Electron Node24.21.0
Bundled Playwright1.60.0
Original providerBob 2.0.8, acpRegistry_bob
Original clientsDesktop app with its local server; web/mobile access to that environment
Isolated testSeparate Electron process and profile, real <webview>, loopback-only static HTML

The installed app and its live database were not patched, replaced, or restarted. A source copy was checked out at the installed commit and left unchanged. The database was backed up through SQLite and the backup was inspected read-only. No Slack operation was performed during the diagnostic tests.

Reproduction

A. Product-level sequence

This describes the supported route to the failure. Use a disposable local page and explicit tab IDs. A remote viewer is important for the capture-lock variant.

  1. Run the official macOS desktop app and local environment.
  2. Open a local static page in a fresh agent-owned browser tab. Keep the tab inactive in the desktop app, for example by selecting another thread or another browser tab.
  3. Read document.title with preview_evaluate on the explicit tab. It can succeed.
  4. Call preview_snapshot on that tab. Without an earlier blocked viewer capture, it can return the 10-second internal timeout, displayed as a 15-second timeout.
  5. From a web/mobile client, open the remote preview of that same inactive desktop-backed tab. This starts a viewer screencast and its initial still-image path.
  6. If the guest cannot provide a frame, the viewer still capture stays pending while holding captureLock.
  7. Call preview_snapshot again. It now waits for captureLock before ServerBrowserPage.snapshot is entered. Follow it with a trivial evaluation or navigation on that tab.
  8. The snapshot and later queued calls reach the broker deadlines. Another overlapping request on server-browser can receive PreviewAutomationClientDisconnectedError when the host is evicted.

These product steps are supported by the original logs. A new end-to-end packaged-app reproduction through the MCP service was not completed during this investigation. The controlled proof below tests the exact installed capture/queue functions in a separate real Electron app.

B. Controlled exact-code reproduction

The script below reads functions from the installed ASAR without changing it. It uses the installed Playwright package over a loopback WebSocket and the installed CDP relay. It does not launch T3's server against the real T3 home.

It tests these stages separately:

  1. Visible guest snapshot.
  2. Same guest moved to the stock inactive macOS placement: left/top: -100000px, visibility: visible, backgroundThrottling: false.
  3. Direct title evaluation and screenshot capture.
  4. Direct call to the installed snapshot function, including its real 10-second timer.
  5. A viewer-style still capture under the installed withCaptureLock.
  6. A snapshot under the installed withScreencastsPaused and SessionControl.agent.
  7. A queued evaluation versus a direct evaluation.
  8. Move the same guest to 0,0, behind the app, and await the original pending work.

The 12-second and 4-second observation limits in the fixture are test limits, not T3's tool budgets. They do not cancel the pending work. The test then makes the guest paintable and observes that same work complete. This is intentional: it distinguishes a blocked capture from a dead renderer.

Run in a fresh diagnostic folder, not in the installed application or T3 home:

sh
1npm install --no-save electron@44.4.5
2# Save the script below as offscreen-capture-repro.cjs.
3# Change T3_APP_ASAR only if the official app is in a different location.
4env -u ELECTRON_RUN_AS_NODE \
5 T3_APP_ASAR='/Applications/T3 Code (Nightly).app/Contents/Resources/app.asar' \
6 ./node_modules/.bin/electron offscreen-capture-repro.cjs

The script assumes the bundle layout of the reported build and fails if those markers change. It creates a new diagnostic profile and method-only log in a temporary folder. It never uses the installed app's browser profile.

Complete tested reproducer
javascript
1// Run with Electron 44.4.5, outside the installed T3 app.
2// Reads code from the installed ASAR. Uses only a local synthetic page and a new profile.
3const { app, BrowserWindow } = require('electron');
4const fs = require('node:fs');
5const os = require('node:os');
6const path = require('node:path');
7const http = require('node:http');
8const crypto = require('node:crypto');
9
10const asar = process.env.T3_APP_ASAR || '/Applications/T3 Code (Nightly).app/Contents/Resources/app.asar';
11const directory = process.env.T3_DIAG_DIR || fs.mkdtempSync(path.join(os.tmpdir(), 't3-capture-repro-'));
12fs.mkdirSync(directory, { recursive: true });
13app.setPath('userData', path.join(directory, 'profile'));
14const playwrightPath = path.join(asar, 'node_modules/playwright-core');
15const { chromium } = require(playwrightPath);
16const { wsServer } = require(path.join(playwrightPath, 'lib/utilsBundle.js'));
17const log = (event, data = {}) => {
18 const row = { at: new Date().toISOString(), event, ...data };
19 fs.appendFileSync(path.join(directory, 'probe.ndjson'), JSON.stringify(row) + '\n');
20 if (!event.startsWith('cdp.')) console.log(JSON.stringify(row));
21};
22const desktop = fs.readFileSync(path.join(asar, 'apps/desktop/dist-electron/main.cjs'), 'utf8');
23const serverDirectory = path.join(asar, 'apps/server/dist');
24const serverFile = fs.readdirSync(serverDirectory).find(name => /^binCli-.*\.mjs$/.test(name));
25if (!serverFile) throw new Error('Cannot find the installed server bundle.');
26const server = fs.readFileSync(path.join(serverDirectory, serverFile), 'utf8');
27function region(source, name) {
28 const body = source.split(`//#region ${name}\n`)[1]?.split('//#endregion')[0];
29 if (!body) throw new Error(`Bundle layout changed: ${name}`);
30 return body;
31}
32const createRelay = new Function(region(desktop, 'src/preview/CdpRelay.ts') + '\nreturn createCdpRelayConnection;')();
33const operations = new Function('NodeCrypto', 'BrowserControlInterrupted', 'constVoid', 'PROVIDER_SEND_TURN_MAX_FILE_BYTES',
34 region(server, 'src/preview/ServerBrowserPage.ts') + '\nreturn {snapshot, captureViewport, evaluate};')(
35 crypto, class extends Error {}, () => {}, 50 * 1024 * 1024);
36const SessionControl = new Function(region(server, 'src/preview/SessionControl.ts') + '\nreturn SessionControl;')();
37const lockSource = server.split('\tconst withCaptureLock = ')[1]?.split('\n\tconst startRecording')[0];
38const pausedSource = server.split('\tconst withScreencastsPaused = ')[1]?.split('\n\tconst stopRecording')[0];
39if (!lockSource || !pausedSource) throw new Error('Capture helper names changed in this bundle.');
40const withCaptureLock = new Function('constVoid', 'return ' + lockSource)(() => {});
41const withScreencastsPaused = new Function('withCaptureLock', 'constVoid', 'return ' + pausedSource)(withCaptureLock, () => {});
42
43async function bounded(label, work, limit = 12000) {
44 let timer;
45 const start = performance.now();
46 try {
47 const result = await Promise.race([work(), new Promise((_, reject) => {
48 timer = setTimeout(() => reject(new Error('probe deadline')), limit);
49 })]);
50 const summary = result?.accessibilityTree !== undefined
51 ? { treeLength: result.accessibilityTree.length, screenshotBase64Characters: result.screenshot?.data?.length }
52 : typeof result === 'string' && result.length < 100 ? result : undefined;
53 log(label, { ok: true, ms: Math.round(performance.now() - start), result: summary });
54 return result;
55 } catch (error) {
56 log(label, { ok: false, ms: Math.round(performance.now() - start), error: String(error) });
57 } finally { clearTimeout(timer); }
58}
59
60app.whenReady().then(async () => {
61 log('runtime', { electron: process.versions.electron, chrome: process.versions.chrome, node: process.versions.node });
62 const origin = http.createServer((_, response) => {
63 response.setHeader('Content-Type', 'text/html');
64 response.end('<title>Browser probe</title><h1>Browser probe</h1><button>Test button</button><input type="password" value="synthetic-test-only">');
65 });
66 await new Promise(resolve => origin.listen(0, '127.0.0.1', resolve));
67 const win = new BrowserWindow({ width: 1200, height: 900, show: true,
68 webPreferences: { webviewTag: true, nodeIntegration: true, contextIsolation: false, backgroundThrottling: false } });
69 const attached = new Promise(resolve => win.webContents.once('did-attach-webview', (_, guest) => resolve(guest)));
70 await win.loadURL('data:text/html,' + encodeURIComponent('<body style="margin:0"><div id="wrapper"><webview style="width:1100px;height:800px" webpreferences="backgroundThrottling=no" src="about:blank"></webview></div>'));
71 const guest = await attached;
72 guest.debugger.attach('1.3');
73 await guest.debugger.sendCommand('Emulation.setDefaultBackgroundColorOverride', { color: { r:255,g:255,b:255,a:1 } });
74 const socketServer = new wsServer({ port: 0, host: '127.0.0.1' });
75 await new Promise(resolve => socketServer.once('listening', resolve));
76 socketServer.on('connection', socket => {
77 let sequence = 0;
78 const relay = createRelay({
79 targetId: async () => (await guest.debugger.sendCommand('Target.getTargetInfo')).targetInfo.targetId,
80 title: () => guest.getTitle(), url: () => guest.getURL(), userAgent: () => guest.getUserAgent(),
81 setDownloadDirectory: () => {},
82 send: async (method, params, sessionID) => {
83 const id = ++sequence;
84 log('cdp.start', { id, method, sessionID });
85 try {
86 const result = await guest.debugger.sendCommand(method, params, sessionID);
87 log('cdp.end', { id, method, sessionID });
88 return result;
89 } catch (error) {
90 log('cdp.error', { id, method, sessionID, error: String(error) });
91 throw error;
92 }
93 },
94 }, raw => { if (socket.readyState === 1) socket.send(raw); });
95 socket.on('message', raw => relay.receive(String(raw)));
96 const events = (_, method, params, sessionID) => relay.event(method, params, sessionID);
97 guest.debugger.on('message', events);
98 socket.on('close', () => guest.debugger.off('message', events));
99 });
100 const browser = await chromium.connectOverCDP(`ws://127.0.0.1:${socketServer.address().port}`);
101 const page = browser.contexts()[0].pages()[0];
102 const cdp = await page.context().newCDPSession(page);
103 await page.goto(`http://127.0.0.1:${origin.address().port}/`, { waitUntil: 'load' });
104 const snapshot = () => operations.snapshot({ page, cdp, renderScale: 'display', consoleEntries: [], networkEntries: [], actionTimeline: [] });
105 await bounded('visible.snapshot', snapshot);
106 const position = async offset => win.webContents.executeJavaScript(
107 `document.querySelector('#wrapper').style.cssText='position:fixed;left:${offset}px;top:${offset}px;width:1100px;height:800px;z-index:-1;pointer-events:none;visibility:visible'`);
108 await position(-100000);
109 await bounded('offscreen.evaluate', () => operations.evaluate(cdp, { expression: 'document.title' }, 1000));
110 await bounded('offscreen.capture', () => operations.captureViewport(page, cdp, { format:'png', scale:0.5, viewport: {width:1100,height:800} }));
111 await bounded('offscreen.snapshot', snapshot);
112 const tab = { page, cdp, captureLock: Promise.resolve(), capturing: 0, viewers: new Set(), recording: null, closing: false,
113 control: new SessionControl('probe') };
114 withCaptureLock(tab, () => operations.captureViewport(page, cdp, { format:'jpeg', quality:80, scale:1 })).catch(() => {});
115 const queuedSnapshot = tab.control.agent('probe', () => withScreencastsPaused(tab, snapshot));
116 queuedSnapshot.catch(() => {});
117 await bounded('queued.snapshot.behind.viewer.still', () => queuedSnapshot, 4000);
118 await bounded('queued.evaluate.after.snapshot', () => tab.control.agent('probe', () => operations.evaluate(cdp, {expression:'document.title'}, 1000)), 4000);
119 await bounded('direct.evaluate.while.queue.blocked', () => operations.evaluate(cdp, {expression:'document.title'}, 1000));
120 await position(0);
121 await bounded('queued.snapshot.after.paintable', () => queuedSnapshot);
122 await bounded('in-window.behind-app.snapshot', snapshot);
123 await bounded('queued.evaluate.after.paintable', () => tab.control.agent('probe', () => operations.evaluate(cdp, {expression:'document.title'}, 1000)));
124 await browser.close();
125 socketServer.close();
126 origin.close();
127 win.destroy();
128 app.quit();
129}).catch(error => { log('fatal', { error: String(error) }); app.exit(1); });

Measured result of that published script

This is a fresh run of the script above, not only the earlier private diagnostic. Times are from 21 October 2026, 18:08:57–18:09:27 UTC.

CheckResult
Visible snapshotSuccess, 58 ms
Off-screen direct title evaluationSuccess, 2 ms
Off-screen Page.captureScreenshotStill pending at 12,003 ms observation limit
Installed direct snapshot functiontooBusy() after 10,002 ms
Snapshot behind viewer-style still captureStill blocked at 4,003 ms observation limit
Evaluation queued after that snapshotStill blocked at 4,002 ms observation limit
Direct evaluation while the queue is blockedSuccess, 6 ms
Move same guest to 0,0; original queued snapshotCompletes 92 ms after the move
New snapshot with guest inside the window, behind the appSuccess, 30 ms
Queued evaluation after painting resumesSuccess, 2 ms

The successful captures returned nonempty PNG data and a 269-character accessibility tree. The fixture records the image field's base64 character count, not decoded PNG byte length. This is a frame-availability and queue-progress test, not a pixel-fidelity or performance benchmark.

The earlier exact-code run independently gave visible snapshot 50 ms, off-screen title read 2 ms, direct read while the queue was blocked 3 ms, and a new in-window snapshot 52 ms.

Source analysis at the installed commit

All links below are pinned to the installed revision, not a moving main.

1. Background placement does not supply frames

hostedBrowserWebviewStyle.ts already documents the constraint:

ts
1// Electron stops compositing a guest that is fully outside the window, even
2// when background throttling is disabled. Keep capture-active guests inside
3// the viewport but behind the app so recordings receive complete frames.

Its inactive macOS state is at -100000,-100000 with visibility: visible. Its renderingActive state instead uses 0,0, behind the app.

HostedBrowserWebview.tsx:296-306 computes:

ts
1const renderingActive = active || backgroundActivity || pictureInPicture || recordingActive;

In this revision, the only production caller of acquireBrowserSurfaceActivity is the local recording path at browserRecording.ts:537. The server's CDP screenshot and remote-viewer paths do not acquire that local activity state.

The fixture copies the inactive and rendering-active coordinates. It does not mount the production React component or test a new activity-lease implementation.

2. The inner snapshot deadline does not cancel the CDP capture

ServerBrowserPage.captureViewport:473-504 awaits a bare CDP call:

ts
1const { data } = await cdp.send("Page.captureScreenshot", { ... });

snapshot:615-640 uses a 10-second Promise.race. That bounds the snapshot function after it starts, but does not cancel the pending screenshot command. The snapshot's capture branch combines page evaluation and screenshot work at 550-582.

This report does not say #20446's inner deadline never works. It works in the direct snapshot fixture and matches several historical 10-second errors.

3. A viewer still capture can hold a different, unbounded lock

withCaptureLock:1460-1466:

ts
1const run = tab.captureLock.then(() => {
2 if (tab.closing) throw new Error("The preview tab closed.");
3 return operation();
4});
5tab.captureLock = run.then(constVoid, constVoid);
6return run;

attachViewer / pushStill:2346-2355 calls captureViewport under this lock, without an operation deadline. 2407-2411 awaits that still when a new screencast has not produced a frame. Later settle-frame handling can request another still too.

withScreencastsPaused:1533-1553 must obtain the same lock before calling the snapshot function. Its pause and resume awaits are also outside the inner snapshot timer.

Thus, a snapshot waiting behind a viewer still never reaches the code that starts the 10-second clock.

4. Agent serialization turns the capture stall into later action failures

The snapshot is itself inside the agent action at ServerBrowser.ts:1912-1932, and 1970-1976 calls withScreencastsPaused.

SessionControl.ts:44-50 serializes actions with a Promise tail. 76-90 checks a request's deadline after the queued action can start. This correctly prevents expired queued work from starting late, but does not release work that is already waiting inside a capture.

That explains the fixture's key split: a queued document.title read does not start, while a direct read over the same CDP connection still works in milliseconds.

5. The broker timeout is a separate clock and failure scope

PreviewAutomationBroker.ts:645-660 waits for the request budget plus 1 second of host-response grace. If no response arrives, it disconnects the host connection.

closeConnection / disconnect:354-386 fails the connection's other pending requests. All these tabs share server-browser.

This is the behavior already tracked in #16921. A host reconnect does not resolve a pending JavaScript capture lock on the old tab. It is not evidence of an Electron renderer crash.

Original incident evidence

Two real threads are called A and B below. User text, private URLs, local paths, environment/session UUIDs, and credentials are omitted. Tab suffixes are also omitted. All times are UTC on 21 October 2026.

Distinct errors, not repeated text matches

The event store contained 23 distinct timeout tool results and 2 distinct browser-disconnect tool results across these two threads. Deduplication used tool-item identity. ACP session/load replay records were not counted as new tool executions.

  • 17 timeouts reached the outer broker deadline with no host response.
  • 4 were the inner snapshot tooBusy() failure at about 10 seconds.
  • 1 was an evaluated Promise that did not settle; the retained cause says the evaluation was stopped at 15 seconds.
  • 1 was a locator operation whose retained cause is locator.scrollIntoViewIfNeeded: Timeout 15000ms exceeded.

The last two are not proof of this capture fault. Neither are the separately observed invalid-schema, tab-ownership, or local-file access errors. They are excluded from the root-cause claim.

All 25 timeout/disconnect spans from the two threads

These are broker invoke start times and span durations, not tool completion timestamps. Remote-error and no-response timeout paths are kept separate.

Invoke start, UTCOperationDuration, msFailure path
17:26:42.571snapshot10,004.472Inner snapshot deadline: tooBusy
17:26:56.061snapshot10,003.087Inner snapshot deadline: tooBusy
17:27:14.841snapshot10,009.094Inner snapshot deadline: tooBusy
17:27:32.899snapshot16,003.629No host response; outer broker deadline
17:27:52.811waitFor31,004.828No host response; outer broker deadline
17:28:31.770evaluate16,003.628No host response; outer broker deadline
17:28:59.342snapshot16,003.940No host response; outer broker deadline
17:29:19.476navigate31,003.804No host response; outer broker deadline
17:30:03.120evaluate16,003.831No host response; outer broker deadline
17:30:23.862click16,004.343No host response; outer broker deadline
17:30:54.092navigate31,002.476No host response; outer broker deadline
17:31:33.244snapshot16,002.654No host response; outer broker deadline
17:32:15.409snapshot16,003.949No host response; outer broker deadline
17:32:39.759waitFor21,002.701No host response; outer broker deadline
17:33:08.942snapshot16,000.608No host response; outer broker deadline
17:33:30.490snapshot16,002.056No host response; outer broker deadline
17:33:33.564snapshot12,928.862Shared host disconnected before this call's deadline
17:33:50.312press16,004.329No host response; outer broker deadline
17:33:50.882snapshot15,435.335Shared host disconnected before this call's deadline
17:34:10.882navigate31,001.176No host response; outer broker deadline
17:34:19.572snapshot10,002.629Inner snapshot deadline: tooBusy
17:34:50.419evaluate16,002.009No host response; outer broker deadline
17:35:17.303evaluate15,002.131Evaluation deadline: script stopped
17:36:52.995click15,001.619Locator scrollIntoViewIfNeeded timeout
17:37:22.986click16,003.811No host response; outer broker deadline

Viewer startup spans match the lock-wait transition

The long numbers below are ServerBrowser.attachViewer startup spans, not merely the normal lifetime of an already attached stream. The corresponding stream route contains the same thread/tab. The working control's startup span is short while its stream can remain open.

Thread / tabAttach startStartup durationWhat ended the wait
A / tab_317:27:24.459about 203 sTab closes at about 17:30:47
A / tab_417:28:53.592about 599 sTab closes at about 17:38:53
B / tab_517:32:03.959about 187 sTab closes at about 17:35:10
B / fresh tab_717:35:16.526about 15 msStartup completes; later actions work

Before the first long viewer startup, A's snapshots at 17:26:42, 17:26:56, and 17:27:14 reached the inner 10-second deadline. After viewer attachment began at 17:27:24, A's next snapshot at 17:27:32.899 instead reached the outer 16-second deadline. This is the transition reproduced by placing a pending viewer still before the agent snapshot.

Two exact cross-thread failures

text
117:33:30.490 Thread B snapshot begins; its 15s budget + 1s grace expires at ~17:33:46.492
217:33:33.564 Thread A snapshot begins
317:33:46.493 Thread A snapshot fails after 12,928.862 ms: server-browser disconnected
4
517:33:50.313 Thread B press begins; its 15s budget + 1s grace expires at ~17:34:06.317
617:33:50.882 Thread A snapshot begins
717:34:06.318 Thread A snapshot fails after 15,435.335 ms: server-browser disconnected

In both cases, A's request fails when B evicts the shared host, not when A reaches its own broker deadline. Server disconnect/connect spans show re-registration about one second later. That does not clear the same-tab capture wait.

Representative errors

text
1Preview snapshot failed: Preview automation snapshot timed out after 15000ms.
2Preview automation evaluate timed out after 15000ms.
3Preview automation navigate timed out after 30000ms.
4Preview snapshot failed: Preview automation client server-browser disconnected during snapshot.

The server trace preserves this cause for the 10-second snapshot failures:

text
1The page was too busy to capture a snapshot in time. Wait for it to settle and take another snapshot.

The renderer history shows normal guest creation/DOM-ready events and destruction when the tabs close. It does not show a renderer crash for these failed tabs. It also does not prove that all original DOM or compositor stages were healthy.

Why the displayed 15 seconds can mean 10 seconds

This is relevant to triage, not a request for a separate timeout-text cleanup in this issue.

PreviewAutomationTimeoutError.message prints the outer timeoutMs. classifyResponseError retains the remote cause, but the summary does not show it.

Therefore the same user-facing error can mean:

  1. The snapshot function started and returned its 10-second inner error; or
  2. The host never answered within 15 seconds + 1 second; lock wait is one cause.

The actual spans and retained cause distinguish them. The text alone does not. Calling a synthetic static page “too busy” also points agents toward the wrong explanation if the missing frame is the real blocker.

includeImage:false does not bypass this path. The MCP handler removes that response-selection field, and the server still performs screenshot capture.

Confidence and limits

Directly demonstrated:

  • A real off-screen Electron 44.4.5 guest leaves Page.captureScreenshot pending while direct JavaScript works.
  • The installed snapshot function's 10-second timer works after that function starts.
  • The installed viewer-style capture lock can block entry into that function.
  • The installed action queue then blocks a later agent evaluation, while a direct CDP evaluation works.
  • Moving the same guest inside the window releases the pending capture and queued snapshot.
  • The original two threads have both timeout paths, long viewer startup spans, and two cross-thread host-disconnect failures.

Not demonstrated:

  • The original historical logs do not record each view's CSS position or each CDP command. Attribution of the original initial stalls to the off-screen path is high-confidence inference, not a direct historical frame trace.
  • The fixture simulates the viewer's still-capture call using the exact helper. It does not exercise the full mobile transport, production React tree, provider, or MCP authorization layer.
  • Its viewers set is empty for the queue proof; it does not measure delays in actual viewer pause/resume or multi-viewer races. Those waits remain outside the inner timer in source.
  • The fixture parent window is shown. It is not a test of an entirely hidden, minimized, locked-screen, or sleeping desktop.
  • No fix branch or PR head was tested. A few successful captures are not a reliability benchmark or full packaged-app acceptance test.
  • No claim is made that all 23 historical timeouts share this cause, or that every fresh tab stays on the same rendering engine.

Suggested acceptance checks

These are desired properties, not an implemented patch:

  1. Acquire and acknowledge a scoped desktop paint/capture state for server snapshots and remote viewers. Release it on completion, failure, disconnect, and tab close.
  2. Bound the full operation: capture-lock wait, viewer pause, screenshot/still capture, and viewer resume. The bound must start before lock acquisition.
  3. Cancel or safely retire native/CDP work on expiry. A Promise.race alone does not release the underlying screenshot or guarantee safe queue progress.
  4. Verify that a stalled viewer still cannot hold captureLock forever, including when its client disconnects during startup.
  5. On a capture failure, preserve the page/session and allow a later safe same-tab read or reveal. Do not replay clicks or other uncertain mutations automatically.
  6. Test default and incognito profiles, no viewer / one viewer / multiple viewers, background desktop tabs, and the no-desktop headless path separately.
  7. Preserve password masking and stale-ref safety. A cancelled late snapshot must not invalidate or replace the next snapshot's refs.
  8. Keep shared-host isolation work linked to #16921 rather than treating host reconnect as capture recovery.

Workaround

Keep the target browser view inside the desktop app's visible panel before capture. If it is already blocked, making the same guest paintable can release the pending work; closing it and opening a fresh tab is another option if ownership permits it. A tool's returned visible field is not a reliable measurement of physical desktop visibility because it counts stream viewers.

This is a limited workaround. In the isolated test, the in-window, behind-app placement works. It is not a guarantee for minimized/hidden windows. Increasing tool timeouts or requesting includeImage:false does not solve a frame that is not being produced.

Privacy and investigation method

Raw transcripts, database backups, browser-profile data, Slack content, credentials, local personal paths, remote access tickets, and private URLs are not attached. The public script uses only synthetic data. The report retains technical version data, method names, durations, and sanitized sequence evidence.

Investigated and drafted with OpenAI GPT-6.1 Sol through OpenCode in T3 Code, at the user's request. No application fix is claimed.