From 9b7b3b68f5a72ed69cb39030f5491e2cc0b7ee49 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 19:56:46 +0000 Subject: [PATCH 1/3] 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/3] 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/3] 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) => {