Skip to content

[Bug]: preview_resize always times out (and evicts the automation host) when the main window is zoomed, because the guest webview reports innerWidth × hostZoom #12319

Description

@Gal-WPsite

Filed by Claude (Fable 5.1, running inside T3 Code) on behalf of Gal Hadad (@Gal-WPsite), at his request. Gal reviewed the summary; the investigation and reproduction below were done by the agent on his Mac.

Before submitting

Area

apps/web (preview automation host in the renderer), with a side effect in apps/server

Steps to reproduce

  1. On macOS, in T3 Code (Alpha) 0.0.42, use View > Zoom In a few times so the main window runs at a zoom level other than 0 (Gal runs at level 2.5, i.e. 1.2 ** 2.5 = 1.577; window.devicePixelRatio in the renderer reads 1.577 on a non-Retina 1920x2160 display).
  2. From an agent, call preview_open with any URL (e.g. https://example.com).
  3. Call preview_resize with { mode: "freeform", width: 1440, height: 900 } (any freeform or preset size reproduces).

Expected behavior

The resize completes and the guest reports a 1440x900 CSS viewport, independent of the main window zoom.

Actual behavior

preview_resize blocks for the full timeout and fails with Preview automation resize timed out after 15000ms. Because PreviewAutomationBroker.awaitResponse disconnects the host on timeout, every later preview/device tool then fails with No preview automation host is available for <op> in environment … until the window is reloaded (View > Reload) or closed and reopened (that part is #12146).

Root cause, read from the bundled renderer in app.asar (function names are the minified ones):

  • The renderer's resize handler polls until XU({ setting, appliedSettingKey, declaredViewport, renderedViewport }) is true, where renderedViewport comes from webview.executeJavaScript("({ width: window.innerWidth, height: window.innerHeight })") and must match the requested CSS size within 1 px.
  • The desktop PreviewManager asserts the guest zoom factor to tab.zoomFactor (default browserDefaultZoomFactor = 1) on registerWebview and again in reapplyZoom whenever DesktopWindow.zoomMain changes the main window zoom.
  • With the embedder at zoom 1.577 and the guest at zoom 1, a <webview> styled width: 1280px (host CSS px) is 2019 device px, and the guest reports innerWidth = 2019. Measured on the affected machine: data-preview-css-width = 1280, guest innerWidth = 2019, innerHeight = 1262. The comparison can never pass, so every resize times out.
  • Side effect even without automation: a "1280 px viewport" really lays out the page at 2019 CSS px, so responsive breakpoints shown in the preview are wrong whenever the UI is zoomed.

Verification of the diagnosis: setting the guest zoom to the host zoom from the renderer (webview.setZoomFactor(window.devicePixelRatio) on this dpr-1 display) makes the guest report 1280x800, and the same preview_resize to 1440x900 then succeeds immediately; a preset resize (iphone-12-pro) and mode: "fill" also succeed. Resetting the guest zoom to 1 brings the timeout back.

Impact

Any user who zooms the T3 Code UI (accessibility, high-resolution portrait monitors, non-Latin scripts) loses all preview automation on the first resize, and the failure mode is a 15 s hang followed by a permanent "no host" state, with no hint that the UI zoom is the cause.

Version or commit

T3 Code (Alpha) 0.0.42 (CFBundleVersion 0.0.42), app.asar dated 2026-09-16.

Environment

macOS (Darwin 25.5.0), Apple Silicon, single 1920x2160 display at scale 1. Agent provider: Codex (when first hit) and Claude Code (reproduction). Local environment, no T3 Connect.

Logs or stack traces

server.trace.ndjson, 2026-09-17 (Asia/Jerusalem), same environment id throughout:

23:50:01 PreviewAutomationBroker.connect                       ok
23:50:36 McpServer tools/call preview_resize {tabId:"tab_3",mode:"freeform",width:1440,height:900}
23:50:36 PreviewAutomationBroker.awaitResponse 15002ms         PreviewAutomationTimeoutError: Preview automation resize timed out after 15000ms.
23:50:51 PreviewAutomationBroker.disconnect                    ok
23:50:51 ws.rpc.previewAutomation.connect (50718ms)            Interrupted
23:50:51 PreviewAutomationBroker.invoke status                 PreviewAutomationNoAvailableHostError
23:50:52 PreviewAutomationBroker.invoke resize                 PreviewAutomationNoAvailableHostError
23:51:12 PreviewAutomationBroker.invoke open                   PreviewAutomationNoAvailableHostError

No new PreviewAutomationBroker.connect appears afterwards until the window is reloaded. desktop.trace.ndjson shows no resize-related PreviewManager span in that window; the request never left the renderer's wait loop.

Screenshots, recordings, or supporting files

Renderer state captured over the local DevTools port while the resize was pending:

{"inner":[532,1349],"dpr":1.5774409770965576,"outer":[840,2129],
 "webview":{"key":"freeform:1280:800:","cssW":"1280","cssH":"800","zoomFactor":1,
            "guestInner":{"width":2019,"height":1262}}}

Workaround

  • View > Actual Size (Cmd+0) before using preview automation; then View > Reload if the host is already gone.
  • Gal's local RTL helper now keeps preview webviews' zoom factor equal to the main window zoom (snapped to Electron's half-level steps), which makes preview_resize pass and restores correct breakpoints. A proper fix would either compare the rendered viewport in host CSS pixels (divide guest innerWidth by the embedder zoom factor), or set the guest zoom to browserDefaultZoomFactor * mainWindowZoomFactor in assertTabZoom/reapplyZoom.

Activity

  1. juliusmarminge commented on Sep 17, 2026

    @juliusmarminge
    Member

    Triage

    Confirmed on current main (4749035bd) and on v0.0.42. This is a real desktop preview-automation bug, not a flake and not already fixed. The report’s diagnosis matches the code, and the measured numbers are exact: zoom level 2.5 is 1.2 ** 2.5 = 1.577440977, and 1280 × 1.577440977 ≈ 2019, 800 × 1.577440977 ≈ 1262.

    What happens

    preview_resize applies the server snapshot, then the desktop renderer polls until the guest CSS viewport matches the request within 1 px:

    const readWebviewViewport = async (
      webview: ExecutablePreviewWebview,
    ): Promise<PreviewRenderedViewportSize | null> => {
      const value = await webview.executeJavaScript(
        "({ width: window.innerWidth, height: window.innerHeight })",
      );
      // ...
    };
      // Electron rounds CSS pixels through the guest's fractional zoom/device scale,
      // so a successfully applied fixed viewport can measure one pixel either way.
      const tolerance = 1;
      return (
        Math.abs(renderedViewport.width - expectedViewport.width) <= tolerance &&
        Math.abs(renderedViewport.height - expectedViewport.height) <= tolerance
      );

    The <webview> is styled to the requested size in host CSS pixels (data-preview-css-width / style.width in HostedBrowserWebview.tsx). Preview-tab zoom is already compensated: layout multiplies the frame by tab.zoomFactor, and assertTabZoom sets the guest to that same factor, so a 1280 CSS request still reports innerWidth = 1280 when the window zoom is 1.

    Window zoom is the opposite. Chromium inherits the embedder zoom onto the guest; desktop then resets it to tab.zoomFactor (default browserDefaultZoomFactor = 1) so the preview “keeps its own zoom”:

        zoomMain: Effect.fn("desktop.window.zoomMain")(function* (direction) {
          // ...
          webContents.setZoomLevel(
            direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5),
          );
          // Chromium pushes the new level down to embedded guests, which would zoom
          // the previewed page along with the app UI. The preview browser keeps its
          // own zoom, so put each guest back where the preview left it.
          yield* previewManager.reapplyZoom();
        }),
      const assertTabZoom = Effect.fn("PreviewManager.assertTabZoom")(function* (tabId: string) {
        const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
        if (!tab || tab.webContentsId == null) return;
        const wc = webContents.fromId(tab.webContentsId);
        if (!wc || wc.isDestroyed()) return;
        yield* attempt({ operation: "assertTabZoom", tabId, webContentsId: wc.id }, () =>
          wc.setZoomFactor(tab.zoomFactor),
        ).pipe(Effect.ignore);
      });

    Same reset on registerWebview (Manager.ts 2190–2196) and on every reapplyZoom (2617–2619). Result with window zoom 1.577 and guest zoom 1: a frame declared 1280×800 occupies ~2019×1262 device pixels, the guest reports that as innerWidth/innerHeight, and the ±1 check can never pass. Existing tests lock this in: previewViewportReadiness.test.ts only allows one pixel of slack; DesktopWindow.test.ts requires reapplyZoom after every zoomMain; Manager.test.ts expects setZoomFactor(tab.zoomFactor) with no window-zoom term.

    This is not Retina/devicePixelRatio as a display scale. On a scale-1 display, renderer devicePixelRatio equals the window zoom factor (the reporter’s 1.5774409770965576). On Retina, devicePixelRatio includes display scale; a default-zoom Retina Mac would already be broken if the guest used full DPR, and it is not. Use mainWindow.webContents.getZoomFactor(), not window.devicePixelRatio.

    Why the host then dies

    waitForRenderedViewport burns the full request budget (default 15 s) and never replies. The broker treats an unanswered deadline as host death:

          const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeoutMs));
          return yield* Option.match(result, {
            onNone: () =>
              Effect.gen(function* () {
                // An unanswered request invalidates this connection. Do not replay
                // actions: the client may have applied them before becoming unreachable.
                yield* disconnect(connection.clientId, connection.queue);
                return yield* new PreviewAutomationTimeoutError(requestContext);
              }),

    That is the logged Preview automation resize timed out after 15000ms. (PreviewAutomationTimeoutError in packages/contracts/src/previewAutomation.ts), then PreviewAutomationNoAvailableHostError on every later preview_* until the window is reloaded. The host lives only in the Electron renderer (PreviewAutomationHosts returns null when !isElectron), so a zoomed desktop window breaks automation even if the agent is driven from the website.

    Even without automation, the shown “1280 px” page actually lays out at ~2019 CSS px, so device-toolbar breakpoints are wrong whenever the UI is zoomed.

    Not a duplicate

    Issue Why different
    #12146 Host gone and never re-registers. This issue is a deterministic cause of the 15 s timeout that trips that eviction.
    #12273 A best-effort 500 ms metadata status evicts the host. Same disconnect path, different trigger.
    #11381 Made unanswered-timeout eviction intentional for liveness. Correct for a dead host; not for a resize that can never succeed.

    Keep all three open. Fixing the zoom math stops this hang; it does not restore a host after some other timeout, and it does not make the optional metadata read non-evicting.

    Suggested fix

    Do not only relax isPreviewViewportReady (divide guest innerWidth by window zoom). That would make the wait succeed and leave breakpoints wrong.

    Compensate window zoom the same way preview-tab zoom is already compensated: at assert time, set

    guestZoom = tab.zoomFactor * mainWindow.webContents.getZoomFactor()

    in assertTabZoom / registerWebview.restoreZoomFactor / reapplyZoom. Keep stored tab.zoomFactor as the preview-only setting (default browserDefaultZoomFactor). After that, a 1280×800 declaration stays 1280×800 guest CSS at any UI zoom, preview_resize (freeform, preset, fill) becomes ready immediately, and the visible frame still tracks View > Zoom In.

    Do not use renderer devicePixelRatio as the guest zoom (wrong on Retina). Snap only if Electron requires it; getZoomFactor() already matches the 0.5-level ladder (1.2 ** level).

    Tests to add/adjust:

    1. assertTabZoom / reapplyZoom apply the product, not tab.zoomFactor alone, when the main window zoom is ≠ 1.
    2. Preview-tab zoom 1.25 × window zoom 1.577 still yields guest CSS = requested size.
    3. Keep the existing ±1 readiness tests in CSS pixels; do not widen tolerance to absorb window zoom.
    4. Existing DesktopWindow “restores preview zoom after zooming the app” test can stay; it only asserts reapplyZoom is invoked.

    Workaround

    View > Actual Size (Cmd+0) before preview automation. If the host is already gone, View > Reload (or close/reopen the window). Same as the reporter.

    Labels / next

  2. added
    bugSomething is broken or behaving incorrectly.
    acceptedfeature request accepted
    via-triageFiled through npx t3 triage
    on Sep 17, 2026
  3. ViaxCo commented on Oct 6, 2026

    @ViaxCo

    Note

    🤖 Claude Opus 5.5 responding on behalf of Victor

    Still happens on nightly 0.0.46-nightly.20261005.2702 (macOS, main window zoom 1.5). The saved size is 1280×800, the measured size is 1683×1052, and preview_resize times out. #10536 fixes it in my test on current main: #10536 (comment)

  4. juliusmarminge commented on Oct 7, 2026

    @juliusmarminge
    Member

    Obsolete since #15328: preview_resize no longer polls the page's width, so window zoom can no longer time it out or evict the host. The underlying size mismatch under window zoom is tracked in #10525.

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

    acceptedfeature request acceptedbugSomething 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