Skip to content

[Bug]: Linux: a hidden agent preview tab held Chromium's "Capturing" wake lock for hours, and t3_preview_close couldn't close the tab #17180

Description

@amihos

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/desktop

Steps to reproduce

I don't have a deterministic repro yet. What happened:

  1. At 21:51 a Codex agent in one thread called preview_open, preview_navigate, preview_evaluate and preview_snapshot twice. Both snapshots returned in about 0.25 s. Nobody opened the preview panel, and the agent never closed the tab (runtime: "server", reveal: false).
  2. More than three hours later, at 01:05, GNOME still listed an idle inhibitor owned by T3 Code with the reason "Capturing".

Expected behavior

A preview tab nobody is looking at shouldn't hold a display wake lock once the agent's calls have returned. An agent should also be able to close a preview tab its own thread opened.

Actual behavior

GNOME's inhibitor list held two idle inhibitors (flag 8 = idle):

('/tmp/.mount_T3-CodpEnOOj/t3code',) ('Capturing',) (uint32 8,)
('mutter',) ('idle-inhibit',) (uint32 8,)

"Capturing" is the wake lock Chromium's WebContentsImpl takes while a capturer with stay_awake=true is attached (GetWakeLock(kPreventDisplaySleepAllowDimming, ..., "Capturing")). CDP Page.captureScreenshot takes one and releases it when the screenshot callback runs, and tab capture (WebContentsFrameTracker) holds one while the stream is open. I found no powerSaveBlocker and no stayAwake in app.asar, so the lock came from one of those paths.

According to renderer-history.ndjson, the only guest still alive was that stale preview tab (renderer created at 21:51, webContentsId 2). The thread had a second preview tab, which closed at 22:09. When I killed the stale tab's renderer process, both inhibitors disappeared within 2 seconds. T3 Code immediately recreated the webview as webContentsId 4, and the lock did not come back. So a capturer on that tab's old WebContents was never released.

I couldn't tell which call took the lock. desktop.trace.ndjson has no startFrameCapture span for that tab, and both preview_snapshot calls returned. One possibility is a Page.captureScreenshot or capturePage on the hidden guest that never completed. The bundle already wraps capturePage in a timeout because it can stall (see #10366), but a JS-side timeout doesn't release the native capturer handle.

On a stock GNOME session, an idle inhibitor like this keeps the session from going idle, so the screen doesn't blank and automatic suspend doesn't happen. I didn't see that effect myself: on this machine GNOME's own idle blanking is turned off and a separate script powers the panel off based on Mutter's idle time, which ignores inhibitors. So I can confirm the leaked lock, but not a blank screen that failed to happen because of it.

Closing the tab from the agent didn't work either. Later, in a new run of the same thread, the agent called t3_preview_close with that tab's tabId. It failed twice with {"code":"orchestration_error","message":"The operation could not be completed."}. The server trace shows PreviewAutomationBroker.invoke → respond in about 20 ms, and the desktop trace shows no close IPC at all. The t3_preview_close handler maps every broker failure to unavailable(), which is the same masking as #15586 (fixed there for thread tools), so the actual reason is lost. My guess is the tab's automation owner was the agent session from the earlier run. The tool description says "Close one preview tab owned by this thread", though.

Impact

Minor bug or occasional failure

Version or commit

0.0.46-nightly.20261007.2774 (AppImage; Electron 44.4.2, Chrome 152.0.7977.130)

Environment

Ubuntu 26.04.1 LTS, GNOME Shell 50.1 (Wayland), Linux 7.0.0, x86-64, Codex provider

Logs or stack traces

# GNOME inhibitors before / after killing the preview tab's renderer
BEFORE 01:27:06
  ('mutter',) ('idle-inhibit',) (uint32 8,)
  ('/tmp/.mount_T3-CodpEnOOj/t3code',) ('Capturing',) (uint32 8,)
t+2s  (both gone; only unrelated logout/suspend inhibitors left)

Workaround

Check gdbus call --session -d org.gnome.SessionManager -o /org/gnome/SessionManager -m org.gnome.SessionManager.GetInhibitors for a t3code "Capturing" inhibitor. Then either restart T3 Code, or kill the preview renderer (its rendererPid for surface: "preview" in ~/.t3/userdata/logs/renderer-history.ndjson).

Related: #15343 (a leaked wake lock on macOS, but there it's Chromium's "Video Wake Lock"), #15579 (hidden preview tabs stay alive until restart), #15586 (generic MCP error message), #10366 (capture timeout).

