From 9b7b3b68f5a72ed69cb39030f5491e2cc0b7ee49 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 19:56:46 +0000 Subject: [PATCH 1/7] test(server): reproduce preview navigate failing on a late load event (#8732) These tests reproduce pingdotgg/t3code#8732: preview_navigate rejects with PreviewAutomationTimeoutError when the page has committed to the target and only its load event is late. A guard keeps a navigation that never commits failing with the timeout. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 69 ++++++++++++++++++- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index b87bc8d9ca4d8..656a10e792bad 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -158,12 +158,15 @@ function makeContext(onClose?: (context: BrowserContext) => void) { mainFrame: () => page, frames: () => [page], url: () => url, + commit: (next: string) => { + url = next; + }, title: vi.fn(async () => "test page"), viewportSize: () => viewport, setViewportSize: vi.fn(async (size: typeof viewport) => { viewport = size; }), - goto: vi.fn(async (next: string) => { + goto: vi.fn(async (next: string, _options?: { timeout?: number; waitUntil?: string }) => { url = next; events.emit("load"); const pending = initialNavigation; @@ -174,7 +177,7 @@ function makeContext(onClose?: (context: BrowserContext) => void) { goForward: vi.fn(async () => {}), reload: vi.fn(async () => {}), emulateMedia: vi.fn(async () => {}), - waitForLoadState: vi.fn(async () => {}), + waitForLoadState: vi.fn(async (_state?: string, _options?: { timeout?: number }) => {}), evaluate: vi.fn(async () => ({ url, title: "test page", @@ -437,6 +440,68 @@ it.live("preview_open reports its navigation timeout instead of the request budg ).pipe(Effect.provide(layer)), ); +it.live("navigate returns the committed page while its load event is still pending", () => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + const page = contexts[0]!.page; + const heavy = "http://localhost:5173/heavy"; + const timedOut = (method: string, timeout: number | undefined) => + Object.assign(new Error(`${method}: Timeout ${timeout}ms exceeded.`), { + name: "TimeoutError", + }); + // The document commits and parses, but a stalled subresource holds back `load`. + page.goto.mockImplementation(async (next, options) => { + page.commit(next); + page.emit("request", { + url: () => next, + isNavigationRequest: () => true, + frame: () => page, + }); + if (options?.waitUntil === "load") throw timedOut("page.goto", options.timeout); + }); + page.waitForLoadState.mockImplementation(async (state, options) => { + if (state === undefined || state === "load") { + throw timedOut("page.waitForLoadState", options?.timeout); + } + }); + + const status = yield* broker.invoke({ + scope, + tabId, + operation: "navigate", + input: { url: heavy, timeoutMs: 4_000 }, + }); + + expect(status).toMatchObject({ url: heavy, loading: true }); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("navigate fails when the page never commits within its timeout", () => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + contexts[0]!.page.goto.mockImplementation(async () => { + throw Object.assign(new Error("page.goto: Timeout 4000ms exceeded."), { + name: "TimeoutError", + }); + }); + + const error = yield* broker + .invoke({ + scope, + tabId, + operation: "navigate", + input: { url: "http://10.255.255.1/", timeoutMs: 4_000 }, + }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ _tag: "PreviewAutomationTimeoutError", timeoutMs: 4_000 }); + }), + ).pipe(Effect.provide(layer)), +); + it.live.each(["desktop" as const, undefined])( "explicit desktop targets ($0 runtime) fail without creating a server tab", (runtime) => From 1f132e768c2d00520aa32629a4453ddcf13f36d7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 20:00:41 +0000 Subject: [PATCH 2/7] fix(server): preview_navigate returns the committed page when load is slow A page whose HTML arrives at once but whose subresource stalls made preview_navigate fail with a timeout, though the tab already showed the target. Navigate now goes to commit first with the existing error mapping, then waits for the requested readiness within what is left of the same budget, and returns the loading tab's status if only that wait times out. Fixes pingdotgg/t3code#8732 Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index a2485b30e9e36..965767bba8cc1 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -1419,20 +1419,22 @@ const make = Effect.gen(function* () { readiness: "load" | "domContentLoaded" | "none", timeout: number, ) => { - const navigation = tab.page.goto(url, { - timeout, - waitUntil: - readiness === "domContentLoaded" - ? "domcontentloaded" - : readiness === "none" - ? "commit" - : "load", - }); + const startedAt = performance.now(); + const navigation = tab.page.goto(url, { timeout, waitUntil: "commit" }); if (readiness === "none") { tab.control.track(navigation); return; } - await navigation.catch((cause: unknown) => { + // Once the target commits, a stalled subresource only delays readiness; report the loading tab. + const ready = () => + tab.page + .waitForLoadState(readiness === "domContentLoaded" ? "domcontentloaded" : "load", { + timeout: Math.max(1, timeout - (performance.now() - startedAt)), + }) + .catch((cause: unknown) => { + if (!(cause instanceof Error && cause.name === "TimeoutError")) throw cause; + }); + await navigation.then(ready).catch((cause: unknown) => { const message = cause instanceof Error ? cause.message : String(cause); if (message.includes("Download is starting")) { throw new ServerBrowserPage.ServerBrowserOperationError( From 989a080ab99f1c7123b31b052de5256cf8564108 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 20:25:09 +0000 Subject: [PATCH 3/7] fix(server): bound the preview status title read Navigate now returns the committed page while load is pending, which reads the title on a page that may still be running script. page.title() waits on the renderer's main thread, so a busy page held the reply past the broker's grace and evicted the shared server preview host. Cap the read at 250 ms and report no title when it does not answer. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 73 +++++++++++-------- apps/server/src/preview/ServerBrowser.ts | 11 ++- 2 files changed, 51 insertions(+), 33 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 656a10e792bad..efc38f5ee6623 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -440,42 +440,51 @@ it.live("preview_open reports its navigation timeout instead of the request budg ).pipe(Effect.provide(layer)), ); -it.live("navigate returns the committed page while its load event is still pending", () => - Effect.scoped( - Effect.gen(function* () { - const { broker, tabId } = yield* ready; - const page = contexts[0]!.page; - const heavy = "http://localhost:5173/heavy"; - const timedOut = (method: string, timeout: number | undefined) => - Object.assign(new Error(`${method}: Timeout ${timeout}ms exceeded.`), { - name: "TimeoutError", +it.live.each([ + { renderer: "idle", title: "Heavy page" }, + { renderer: "busy", title: null }, +])( + "navigate returns the committed page while its load event is still pending ($renderer renderer)", + ({ renderer, title }) => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + const page = contexts[0]!.page; + // A busy main thread never answers title(); the status must not wait past the broker's grace. + page.title.mockImplementation(() => + renderer === "busy" ? new Promise(() => {}) : Promise.resolve("Heavy page"), + ); + const heavy = "http://localhost:5173/heavy"; + const timedOut = (method: string, timeout: number | undefined) => + Object.assign(new Error(`${method}: Timeout ${timeout}ms exceeded.`), { + name: "TimeoutError", + }); + // The document commits and parses, but a stalled subresource holds back `load`. + page.goto.mockImplementation(async (next, options) => { + page.commit(next); + page.emit("request", { + url: () => next, + isNavigationRequest: () => true, + frame: () => page, + }); + if (options?.waitUntil === "load") throw timedOut("page.goto", options.timeout); }); - // The document commits and parses, but a stalled subresource holds back `load`. - page.goto.mockImplementation(async (next, options) => { - page.commit(next); - page.emit("request", { - url: () => next, - isNavigationRequest: () => true, - frame: () => page, + page.waitForLoadState.mockImplementation(async (state, options) => { + if (state === undefined || state === "load") { + throw timedOut("page.waitForLoadState", options?.timeout); + } }); - if (options?.waitUntil === "load") throw timedOut("page.goto", options.timeout); - }); - page.waitForLoadState.mockImplementation(async (state, options) => { - if (state === undefined || state === "load") { - throw timedOut("page.waitForLoadState", options?.timeout); - } - }); - const status = yield* broker.invoke({ - scope, - tabId, - operation: "navigate", - input: { url: heavy, timeoutMs: 4_000 }, - }); + const status = yield* broker.invoke({ + scope, + tabId, + operation: "navigate", + input: { url: heavy, timeoutMs: 4_000 }, + }); - expect(status).toMatchObject({ url: heavy, loading: true }); - }), - ).pipe(Effect.provide(layer)), + expect(status).toMatchObject({ url: heavy, title, loading: true }); + }), + ).pipe(Effect.provide(layer)), ); it.live("navigate fails when the page never commits within its timeout", () => diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 965767bba8cc1..729c139f12c4b 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -123,6 +123,8 @@ const EDITABLE_AT_POINT_SCRIPT = `(x, y) => { }`; const UNATTACHED_FILL_VIEWPORT = { width: 1280, height: 800 } as const; const NAVIGATION_TIMEOUT_MS = 15_000; +// page.title() runs in the renderer; a busy page must not hold a status past the broker's grace. +const TITLE_READ_MS = 250; const VIEWER_NAVIGATION_OPTIONS = { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS } as const; const ACTION_TIMELINE_LIMIT = 50; @@ -1401,7 +1403,14 @@ const make = Effect.gen(function* () { })), }; if (status.url === null || tab.dialog) return status; - return { ...status, title: (await tab.page.title().catch(() => "")) || null }; + let timer: ReturnType | undefined; + const late = new Promise((resolve) => { + timer = setTimeout(() => resolve(""), TITLE_READ_MS); + }); + const title = await Promise.race([tab.page.title().catch(() => ""), late]).finally(() => + clearTimeout(timer), + ); + return { ...status, title: title || null }; }; const resolveNavigationUrl = (input: PreviewAutomationNavigateInput) => { From 573a8e0ea2e9ce2b0cbcb9f65744561285f927a4 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 4 Nov 2026 02:26:56 -0500 Subject: [PATCH 4/7] test(server): control the preview title deadline with fake timers --- apps/server/src/preview/ServerBrowser.test.ts | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 406c0b2dcc198..d6fcf27c58033 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -601,7 +601,7 @@ it.live("an initial download stays available after preview_open reports the navi ).pipe(Effect.provide(layer)), ); -it.live.each([ +it.effect.each([ { renderer: "idle", title: "Heavy page" }, { renderer: "busy", title: null }, ])( @@ -611,10 +611,14 @@ it.live.each([ Effect.gen(function* () { const { broker, tabId } = yield* ready; const page = contexts[0]!.page; - // A busy main thread never answers title(); the status must not wait past the broker's grace. - page.title.mockImplementation(() => - renderer === "busy" ? new Promise(() => {}) : Promise.resolve("Heavy page"), - ); + const titleRequested = Promise.withResolvers(); + // A busy main thread never answers title(); drive its native deadline explicitly. + page.title.mockImplementation(() => { + titleRequested.resolve(); + return renderer === "busy" + ? new Promise(() => {}) + : Promise.resolve("Heavy page"); + }); const heavy = "http://localhost:5173/heavy"; const timedOut = (method: string, timeout: number | undefined) => Object.assign(new Error(`${method}: Timeout ${timeout}ms exceeded.`), { @@ -636,16 +640,24 @@ it.live.each([ } }); - const status = yield* broker.invoke({ - scope, - tabId, - operation: "navigate", - input: { url: heavy, timeoutMs: 4_000 }, - }); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const result = yield* broker + .invoke({ + scope, + tabId, + operation: "navigate", + input: { url: heavy, timeoutMs: 4_000 }, + }) + .pipe(Effect.forkScoped); + yield* Effect.promise(() => titleRequested.promise); + if (renderer === "busy") { + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(250)); + } + const status = yield* Fiber.join(result); expect(status).toMatchObject({ url: heavy, title, loading: true }); }), - ).pipe(Effect.provide(layer)), + ).pipe(Effect.ensuring(Effect.sync(() => vi.useRealTimers())), Effect.provide(layer)), ); it.live("navigate fails when the page never commits within its timeout", () => From 81be4df4885878f0a15b4c2bbc114da7bc9e7558 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 01:04:46 +0000 Subject: [PATCH 5/7] test(server): fail fast when navigate errors before the title read Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index d6fcf27c58033..f80c0e8817fca 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -649,8 +649,11 @@ it.effect.each([ input: { url: heavy, timeoutMs: 4_000 }, }) .pipe(Effect.forkScoped); - yield* Effect.promise(() => titleRequested.promise); - if (renderer === "busy") { + const titleRead = yield* Effect.raceFirst( + Effect.promise(() => titleRequested.promise).pipe(Effect.as(true)), + Fiber.await(result).pipe(Effect.as(false)), + ); + if (titleRead && renderer === "busy") { yield* Effect.promise(() => vi.advanceTimersByTimeAsync(250)); } const status = yield* Fiber.join(result); From 6d1ca8028e3243ac693828d04e5877b319525cf5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 02:58:22 +0000 Subject: [PATCH 6/7] fix(server): read the preview title alongside the desktop viewport A busy desktop tab could spend the full 1 s viewport read and then the 250 ms title read in sequence, past the broker's 1 s grace. Overlap the two reads so the title adds no time. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.ts | 33 ++++++++++++++---------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 0b4254f070dda..dc14749326fb9 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -1790,6 +1790,17 @@ const make = Effect.gen(function* () { } }; + const readTitle = async (tab: ServerTab) => { + let timer: ReturnType | undefined; + const late = new Promise((resolve) => { + timer = setTimeout(() => resolve(""), TITLE_READ_MS); + }); + const title = await Promise.race([tab.page.title().catch(() => ""), late]).finally(() => + clearTimeout(timer), + ); + return title || null; + }; + const statusWithTitle = async ( tab: ServerTab | undefined, agentSessionId?: string, @@ -1806,14 +1817,19 @@ const make = Effect.gen(function* () { }; } const url = ServerBrowserPage.withoutDataPayload(tab.page.url()); - const viewport = tab.desktop ? await layoutSize(tab) : tab.page.viewportSize(); const loadError = loadErrorOf(tab); - const status = { + const statusUrl = loadError?.url ?? (url === "about:blank" ? null : url); + // Both reads need the renderer; overlapping them keeps a busy page within the broker's grace. + const [viewport, title] = await Promise.all([ + tab.desktop ? layoutSize(tab) : tab.page.viewportSize(), + statusUrl === null || tab.dialog ? null : readTitle(tab), + ]); + return { available: true, visible: isVisible(tab), tabId: tab.tabId, - url: loadError?.url ?? (url === "about:blank" ? null : url), - title: null, + url: statusUrl, + title, loading: tab.loading, ...(loadError ? { loadError } : {}), control: { @@ -1851,15 +1867,6 @@ const make = Effect.gen(function* () { completedAt, })), }; - if (status.url === null || tab.dialog) return status; - let timer: ReturnType | undefined; - const late = new Promise((resolve) => { - timer = setTimeout(() => resolve(""), TITLE_READ_MS); - }); - const title = await Promise.race([tab.page.title().catch(() => ""), late]).finally(() => - clearTimeout(timer), - ); - return { ...status, title: title || null }; }; const resolveNavigationUrl = (input: PreviewAutomationNavigateInput) => { From ace4fd890e246775ad607aa441dc85429f3f3f8e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 03:11:43 +0000 Subject: [PATCH 7/7] fix(server): bound the desktop viewport read in preview status A busy desktop renderer could hold the status viewport measurement for its full 1 s timeout (longer when a measurement was already queued), using up the broker's 1 s grace after a navigate that spent its own budget. Status now waits at most 250 ms for each renderer read and falls back to the last measured viewport; the measurement still finishes in the background and refreshes it. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 34 +++++++++++++++++++ apps/server/src/preview/ServerBrowser.ts | 28 ++++++++------- 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index d1a1f51a2ac03..b94ef1ae93945 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -754,6 +754,40 @@ it.effect.each([ ).pipe(Effect.ensuring(Effect.sync(() => vi.useRealTimers())), Effect.provide(layer)), ); +it.effect("a desktop status answers with the last viewport while the renderer cannot measure", () => + Effect.scoped( + Effect.gen(function* () { + const { broker } = yield* ready; + desktopRendersNext = true; + const opened = yield* broker.invoke({ + scope, + operation: "open", + input: { url: "https://example.com/", reuseExistingTab: false, show: false }, + }); + const measuring = Promise.withResolvers(); + desktopSizeAnswer = () => { + measuring.resolve(); + return new Promise(() => {}); + }; + + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const result = yield* broker + .invoke({ + scope, + tabId: opened.tabId!, + operation: "status", + input: {}, + }) + .pipe(Effect.forkScoped); + yield* Effect.promise(() => measuring.promise); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(250)); + const status = yield* Fiber.join(result); + + expect(status.viewport).toEqual({ width: 1920, height: 1080 }); + }), + ).pipe(Effect.ensuring(Effect.sync(() => vi.useRealTimers())), Effect.provide(layer)), +); + it.live("navigate fails when the page never commits within its timeout", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index dc14749326fb9..b70ad06df323a 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -156,8 +156,8 @@ const EDITABLE_AT_POINT_SCRIPT = `(x, y) => { }`; const UNATTACHED_FILL_VIEWPORT = { width: 1280, height: 800 } as const; const NAVIGATION_TIMEOUT_MS = 15_000; -// page.title() runs in the renderer; a busy page must not hold a status past the broker's grace. -const TITLE_READ_MS = 250; +// Status reads run in the renderer; a busy page must not hold a status past the broker's grace. +const STATUS_READ_MS = 250; const VIEWER_NAVIGATION_OPTIONS = { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS } as const; const ACTION_TIMELINE_LIMIT = 50; @@ -1790,15 +1790,12 @@ const make = Effect.gen(function* () { } }; - const readTitle = async (tab: ServerTab) => { + const withinStatusDeadline = (read: Promise, late: () => A): Promise => { let timer: ReturnType | undefined; - const late = new Promise((resolve) => { - timer = setTimeout(() => resolve(""), TITLE_READ_MS); + const deadline = new Promise((resolve) => { + timer = setTimeout(() => resolve(late()), STATUS_READ_MS); }); - const title = await Promise.race([tab.page.title().catch(() => ""), late]).finally(() => - clearTimeout(timer), - ); - return title || null; + return Promise.race([read, deadline]).finally(() => clearTimeout(timer)); }; const statusWithTitle = async ( @@ -1819,10 +1816,17 @@ const make = Effect.gen(function* () { const url = ServerBrowserPage.withoutDataPayload(tab.page.url()); const loadError = loadErrorOf(tab); const statusUrl = loadError?.url ?? (url === "about:blank" ? null : url); - // Both reads need the renderer; overlapping them keeps a busy page within the broker's grace. const [viewport, title] = await Promise.all([ - tab.desktop ? layoutSize(tab) : tab.page.viewportSize(), - statusUrl === null || tab.dialog ? null : readTitle(tab), + // A late measurement still lands in desktopViewport for the next status. + tab.desktop + ? withinStatusDeadline(layoutSize(tab), () => tab.desktopViewport) + : tab.page.viewportSize(), + statusUrl === null || tab.dialog + ? null + : withinStatusDeadline( + tab.page.title().catch(() => ""), + () => "", + ).then((title) => title || null), ]); return { available: true,