Repository navigation
[Bug]: Background viewer capture can hold captureLock and block agent calls past the 10s snapshot deadline (nightly 3457) #23022
Description
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 preview_snapshot on a background tab never returns and blocks every later action on that tab #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.
- [Bug]: Windows snapshot/evaluate timeouts evict the shared server-browser host and abort another open #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.
- fix(desktop): capture fresh agent preview frames in background tabs #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 [Bug]: Resumed Bob thread cannot navigate or close its own preview tab after the 30-minute idle stop #23021. It does not explain the screenshot stall and is not part of the requested fix here.
Area and impact
- Area:
apps/servercapture/queue handling, with theapps/desktop/apps/webnative 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
| Component | Value |
|---|---|
| Official app | T3 Code Nightly 0.1.2-nightly.20261021.3457 |
| Installed commit | e64766c56ac6fc1678e6107052feb0047a35337f from app.asar/package.json |
| OS | macOS 15.8.1, build 24H32, arm64 |
| Electron | 44.4.5 |
| Electron Chromium | 152.0.7977.130 |
| Electron Node | 24.21.0 |
| Bundled Playwright | 1.60.0 |
| Original provider | Bob 2.0.8, acpRegistry_bob |
| Original clients | Desktop app with its local server; web/mobile access to that environment |
| Isolated test | Separate 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.
- Run the official macOS desktop app and local environment.
- 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.
- Read
document.titlewithpreview_evaluateon the explicit tab. It can succeed. - Call
preview_snapshoton that tab. Without an earlier blocked viewer capture, it can return the 10-second internal timeout, displayed as a 15-second timeout. - 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.
- If the guest cannot provide a frame, the viewer still capture stays pending while holding
captureLock. - Call
preview_snapshotagain. It now waits forcaptureLockbeforeServerBrowserPage.snapshotis entered. Follow it with a trivial evaluation or navigation on that tab. - The snapshot and later queued calls reach the broker deadlines. Another overlapping request on
server-browsercan receivePreviewAutomationClientDisconnectedErrorwhen 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:
- Visible guest snapshot.
- Same guest moved to the stock inactive macOS placement:
left/top: -100000px,visibility: visible,backgroundThrottling: false. - Direct title evaluation and screenshot capture.
- Direct call to the installed snapshot function, including its real 10-second timer.
- A viewer-style still capture under the installed
withCaptureLock. - A snapshot under the installed
withScreencastsPausedandSessionControl.agent. - A queued evaluation versus a direct evaluation.
- 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:
npm install --no-save electron@44.4.5
# Save the script below as offscreen-capture-repro.cjs.
# Change T3_APP_ASAR only if the official app is in a different location.
env -u ELECTRON_RUN_AS_NODE \
T3_APP_ASAR='/Applications/T3 Code (Nightly).app/Contents/Resources/app.asar' \
./node_modules/.bin/electron offscreen-capture-repro.cjsThe 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
// Run with Electron 44.4.5, outside the installed T3 app.
// Reads code from the installed ASAR. Uses only a local synthetic page and a new profile.
const { app, BrowserWindow } = require('electron');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const http = require('node:http');
const crypto = require('node:crypto');
const asar = process.env.T3_APP_ASAR || '/Applications/T3 Code (Nightly).app/Contents/Resources/app.asar';
const directory = process.env.T3_DIAG_DIR || fs.mkdtempSync(path.join(os.tmpdir(), 't3-capture-repro-'));
fs.mkdirSync(directory, { recursive: true });
app.setPath('userData', path.join(directory, 'profile'));
const playwrightPath = path.join(asar, 'node_modules/playwright-core');
const { chromium } = require(playwrightPath);
const { wsServer } = require(path.join(playwrightPath, 'lib/utilsBundle.js'));
const log = (event, data = {}) => {
const row = { at: new Date().toISOString(), event, ...data };
fs.appendFileSync(path.join(directory, 'probe.ndjson'), JSON.stringify(row) + '\n');
if (!event.startsWith('cdp.')) console.log(JSON.stringify(row));
};
const desktop = fs.readFileSync(path.join(asar, 'apps/desktop/dist-electron/main.cjs'), 'utf8');
const serverDirectory = path.join(asar, 'apps/server/dist');
const serverFile = fs.readdirSync(serverDirectory).find(name => /^binCli-.*\.mjs$/.test(name));
if (!serverFile) throw new Error('Cannot find the installed server bundle.');
const server = fs.readFileSync(path.join(serverDirectory, serverFile), 'utf8');
function region(source, name) {
const body = source.split(`//#region ${name}\n`)[1]?.split('//#endregion')[0];
if (!body) throw new Error(`Bundle layout changed: ${name}`);
return body;
}
const createRelay = new Function(region(desktop, 'src/preview/CdpRelay.ts') + '\nreturn createCdpRelayConnection;')();
const operations = new Function('NodeCrypto', 'BrowserControlInterrupted', 'constVoid', 'PROVIDER_SEND_TURN_MAX_FILE_BYTES',
region(server, 'src/preview/ServerBrowserPage.ts') + '\nreturn {snapshot, captureViewport, evaluate};')(
crypto, class extends Error {}, () => {}, 50 * 1024 * 1024);
const SessionControl = new Function(region(server, 'src/preview/SessionControl.ts') + '\nreturn SessionControl;')();
const lockSource = server.split('\tconst withCaptureLock = ')[1]?.split('\n\tconst startRecording')[0];
const pausedSource = server.split('\tconst withScreencastsPaused = ')[1]?.split('\n\tconst stopRecording')[0];
if (!lockSource || !pausedSource) throw new Error('Capture helper names changed in this bundle.');
const withCaptureLock = new Function('constVoid', 'return ' + lockSource)(() => {});
const withScreencastsPaused = new Function('withCaptureLock', 'constVoid', 'return ' + pausedSource)(withCaptureLock, () => {});
async function bounded(label, work, limit = 12000) {
let timer;
const start = performance.now();
try {
const result = await Promise.race([work(), new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error('probe deadline')), limit);
})]);
const summary = result?.accessibilityTree !== undefined
? { treeLength: result.accessibilityTree.length, screenshotBase64Characters: result.screenshot?.data?.length }
: typeof result === 'string' && result.length < 100 ? result : undefined;
log(label, { ok: true, ms: Math.round(performance.now() - start), result: summary });
return result;
} catch (error) {
log(label, { ok: false, ms: Math.round(performance.now() - start), error: String(error) });
} finally { clearTimeout(timer); }
}
app.whenReady().then(async () => {
log('runtime', { electron: process.versions.electron, chrome: process.versions.chrome, node: process.versions.node });
const origin = http.createServer((_, response) => {
response.setHeader('Content-Type', 'text/html');
response.end('<title>Browser probe</title><h1>Browser probe</h1><button>Test button</button><input type="password" value="synthetic-test-only">');
});
await new Promise(resolve => origin.listen(0, '127.0.0.1', resolve));
const win = new BrowserWindow({ width: 1200, height: 900, show: true,
webPreferences: { webviewTag: true, nodeIntegration: true, contextIsolation: false, backgroundThrottling: false } });
const attached = new Promise(resolve => win.webContents.once('did-attach-webview', (_, guest) => resolve(guest)));
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>'));
const guest = await attached;
guest.debugger.attach('1.3');
await guest.debugger.sendCommand('Emulation.setDefaultBackgroundColorOverride', { color: { r:255,g:255,b:255,a:1 } });
const socketServer = new wsServer({ port: 0, host: '127.0.0.1' });
await new Promise(resolve => socketServer.once('listening', resolve));
socketServer.on('connection', socket => {
let sequence = 0;
const relay = createRelay({
targetId: async () => (await guest.debugger.sendCommand('Target.getTargetInfo')).targetInfo.targetId,
title: () => guest.getTitle(), url: () => guest.getURL(), userAgent: () => guest.getUserAgent(),
setDownloadDirectory: () => {},
send: async (method, params, sessionID) => {
const id = ++sequence;
log('cdp.start', { id, method, sessionID });
try {
const result = await guest.debugger.sendCommand(method, params, sessionID);
log('cdp.end', { id, method, sessionID });
return result;
} catch (error) {
log('cdp.error', { id, method, sessionID, error: String(error) });
throw error;
}
},
}, raw => { if (socket.readyState === 1) socket.send(raw); });
socket.on('message', raw => relay.receive(String(raw)));
const events = (_, method, params, sessionID) => relay.event(method, params, sessionID);
guest.debugger.on('message', events);
socket.on('close', () => guest.debugger.off('message', events));
});
const browser = await chromium.connectOverCDP(`ws://127.0.0.1:${socketServer.address().port}`);
const page = browser.contexts()[0].pages()[0];
const cdp = await page.context().newCDPSession(page);
await page.goto(`http://127.0.0.1:${origin.address().port}/`, { waitUntil: 'load' });
const snapshot = () => operations.snapshot({ page, cdp, renderScale: 'display', consoleEntries: [], networkEntries: [], actionTimeline: [] });
await bounded('visible.snapshot', snapshot);
const position = async offset => win.webContents.executeJavaScript(
`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'`);
await position(-100000);
await bounded('offscreen.evaluate', () => operations.evaluate(cdp, { expression: 'document.title' }, 1000));
await bounded('offscreen.capture', () => operations.captureViewport(page, cdp, { format:'png', scale:0.5, viewport: {width:1100,height:800} }));
await bounded('offscreen.snapshot', snapshot);
const tab = { page, cdp, captureLock: Promise.resolve(), capturing: 0, viewers: new Set(), recording: null, closing: false,
control: new SessionControl('probe') };
withCaptureLock(tab, () => operations.captureViewport(page, cdp, { format:'jpeg', quality:80, scale:1 })).catch(() => {});
const queuedSnapshot = tab.control.agent('probe', () => withScreencastsPaused(tab, snapshot));
queuedSnapshot.catch(() => {});
await bounded('queued.snapshot.behind.viewer.still', () => queuedSnapshot, 4000);
await bounded('queued.evaluate.after.snapshot', () => tab.control.agent('probe', () => operations.evaluate(cdp, {expression:'document.title'}, 1000)), 4000);
await bounded('direct.evaluate.while.queue.blocked', () => operations.evaluate(cdp, {expression:'document.title'}, 1000));
await position(0);
await bounded('queued.snapshot.after.paintable', () => queuedSnapshot);
await bounded('in-window.behind-app.snapshot', snapshot);
await bounded('queued.evaluate.after.paintable', () => tab.control.agent('probe', () => operations.evaluate(cdp, {expression:'document.title'}, 1000)));
await browser.close();
socketServer.close();
origin.close();
win.destroy();
app.quit();
}).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.
| Check | Result |
|---|---|
| Visible snapshot | Success, 58 ms |
| Off-screen direct title evaluation | Success, 2 ms |
Off-screen Page.captureScreenshot |
Still pending at 12,003 ms observation limit |
| Installed direct snapshot function | tooBusy() after 10,002 ms |
| Snapshot behind viewer-style still capture | Still blocked at 4,003 ms observation limit |
| Evaluation queued after that snapshot | Still blocked at 4,002 ms observation limit |
| Direct evaluation while the queue is blocked | Success, 6 ms |
Move same guest to 0,0; original queued snapshot |
Completes 92 ms after the move |
| New snapshot with guest inside the window, behind the app | Success, 30 ms |
| Queued evaluation after painting resumes | Success, 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:
// Electron stops compositing a guest that is fully outside the window, even
// when background throttling is disabled. Keep capture-active guests inside
// 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:
const 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:
const { 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
const run = tab.captureLock.then(() => {
if (tab.closing) throw new Error("The preview tab closed.");
return operation();
});
tab.captureLock = run.then(constVoid, constVoid);
return 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, UTC | Operation | Duration, ms | Failure path |
|---|---|---|---|
| 17:26:42.571 | snapshot |
10,004.472 | Inner snapshot deadline: tooBusy |
| 17:26:56.061 | snapshot |
10,003.087 | Inner snapshot deadline: tooBusy |
| 17:27:14.841 | snapshot |
10,009.094 | Inner snapshot deadline: tooBusy |
| 17:27:32.899 | snapshot |
16,003.629 | No host response; outer broker deadline |
| 17:27:52.811 | waitFor |
31,004.828 | No host response; outer broker deadline |
| 17:28:31.770 | evaluate |
16,003.628 | No host response; outer broker deadline |
| 17:28:59.342 | snapshot |
16,003.940 | No host response; outer broker deadline |
| 17:29:19.476 | navigate |
31,003.804 | No host response; outer broker deadline |
| 17:30:03.120 | evaluate |
16,003.831 | No host response; outer broker deadline |
| 17:30:23.862 | click |
16,004.343 | No host response; outer broker deadline |
| 17:30:54.092 | navigate |
31,002.476 | No host response; outer broker deadline |
| 17:31:33.244 | snapshot |
16,002.654 | No host response; outer broker deadline |
| 17:32:15.409 | snapshot |
16,003.949 | No host response; outer broker deadline |
| 17:32:39.759 | waitFor |
21,002.701 | No host response; outer broker deadline |
| 17:33:08.942 | snapshot |
16,000.608 | No host response; outer broker deadline |
| 17:33:30.490 | snapshot |
16,002.056 | No host response; outer broker deadline |
| 17:33:33.564 | snapshot |
12,928.862 | Shared host disconnected before this call's deadline |
| 17:33:50.312 | press |
16,004.329 | No host response; outer broker deadline |
| 17:33:50.882 | snapshot |
15,435.335 | Shared host disconnected before this call's deadline |
| 17:34:10.882 | navigate |
31,001.176 | No host response; outer broker deadline |
| 17:34:19.572 | snapshot |
10,002.629 | Inner snapshot deadline: tooBusy |
| 17:34:50.419 | evaluate |
16,002.009 | No host response; outer broker deadline |
| 17:35:17.303 | evaluate |
15,002.131 | Evaluation deadline: script stopped |
| 17:36:52.995 | click |
15,001.619 | Locator scrollIntoViewIfNeeded timeout |
| 17:37:22.986 | click |
16,003.811 | No 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 / tab | Attach start | Startup duration | What ended the wait |
|---|---|---|---|
A / tab_3 |
17:27:24.459 | about 203 s | Tab closes at about 17:30:47 |
A / tab_4 |
17:28:53.592 | about 599 s | Tab closes at about 17:38:53 |
B / tab_5 |
17:32:03.959 | about 187 s | Tab closes at about 17:35:10 |
B / fresh tab_7 |
17:35:16.526 | about 15 ms | Startup 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
17:33:30.490 Thread B snapshot begins; its 15s budget + 1s grace expires at ~17:33:46.492
17:33:33.564 Thread A snapshot begins
17:33:46.493 Thread A snapshot fails after 12,928.862 ms: server-browser disconnected
17:33:50.313 Thread B press begins; its 15s budget + 1s grace expires at ~17:34:06.317
17:33:50.882 Thread A snapshot begins
17: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
Preview snapshot failed: Preview automation snapshot timed out after 15000ms.
Preview automation evaluate timed out after 15000ms.
Preview automation navigate timed out after 30000ms.
Preview snapshot failed: Preview automation client server-browser disconnected during snapshot.
The server trace preserves this cause for the 10-second snapshot failures:
The 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:
- The snapshot function started and returned its 10-second inner error; or
- 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.captureScreenshotpending 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
viewersset 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:
- Acquire and acknowledge a scoped desktop paint/capture state for server snapshots and remote viewers. Release it on completion, failure, disconnect, and tab close.
- Bound the full operation: capture-lock wait, viewer pause, screenshot/still capture, and viewer resume. The bound must start before lock acquisition.
- Cancel or safely retire native/CDP work on expiry. A
Promise.racealone does not release the underlying screenshot or guarantee safe queue progress. - Verify that a stalled viewer still cannot hold
captureLockforever, including when its client disconnects during startup. - 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.
- Test default and incognito profiles, no viewer / one viewer / multiple viewers, background desktop tabs, and the no-desktop headless path separately.
- Preserve password masking and stale-ref safety. A cancelled late snapshot must not invalidate or replace the next snapshot's refs.
- Keep shared-host isolation work linked to [Bug]: Windows snapshot/evaluate timeouts evict the shared server-browser host and abort another open #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.
Activity