Activity

  1. juliusmarminge commented on Oct 8, 2026

    @juliusmarminge
    Member

    Note

    Grok responding on behalf of Julius.

    Thanks for the detailed report and the inhibitor before/after. I read the code on main (a4c9494b0) but didn't reproduce either symptom at runtime, so treat the following as code reading.

    1. t3_preview_close fails with a generic error

    This looks like a real bug, and your guess about the owner appears to be right.

    • The broker tags every request with agentSessionId = environmentId + providerSessionId (apps/server/src/mcp/PreviewAutomationBroker.ts:177-178, sent at :640). It doesn't use the thread id.
    • The server browser's close goes through requireTab, which rejects a tab whose control.agentId isn't the caller's session with "This tab belongs to another agent session or a human. Open your own tab." (reason agentMismatch) (apps/server/src/preview/ServerBrowser.ts:1420-1449, close at :1587-1596). Tabs are created with automationOwner: request.agentSessionId (:1510-1516). So if the later run got a new provider session, its close would probably be refused here, before any desktop IPC. That would fit the ~20 ms invoke → respond and the missing close IPC.
    • That reason does survive back to the server as PreviewAutomationControlInterruptedError with reason (ServerBrowserPage.ts:43-47, PreviewAutomationBroker.ts:265-271). But t3_preview_close then drops it with Effect.mapError(unavailable) (apps/server/src/mcp/toolkits/previewControls/handlers.ts:36-38, and :41-43 for the non-server path). That becomes orchestration_error / "The operation could not be completed." (apps/server/src/mcp/threadAccess.ts:19-23). It's the same masking pattern as [Bug]: MCP thread tools replace the orchestrator's rejection reason with "The operation could not be completed." #15586, which wasn't fixed for the preview-control tools. The tool's failure union already includes PreviewAutomationUnavailableError (previewControls/tools.ts:16), so it could carry a real reason.
    • The tool description says "Close one preview tab owned by this thread" (previewControls/tools.ts:43), but the server-runtime path actually enforces ownership by agent session. That appears to leave a new run in the same thread with no way to close the old run's tab.

    Likely fix: surface the broker error instead of unavailable(). Then either let t3_preview_close close server-runtime tabs owned by an earlier session of the same thread (for example via PreviewManager.close, as the non-server branch does), or make the description match the per-session rule.

    2. "Capturing" wake lock on the stale hidden tab

    I couldn't pin down which call took the lock. Here's what the code shows:

    • Our only call on a server-runtime guest that should take Chromium's stay-awake ("Capturing") capturer is CDP Page.captureScreenshot in captureViewport (apps/server/src/preview/ServerBrowserPage.ts:154-175). It's used by preview_snapshot (ServerBrowser.ts:1655-1658), recording start (:1217) and the viewer still (:2013-2021). In upstream Chromium, PageHandler::CaptureScreenshot holds IncrementCapturerCount(..., stay_awake=true) until the screenshot callback runs or the pending request is dropped. Both of your snapshots returned, so I didn't find a path in our code that should leave that handle open.
    • The desktop's own webContents.capturePage() calls pass no stayAwake (apps/desktop/src/preview/Manager.ts:305, :631, :2520, :3150). Electron defaults that to false, so they shouldn't take this lock (checked against Electron's current source, not 44.4.2 specifically). Tab recording through getDisplayMedia would hold one, but you saw no frame-capture span, and nothing here suggests it ran.
    • So either one of those CDP captures on the hidden, offscreen guest never finished on the Chromium side (hidden-guest capture stalls are a known pattern, see preview_snapshot on a background tab never returns and blocks every later action on that tab #16567), or Chromium/Electron didn't release the capturer. From here I can't tell whether that's our bug or an upstream one.

    What our code clearly controls is how long the tab lived. closeIdleAgentTabs would close an agent tab that nobody is viewing after AGENT_TAB_IDLE_MS = 30 min (ServerBrowser.ts:335, :1085-1092). But its only caller is preview_open creating a new tab (:1503), and nothing runs it on a timer. An agent that stops using its tab therefore keeps the hidden guest, and whatever it holds, until restart. Here that was 3+ hours, where a periodic sweep would likely have closed it after about 30 minutes and probably dropped the lock along with the WebContents. This overlaps #15579, but that issue (and the hidden-tab part of #16961) is about rendering and throttling, not closing idle agent tabs.

    Related

    None of the open PRs I checked (#8981, #16961, #14985, #13600) changes t3_preview_close error mapping, its ownership check, or idle-tab cleanup. I couldn't fully rule out a wake-lock fix in #8981 or #13600, since both touch background-capture paths.

  2. added
    bugSomething is broken or behaving incorrectly.
    via-triageFiled through npx t3 triage
    on Oct 8, 2026
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

    bugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions