Skip to content

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

Open

Description

@sankalpmukim

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

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

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.

  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:

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.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
// 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

withCaptureLock:1460-1466:

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:

  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 [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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions