Skip to content
15 changes: 14 additions & 1 deletion apps/server/src/mcp/PreviewAutomationBroker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,14 @@ function remoteDetailKind(detail: unknown): RemoteDetailKind {
}
}

const readRemoteTimeoutMs = (detail: unknown): number | undefined => {
if (typeof detail !== "object" || detail === null || !("timeoutMs" in detail)) return undefined;
const timeoutMs = detail.timeoutMs;
return typeof timeoutMs === "number" && Number.isInteger(timeoutMs) && timeoutMs > 0
? timeoutMs
: undefined;
};

const MAX_REASON_CHARS = 500;

const classifyResponseError = (
Expand Down Expand Up @@ -253,11 +261,16 @@ const classifyResponseError = (
...context,
...remoteDiagnostics,
});
case "PreviewAutomationTimeoutError":
case "PreviewAutomationTimeoutError": {
const remoteTimeoutMs = readRemoteTimeoutMs(error.detail);
return new PreviewAutomationTimeoutError({
...context,
...remoteDiagnostics,
...(remoteTimeoutMs === undefined
? {}
: { timeoutMs: Math.min(context.timeoutMs, remoteTimeoutMs) }),
});
}
case "PreviewAutomationControlInterruptedError": {
const reason = decodeControlReason(error.detail);
return new PreviewAutomationControlInterruptedError({
Expand Down
31 changes: 31 additions & 0 deletions apps/server/src/preview/ServerBrowser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,37 @@ beforeEach(() => {
desktopConnections.length = 0;
});

it.live("preview_open reports its navigation timeout instead of the request budget", () =>
Effect.scoped(
Effect.gen(function* () {
const { broker } = yield* ready;
initialNavigation = async () => {
throw Object.assign(new Error("page.goto: Timeout 15000ms exceeded."), {
name: "TimeoutError",
});
};

const error = yield* broker
.invoke<PreviewAutomationStatus>({
scope,
operation: "open",
input: {
url: "http://localhost:1337/",
reuseExistingTab: false,
show: false,
},
})
.pipe(Effect.flip);

expect(error).toMatchObject({
_tag: "PreviewAutomationTimeoutError",
timeoutMs: 15_000,
});
expect(error.message).toBe("Preview automation open timed out after 15000ms.");
}),
).pipe(Effect.provide(layer)),
);

it.live.each(["desktop" as const, undefined])(
"explicit desktop targets ($0 runtime) fail without creating a server tab",
(runtime) =>
Expand Down
6 changes: 4 additions & 2 deletions apps/server/src/preview/ServerBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -902,7 +902,9 @@ const make = Effect.gen(function* () {
(desktop || adopted) && page.url() === snapshot.navStatus.url
? page.reload(options)
: page.goto(snapshot.navStatus.url, options)
).then(constVoid);
).then(constVoid, (cause: unknown) => {
throw ServerBrowserPage.toOperationError(cause, NAVIGATION_TIMEOUT_MS);
});

if (tab.control.agentId !== null) tab.control.track(tab.initialNavigation);
else void tab.initialNavigation.catch(constVoid);
Expand Down Expand Up @@ -1458,7 +1460,7 @@ const make = Effect.gen(function* () {
`Navigation to ${url} failed: ${ServerBrowserPage.parseNetError(message).description}`,
);
}
throw cause;
throw ServerBrowserPage.toOperationError(cause, timeout);
});
};

Expand Down
11 changes: 9 additions & 2 deletions apps/server/src/preview/ServerBrowserPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ export class ServerBrowserOperationError extends Error {
}
}

export const toOperationError = (cause: unknown): ServerBrowserOperationError => {
export const toOperationError = (
cause: unknown,
timeoutMs?: number,
): ServerBrowserOperationError => {
if (cause instanceof BrowserControlInterrupted)
return new ServerBrowserOperationError(
"PreviewAutomationControlInterruptedError",
Expand All @@ -51,7 +54,11 @@ export const toOperationError = (cause: unknown): ServerBrowserOperationError =>
const message = cause instanceof Error ? cause.message : String(cause);
const firstLine = message.split("\n")[0] ?? message;
if (cause instanceof Error && cause.name === "TimeoutError") {
return new ServerBrowserOperationError("PreviewAutomationTimeoutError", firstLine);
return new ServerBrowserOperationError(
"PreviewAutomationTimeoutError",
firstLine,
timeoutMs === undefined ? undefined : { timeoutMs },
);
}
if (
/while parsing selector|Unknown engine|Unexpected token|strict mode violation/i.test(message)
Expand Down
Loading