From f7e78588768f2457f2159133b3fb4329b28e62a7 Mon Sep 17 00:00:00 2001 From: Entrpi Date: Sun, 8 Nov 2026 12:59:44 +1100 Subject: [PATCH] fix(server): preview_wait_for stops at its timeout when the page stops answering ServerBrowserPage.waitFor compared its deadline only after every check in a poll round had settled. The selector check awaits Playwright's locator.count() and the text check awaits page.evaluate(); neither takes a timeout. When one stalls (a desktop-drawn tab whose Playwright utility world never answers, a busy main thread, or a dialog opening mid-wait) the wait never ends. It then holds the tab's SessionControl queue forever, so every later action on that tab times out at the broker, and each broker timeout disconnects the shared server-browser host for every thread. The wait now arms one expiry timer per call, after the target locator is resolved, and races both the poll's checks and the poll sleep against it. The timer is cleared in finally on every path. This is the same expiry shape evaluate and snapshot already use in this module. Racing each inner await, rather than wrapping the whole loop, means the loop itself stops at the deadline instead of polling CDP in the background. The error tag and message are unchanged. Behaviour change: the timeout is now a real deadline. Before, a wait always finished one full round of checks before it could fail, so a very small timeoutMs (such as 1 ms) can now fail on an element that is present if the timer fires before the first CDP reply. evaluate already behaves this way. Scope: this bounds a running wait. It does not cancel the stalled Playwright read, which stays pending until it settles or the page closes. Budgets still start when the run starts, so a wait queued more than about a second behind another action on the same tab can still overrun the broker grace. The type operation's focused-element probe has the same unbounded count() and is left for a follow-up. User story: When I ask an agent to wait for something in the Browser panel and the page stops answering, the wait gives up at its timeout and the agent can keep using the preview. --- .../src/preview/ServerBrowserPage.test.ts | 20 ++++++++++++ apps/server/src/preview/ServerBrowserPage.ts | 31 +++++++++++++------ 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index 78f03f1f91b72..c329aef3553d9 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -521,6 +521,26 @@ describe("server browser evaluate", () => { }); }); +describe("server browser wait", () => { + it("ends at its timeout when a check never answers", async () => { + // As on a desktop tab whose Playwright utility world never answers. + const page = { + locator: () => ({ count: () => new Promise(() => {}) }), + } as unknown as Page; + vi.useFakeTimers(); + try { + let failure: unknown; + void ServerBrowserPage.waitFor(page, { selector: "button", timeoutMs: 300 }).catch( + (error: unknown) => (failure = error), + ); + await vi.advanceTimersByTimeAsync(300); + expect(failure).toMatchObject({ tag: "PreviewAutomationTimeoutError" }); + } finally { + vi.useRealTimers(); + } + }); +}); + describe("server browser net errors", () => { it.each([ ["net::ERR_CONNECTION_REFUSED", "ERR_CONNECTION_REFUSED", -102], diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 31cff821c6e42..21284ae7504e8 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -947,7 +947,6 @@ export const evaluate = async ( export const waitFor = async (page: Page, input: PreviewAutomationWaitForInput) => { const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const deadline = Date.now() + timeoutMs; const locator = targetLocator(page, input); const { text, urlIncludes } = input; const checks: Array<() => Promise> = []; @@ -962,16 +961,28 @@ export const waitFor = async (page: Page, input: PreviewAutomationWaitForInput) ); } if (urlIncludes !== undefined) checks.push(async () => page.url().includes(urlIncludes)); - for (;;) { - const results = await Promise.all(checks.map((check) => check())); - if (results.every(Boolean)) return; - if (Date.now() >= deadline) { - throw new ServerBrowserOperationError({ - tag: "PreviewAutomationTimeoutError", - message: `Waited ${timeoutMs}ms without every condition holding.`, - }); + // `count()` and `evaluate()` take no timeout; a stalled check would hold the tab's control queue. + let timer: ReturnType | undefined; + const expired = new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new ServerBrowserOperationError({ + tag: "PreviewAutomationTimeoutError", + message: `Waited ${timeoutMs}ms without every condition holding.`, + }), + ), + timeoutMs, + ); + }); + try { + for (;;) { + const results = await Promise.race([Promise.all(checks.map((check) => check())), expired]); + if (results.every(Boolean)) return; + await Promise.race([new Promise((resolve) => setTimeout(resolve, WAIT_POLL_MS)), expired]); } - await new Promise((resolve) => setTimeout(resolve, WAIT_POLL_MS)); + } finally { + clearTimeout(timer); } };