* fix(browser): name web-bundle budgets and recover web deadlines Name the existing heap, per-script, and total web-bundle phases, and reject a per-script deadline above the total. Promote capture, CDP command, source-map decode, and source-map fetch deadlines at today's defaults. Name DevTools timeoutMs, and state that discovery, cleanup grace, byte budgets, and Electron hook retention have no runtime override. Refs #5910 Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(browser): apply selected web deadlines to the owning phase Keep HTTP and browser-socket discovery on the fixed discovery deadline so a short command_timeout_ms starts at the handshake. Apply scenario command_timeout_ms to launch startup and the primary connect session, and keep service-worker restoration on the fixed 5000 ms grace. Omit command_timeout_ms from recovery text when the operation does not accept it. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(browser): retain scenario commands that finish after their deadline A timed-out scenario command stays owned so cleanup can close a late browser or context and restore a mutation that lands after the deadline. Child storage replies use that deadline, scenario recovery names command_timeout_ms in the capture JSON, and the session test connection implements beginCleanupGrace. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(browser): keep child command timeouts as the public failure A nested command such as Page.enable that is delivered but never answered was stored as protocol_error, hiding the command name, selected deadline, and recovery. Store the typed timeout and assert that public error. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(browser): keep runtime cleanup on the fixed grace Switch runtime restoration to the cleanup grace before the first send so a short command_timeout_ms cannot fail an otherwise successful observation. Stop advertising command_timeout_ms on list_browser_targets, which never bounds a post-discovery command. Co-authored-by: morluto <morluto@users.noreply.github.com> * test(browser): prove a short command deadline cannot fail runtime cleanup Drive restoration through the runtime fixture. A cleanup command that outlives command_timeout_ms still succeeds, and a stalled restoration names the fixed 5000 ms grace. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(browser): name the effective source-map decode deadline codecBudgetLimitation reported a fixed 20-second deadline after a caller selected decode_timeout_ms. Result and Evidence limitations now name that effective millisecond deadline. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(browser): separate handshake and target-attach deadlines A browser-level Target.attachToTarget no longer shares the WebSocket handshake budget. Blocked service-worker attachment keeps discovery on its fixed deadline and applies command_timeout_ms to the later handshake and attach. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(browser): name the web-bundle resource each runtime exhausts Bun ignores the V8 old-space flag. A Windows Job reports the process commit limit, and other Bun hosts no longer suggest raising max_heap_mb. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(browser): cite the browser-socket discovery message budget WebSocket discovery enforces the 64 MiB protocol budget. Timeout recovery no longer advertises the 8 MiB HTTP document budget. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(browser): bound the blocked service-worker check before discovery The first registration check has its own fixed 10s deadline. Discovery, handshake, and Target.attachToTarget stay on their separate deadlines. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(browser): name the operand that stopped web-bundle admission Host heap pressure no longer claims the fixed result budget is exhausted. A Windows Bun allocation before the commit limit still names that limit. Co-authored-by: morluto <morluto@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: morluto <morluto@users.noreply.github.com>
709 lines
25 KiB
TypeScript
709 lines
25 KiB
TypeScript
import { createServer, type Socket } from "node:net";
|
|
|
|
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
|
|
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
|
|
import { CdpConnection } from "../../../src/browser/CdpConnection.js";
|
|
import { openCdpEndpoint } from "../../../src/browser/CdpTargetSession.js";
|
|
import {
|
|
PlaywrightScenarioServiceWorkers,
|
|
SERVICE_WORKER_POLICY_CHECK_TIMEOUT_MS,
|
|
} from "../../../src/browser/PlaywrightScenarioServiceWorkers.js";
|
|
import { AnalysisTimeoutError } from "../../../src/domain/analysisErrorCore.js";
|
|
import { CdpWebRuntimeProvider } from "../../../src/browser/execution/CdpWebRuntimeProvider.js";
|
|
import { CDP_DISCOVERY_RESPONSE_TIMEOUT_MS } from "../../../src/domain/browserDeadline.js";
|
|
import { BrowserObservationError } from "../../../src/domain/browserObservationError.js";
|
|
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
|
import {
|
|
browserObservationEndpointSchema,
|
|
inspectWebPageInputSchema,
|
|
} from "../../../src/domain/browserObservation.js";
|
|
import { observeWebExecutionInputSchema } from "../../../src/domain/webExecution.js";
|
|
import { WEB_RUNTIME_LIMITS } from "../../../src/domain/webRuntime.js";
|
|
import { V8InspectorProvider } from "../../../src/inspector/V8InspectorProvider.js";
|
|
import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js";
|
|
import { startRuntimeBrowser } from "../../fixtures/webRuntime.js";
|
|
import { startFakeV8Inspector } from "../../fixtures/inspector/fakeV8Inspector.js";
|
|
|
|
// Keep real socket scheduling; expire only a timer whose owning phase is ready.
|
|
beforeEach(() => {
|
|
vi.spyOn(globalThis, "setTimeout");
|
|
vi.spyOn(globalThis, "clearTimeout");
|
|
});
|
|
|
|
afterEach(() => vi.restoreAllMocks());
|
|
|
|
const activeTimers = (milliseconds: number) => {
|
|
const scheduled = vi.mocked(globalThis.setTimeout);
|
|
const cleared = vi.mocked(globalThis.clearTimeout);
|
|
return scheduled.mock.calls.flatMap(([callback, delay], index) => {
|
|
const result = scheduled.mock.results[index];
|
|
if (
|
|
delay !== milliseconds ||
|
|
result?.type !== "return" ||
|
|
cleared.mock.calls.some(([timer]) => timer === result.value)
|
|
)
|
|
return [];
|
|
return [{ callback, timer: result.value }];
|
|
});
|
|
};
|
|
|
|
const expireTimer = (milliseconds: number): void => {
|
|
const timers = activeTimers(milliseconds);
|
|
expect(timers).toHaveLength(1);
|
|
const timer = timers[0];
|
|
if (timer === undefined) throw new Error("Expected one active timer");
|
|
try {
|
|
timer.callback();
|
|
if (milliseconds === WEB_RUNTIME_LIMITS.commandTimeoutMs)
|
|
expect(globalThis.clearTimeout).toHaveBeenCalledWith(timer.timer);
|
|
} finally {
|
|
// The observation callback resolves naturally without cancelling its timer.
|
|
clearTimeout(timer.timer);
|
|
}
|
|
};
|
|
|
|
const expireEveryTimer = (milliseconds: number): void => {
|
|
const timers = activeTimers(milliseconds);
|
|
expect(timers.length).toBeGreaterThan(0);
|
|
for (const timer of timers) {
|
|
timer.callback();
|
|
clearTimeout(timer.timer);
|
|
}
|
|
};
|
|
|
|
it("bounds a stalled WebSocket handshake and releases its socket", async () => {
|
|
const sockets = new Set<Socket>();
|
|
const server = createServer((socket) => {
|
|
sockets.add(socket);
|
|
socket.on("close", () => sockets.delete(socket));
|
|
socket.resume();
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const address = server.address();
|
|
if (address === null || typeof address === "string")
|
|
throw new Error("Expected a TCP address");
|
|
try {
|
|
const rejection = expect(
|
|
CdpConnection.connect(
|
|
`ws://127.0.0.1:${String(address.port)}/target`,
|
|
"inspect_web_page",
|
|
AbortSignal.timeout(10_000),
|
|
),
|
|
).rejects.toMatchObject({ reason: "timeout" });
|
|
await vi.waitFor(() => expect(sockets.size).toBe(1), { timeout: 5_000 });
|
|
expireTimer(WEB_RUNTIME_LIMITS.commandTimeoutMs);
|
|
await rejection;
|
|
await vi.waitFor(() => expect(sockets.size).toBe(0));
|
|
} finally {
|
|
for (const socket of sockets) socket.destroy();
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
}
|
|
});
|
|
|
|
it.each(["Target.attachToTarget", "Page.enable", "Runtime.enable"])(
|
|
"times out %s after HTTP discovery and closes the target session",
|
|
async (hangOnMethod) => {
|
|
const browser = await startFakeCdpBrowser({ hangOnMethod });
|
|
try {
|
|
const pending = new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
}),
|
|
{ signal: AbortSignal.timeout(10_000) },
|
|
);
|
|
await vi.waitFor(
|
|
() =>
|
|
expect(browser.commands.map(({ method }) => method)).toContain(
|
|
hangOnMethod,
|
|
),
|
|
{ timeout: 5_000 },
|
|
);
|
|
expireTimer(WEB_RUNTIME_LIMITS.commandTimeoutMs);
|
|
const result = await pending;
|
|
if (result.ok) throw new Error("Expected a target command timeout");
|
|
expect(projectAnalysisError(result.error)).toMatchObject({
|
|
code: "provider_timeout",
|
|
category: "timeout",
|
|
});
|
|
expect(browser.commands.map(({ method }) => method)).toContain(
|
|
hangOnMethod,
|
|
);
|
|
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("ignores a late timed-out reply and keeps following commands correlated", async () => {
|
|
const browser = await startFakeCdpBrowser({ hangOnMethod: "Page.enable" });
|
|
const connection = await CdpConnection.connect(
|
|
browser.browserWebSocketUrl,
|
|
"inspect_web_page",
|
|
);
|
|
try {
|
|
const rejection = expect(
|
|
connection.send(
|
|
"Page.enable",
|
|
{},
|
|
undefined,
|
|
AbortSignal.timeout(10_000),
|
|
),
|
|
).rejects.toMatchObject({ reason: "timeout" });
|
|
await vi.waitFor(() => expect(browser.commands).toHaveLength(1), {
|
|
timeout: 5_000,
|
|
});
|
|
expireTimer(WEB_RUNTIME_LIMITS.commandTimeoutMs);
|
|
await rejection;
|
|
const command = browser.commands[0];
|
|
if (command === undefined)
|
|
throw new Error("Expected the timed-out command");
|
|
browser.emitRawMessage(JSON.stringify({ id: command.id, result: {} }));
|
|
await expect(connection.send("Runtime.enable")).resolves.toEqual({});
|
|
} finally {
|
|
await connection.close();
|
|
await browser.close();
|
|
}
|
|
});
|
|
|
|
it.each(["success", "rejection", "abort", "disconnect"])(
|
|
"releases handshake and command timers on %s",
|
|
async (outcome) => {
|
|
const cleared = vi.mocked(globalThis.clearTimeout);
|
|
const browser = await startFakeCdpBrowser({
|
|
...(outcome === "abort" || outcome === "success"
|
|
? { hangOnMethod: "Runtime.enable" }
|
|
: {}),
|
|
...(outcome === "disconnect" ? { closeOnMethod: "Runtime.enable" } : {}),
|
|
});
|
|
const controller = new AbortController();
|
|
const removeAbort = vi.spyOn(controller.signal, "removeEventListener");
|
|
const connection = await CdpConnection.connect(
|
|
browser.browserWebSocketUrl,
|
|
"inspect_web_page",
|
|
controller.signal,
|
|
);
|
|
try {
|
|
const pending = connection.send(
|
|
outcome === "rejection" ? "Fixture.unmodeledMethod" : "Runtime.enable",
|
|
{},
|
|
undefined,
|
|
controller.signal,
|
|
);
|
|
const assertion =
|
|
outcome === "success"
|
|
? expect(pending).resolves.toEqual({})
|
|
: expect(pending).rejects.toMatchObject({
|
|
_tag:
|
|
outcome === "abort"
|
|
? "AnalysisCancelledError"
|
|
: "BrowserObservationError",
|
|
});
|
|
if (outcome === "success") {
|
|
await vi.waitFor(() => expect(browser.commands).toHaveLength(1), {
|
|
timeout: 5_000,
|
|
});
|
|
// A healthy reply may take longer than the former 100 ms test budget.
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 250));
|
|
const command = browser.commands[0];
|
|
if (command === undefined)
|
|
throw new Error("Expected a pending command");
|
|
browser.emitRawMessage(JSON.stringify({ id: command.id, result: {} }));
|
|
}
|
|
if (outcome === "abort") controller.abort();
|
|
await assertion;
|
|
const scheduled = vi.mocked(globalThis.setTimeout);
|
|
const deadlines = scheduled.mock.calls.flatMap((call, index) =>
|
|
call[1] === WEB_RUNTIME_LIMITS.commandTimeoutMs
|
|
? [scheduled.mock.results[index]?.value]
|
|
: [],
|
|
);
|
|
expect(deadlines).toHaveLength(2);
|
|
for (const timer of deadlines)
|
|
expect(cleared).toHaveBeenCalledWith(timer);
|
|
expect(removeAbort).toHaveBeenCalledTimes(2);
|
|
} finally {
|
|
await connection.close();
|
|
await browser.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each(["Runtime.enable", "Debugger.enable"])(
|
|
"times out Inspector %s and preserves observations received before expiry",
|
|
async (hangOnMethod) => {
|
|
const inspector = await startFakeV8Inspector({
|
|
targetUrl: "https://fixture.example/app.js",
|
|
hangOnMethod,
|
|
});
|
|
try {
|
|
const pending = new V8InspectorProvider().observe(
|
|
{
|
|
inspector_endpoint: inspector.endpoint,
|
|
target_id: inspector.targetId,
|
|
observation_ms: 0,
|
|
},
|
|
{ signal: AbortSignal.timeout(10_000) },
|
|
);
|
|
await vi.waitFor(
|
|
() =>
|
|
expect(inspector.commands.map(({ method }) => method)).toContain(
|
|
hangOnMethod,
|
|
),
|
|
{ timeout: 5_000 },
|
|
);
|
|
expireTimer(WEB_RUNTIME_LIMITS.commandTimeoutMs);
|
|
const result = await pending;
|
|
if (result.ok) throw new Error("Expected an Inspector command timeout");
|
|
expect(projectAnalysisError(result.error)).toMatchObject({
|
|
code: "provider_timeout",
|
|
category: "timeout",
|
|
});
|
|
expect(result.error.partialObservation).toMatchObject({
|
|
capture: {
|
|
events_observed: hangOnMethod === "Debugger.enable" ? 1 : 0,
|
|
},
|
|
});
|
|
expect(result.error.userMessage ?? "").not.toContain(
|
|
"command_timeout_ms",
|
|
);
|
|
expect(result.error.userMessage ?? "").not.toContain(
|
|
"--command-timeout-ms",
|
|
);
|
|
} finally {
|
|
await inspector.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("keeps the chosen observation window independent of command deadlines", async () => {
|
|
const browser = await startFakeCdpBrowser();
|
|
const observationMs = WEB_RUNTIME_LIMITS.commandTimeoutMs + 250;
|
|
try {
|
|
const pending = new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
target_id: "allowed-page",
|
|
observation_ms: observationMs,
|
|
}),
|
|
);
|
|
await vi.waitFor(
|
|
() => expect(activeTimers(observationMs)).toHaveLength(1),
|
|
{
|
|
timeout: 5_000,
|
|
},
|
|
);
|
|
expect(activeTimers(WEB_RUNTIME_LIMITS.commandTimeoutMs)).toHaveLength(0);
|
|
expireTimer(observationMs);
|
|
const result = await pending;
|
|
expect(result.ok).toBe(true);
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
});
|
|
|
|
it("keeps a short command deadline off HTTP discovery and names Page.getFrameTree", async () => {
|
|
const browser = await startFakeCdpBrowser({
|
|
hangOnMethod: "Page.getFrameTree",
|
|
discoveryDelayMs: 180,
|
|
});
|
|
const provider = new CdpWebRuntimeProvider();
|
|
const started = performance.now();
|
|
try {
|
|
const pending = provider.observeExecution(
|
|
observeWebExecutionInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
target_id: "allowed-page",
|
|
observation_ms: 1,
|
|
command_timeout_ms: 40,
|
|
}),
|
|
{ signal: AbortSignal.timeout(10_000) },
|
|
);
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 50));
|
|
expect(activeTimers(40)).toHaveLength(0);
|
|
const result = await pending;
|
|
expect(performance.now() - started).toBeGreaterThan(300);
|
|
if (result.ok) throw new Error("Expected a command deadline");
|
|
const text = `${result.error.userMessage ?? ""}\n${result.error.message}`;
|
|
expect(text).toContain("40 ms command_timeout_ms");
|
|
expect(text).toContain("Page.getFrameTree");
|
|
expect(text).not.toContain("WebSocket handshake");
|
|
expect(browser.httpRequests.map(({ url }) => url)).toEqual(
|
|
expect.arrayContaining(["/json/version", "/json/list"]),
|
|
);
|
|
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
|
|
} finally {
|
|
await provider.close();
|
|
await browser.close();
|
|
}
|
|
});
|
|
|
|
it("gives browser-socket attach its own command deadline after the handshake", async () => {
|
|
const commandTimeoutMs = 80;
|
|
const browser = await startFakeCdpBrowser({
|
|
handshakeDelayMs: 50,
|
|
hangOnMethod: "Target.attachToTarget",
|
|
});
|
|
const provider = new CdpWebRuntimeProvider();
|
|
try {
|
|
const pending = provider.observeExecution(
|
|
observeWebExecutionInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
target_id: "allowed-page",
|
|
observation_ms: 1,
|
|
command_timeout_ms: commandTimeoutMs,
|
|
}),
|
|
{ signal: AbortSignal.timeout(10_000) },
|
|
);
|
|
await vi.waitFor(
|
|
() =>
|
|
expect(browser.commands.map(({ method }) => method)).toContain(
|
|
"Target.attachToTarget",
|
|
),
|
|
{ timeout: 5_000 },
|
|
);
|
|
expect(activeTimers(commandTimeoutMs)).toHaveLength(1);
|
|
expireTimer(commandTimeoutMs);
|
|
const result = await pending;
|
|
if (result.ok) throw new Error("Expected an attach deadline");
|
|
const text = `${result.error.userMessage ?? ""}\n${result.error.message}`;
|
|
expect(text).toContain(
|
|
`CDP command Target.attachToTarget exceeded its ${String(commandTimeoutMs)} ms command_timeout_ms`,
|
|
);
|
|
expect(text).not.toContain("WebSocket handshake exceeded");
|
|
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
|
|
} finally {
|
|
await provider.close();
|
|
await browser.close();
|
|
}
|
|
});
|
|
|
|
it("stops a stalled blocked-service-worker check before discovery", async () => {
|
|
const commandTimeoutMs = 25_000;
|
|
const browser = await startFakeCdpBrowser();
|
|
const frame = {
|
|
evaluate: () => new Promise<null>(() => undefined),
|
|
isDetached: () => false,
|
|
url: () => `${browser.allowedOrigin}/app`,
|
|
};
|
|
const workers = new PlaywrightScenarioServiceWorkers(
|
|
{
|
|
frames: () => [frame],
|
|
isClosed: () => false,
|
|
mainFrame: () => frame,
|
|
} as unknown as ConstructorParameters<
|
|
typeof PlaywrightScenarioServiceWorkers
|
|
>[0],
|
|
browser.endpoint,
|
|
"allowed-page",
|
|
commandTimeoutMs,
|
|
);
|
|
const pending = workers.initialize();
|
|
try {
|
|
await vi.waitFor(() =>
|
|
expect(activeTimers(SERVICE_WORKER_POLICY_CHECK_TIMEOUT_MS)).toHaveLength(
|
|
1,
|
|
),
|
|
);
|
|
expect(browser.httpRequests).toHaveLength(0);
|
|
expect(browser.commands).toHaveLength(0);
|
|
expect(activeTimers(commandTimeoutMs)).toHaveLength(0);
|
|
expireTimer(SERVICE_WORKER_POLICY_CHECK_TIMEOUT_MS);
|
|
const failure = await pending.catch((cause: unknown) => cause);
|
|
expect(failure).toBeInstanceOf(AnalysisTimeoutError);
|
|
if (!(failure instanceof AnalysisTimeoutError))
|
|
throw new Error("Expected a policy-check deadline");
|
|
const text = `${failure.userMessage ?? ""}\n${failure.message}`;
|
|
expect(text).toContain(
|
|
`${String(SERVICE_WORKER_POLICY_CHECK_TIMEOUT_MS)} ms deadline before CDP discovery`,
|
|
);
|
|
expect(text).toContain("command_timeout_ms does not apply");
|
|
expect(browser.httpRequests).toHaveLength(0);
|
|
expect(activeTimers(commandTimeoutMs)).toHaveLength(0);
|
|
} finally {
|
|
const closing = workers.close().catch(() => undefined);
|
|
await browser.close();
|
|
await closing;
|
|
await pending.catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("keeps blocked service-worker attachment outside the discovery deadline", async () => {
|
|
const commandTimeoutMs = 25_000;
|
|
const browser = await startFakeCdpBrowser({
|
|
hangOnMethod: "Target.attachToTarget",
|
|
});
|
|
const frame = {
|
|
evaluate: async () => null,
|
|
isDetached: () => false,
|
|
url: () => `${browser.allowedOrigin}/app`,
|
|
};
|
|
const workers = new PlaywrightScenarioServiceWorkers(
|
|
{
|
|
frames: () => [frame],
|
|
isClosed: () => false,
|
|
mainFrame: () => frame,
|
|
} as unknown as ConstructorParameters<
|
|
typeof PlaywrightScenarioServiceWorkers
|
|
>[0],
|
|
browser.endpoint,
|
|
"allowed-page",
|
|
commandTimeoutMs,
|
|
);
|
|
const pending = workers.initialize();
|
|
try {
|
|
await vi.waitFor(
|
|
() =>
|
|
expect(browser.commands.map(({ method }) => method)).toContain(
|
|
"Target.attachToTarget",
|
|
),
|
|
{ timeout: 5_000 },
|
|
);
|
|
expect(activeTimers(CDP_DISCOVERY_RESPONSE_TIMEOUT_MS)).toHaveLength(0);
|
|
expect(activeTimers(commandTimeoutMs)).toHaveLength(1);
|
|
} finally {
|
|
const closing = workers.close().catch(() => undefined);
|
|
await browser.close();
|
|
await closing;
|
|
await pending.catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("reports browser-socket discovery on its own deadline", async () => {
|
|
const sockets = new Set<Socket>();
|
|
const server = createServer((socket) => {
|
|
sockets.add(socket);
|
|
socket.on("close", () => sockets.delete(socket));
|
|
socket.resume();
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const address = server.address();
|
|
if (address === null || typeof address === "string")
|
|
throw new Error("Expected a TCP address");
|
|
const started = performance.now();
|
|
try {
|
|
const failure = await openCdpEndpoint(
|
|
browserObservationEndpointSchema.parse(
|
|
`ws://127.0.0.1:${String(address.port)}/devtools/browser/stalled`,
|
|
),
|
|
"list_browser_targets",
|
|
undefined,
|
|
80,
|
|
undefined,
|
|
30,
|
|
).catch((cause: unknown) => cause);
|
|
const elapsed = performance.now() - started;
|
|
expect(elapsed).toBeGreaterThan(60);
|
|
expect(elapsed).toBeLessThan(1_500);
|
|
expect(failure).toBeInstanceOf(BrowserObservationError);
|
|
if (!(failure instanceof BrowserObservationError))
|
|
throw new Error("Expected a browser-socket discovery failure");
|
|
const text = `${failure.userMessage ?? ""}\n${failure.message}`;
|
|
expect(text).toContain("80");
|
|
expect(text).not.toContain("30 ms");
|
|
expect(text).toContain("browser-socket discovery");
|
|
expect(text).toContain(
|
|
`${String(WEB_RUNTIME_LIMITS.protocolBytes)}-byte browser-socket message budget`,
|
|
);
|
|
expect(text).not.toContain("8388608-byte discovery document budget");
|
|
} finally {
|
|
for (const socket of sockets) socket.destroy();
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
}
|
|
});
|
|
|
|
it("applies a non-default handshake deadline and releases its socket", async () => {
|
|
const sockets = new Set<Socket>();
|
|
const server = createServer((socket) => {
|
|
sockets.add(socket);
|
|
socket.on("close", () => sockets.delete(socket));
|
|
socket.resume();
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const address = server.address();
|
|
if (address === null || typeof address === "string")
|
|
throw new Error("Expected a TCP address");
|
|
try {
|
|
let captured: unknown;
|
|
const pending = CdpConnection.connect(
|
|
`ws://127.0.0.1:${String(address.port)}/target`,
|
|
"inspect_web_page",
|
|
AbortSignal.timeout(10_000),
|
|
{ commandTimeoutMs: 1_500 },
|
|
).then(
|
|
() => {
|
|
throw new Error("Expected a handshake deadline");
|
|
},
|
|
(cause: unknown) => {
|
|
captured = cause;
|
|
throw cause;
|
|
},
|
|
);
|
|
const rejection = expect(pending).rejects.toMatchObject({
|
|
reason: "timeout",
|
|
});
|
|
await vi.waitFor(() => expect(sockets.size).toBe(1), { timeout: 5_000 });
|
|
expireTimer(1_500);
|
|
await rejection;
|
|
expect(captured).toBeInstanceOf(BrowserObservationError);
|
|
if (!(captured instanceof BrowserObservationError))
|
|
throw new Error("Expected a handshake deadline");
|
|
expect(captured.userMessage).toContain("1500 ms command_timeout_ms");
|
|
await vi.waitFor(() => expect(sockets.size).toBe(0));
|
|
} finally {
|
|
for (const socket of sockets) socket.destroy();
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
}
|
|
});
|
|
|
|
it("applies a non-default command deadline after HTTP discovery", async () => {
|
|
const browser = await startFakeCdpBrowser({ hangOnMethod: "Page.enable" });
|
|
try {
|
|
const pending = new CdpBrowserProvider().inspectPage(
|
|
inspectWebPageInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
target_id: "allowed-page",
|
|
observation_ms: 0,
|
|
command_timeout_ms: 1_500,
|
|
}),
|
|
{ signal: AbortSignal.timeout(10_000) },
|
|
);
|
|
await vi.waitFor(
|
|
() =>
|
|
expect(browser.commands.map(({ method }) => method)).toContain(
|
|
"Page.enable",
|
|
),
|
|
{ timeout: 5_000 },
|
|
);
|
|
expireTimer(1_500);
|
|
const result = await pending;
|
|
if (result.ok) throw new Error("Expected a selected command timeout");
|
|
expect(result.error.userMessage ?? "").toContain(
|
|
"1500 ms command_timeout_ms",
|
|
);
|
|
await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0));
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
});
|
|
|
|
it("reschedules a raised command deadline onto the fixed cleanup grace", async () => {
|
|
const browser = await startFakeCdpBrowser({ hangOnMethod: "Runtime.enable" });
|
|
const connection = await CdpConnection.connect(
|
|
browser.browserWebSocketUrl,
|
|
"inspect_web_page",
|
|
undefined,
|
|
{ commandTimeoutMs: 60_000 },
|
|
);
|
|
try {
|
|
const pending = connection.send("Runtime.enable");
|
|
await vi.waitFor(() => expect(browser.commands).toHaveLength(1), {
|
|
timeout: 5_000,
|
|
});
|
|
expect(activeTimers(60_000)).toHaveLength(1);
|
|
connection.beginCleanupGrace(5_000);
|
|
expect(activeTimers(60_000)).toHaveLength(0);
|
|
expireTimer(5_000);
|
|
const error = await pending.catch((cause: unknown) => cause);
|
|
expect(error).toMatchObject({ reason: "timeout" });
|
|
if (!(error instanceof BrowserObservationError))
|
|
throw new Error("Expected cleanup grace timeout");
|
|
expect(error.userMessage).toContain("5000 ms");
|
|
expect(error.userMessage).not.toContain("Increase command_timeout_ms");
|
|
expect(error.remediationAction).toContain("no runtime override");
|
|
} finally {
|
|
await connection.close();
|
|
await browser.close();
|
|
}
|
|
});
|
|
|
|
it("keeps runtime cleanup on the fixed grace when command_timeout_ms is shorter", async () => {
|
|
const commandTimeoutMs = 250;
|
|
const browser = await startRuntimeBrowser({
|
|
hangOnMethod: "Profiler.disable",
|
|
});
|
|
const provider = new CdpWebRuntimeProvider();
|
|
try {
|
|
const pending = provider.observeExecution(
|
|
observeWebExecutionInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
target_id: "allowed-page",
|
|
observation_ms: 1,
|
|
command_timeout_ms: commandTimeoutMs,
|
|
}),
|
|
{ signal: AbortSignal.timeout(10_000) },
|
|
);
|
|
await vi.waitFor(
|
|
() =>
|
|
expect(browser.commands.map(({ method }) => method)).toContain(
|
|
"Profiler.disable",
|
|
),
|
|
{ timeout: 5_000 },
|
|
);
|
|
expect(activeTimers(commandTimeoutMs)).toHaveLength(0);
|
|
expect(
|
|
activeTimers(WEB_RUNTIME_LIMITS.cleanupTimeoutMs).length,
|
|
).toBeGreaterThan(0);
|
|
const hung = browser.commands.find(
|
|
({ method }) => method === "Profiler.disable",
|
|
);
|
|
if (hung === undefined) throw new Error("Expected Profiler.disable");
|
|
browser.emitRawMessage(
|
|
JSON.stringify({
|
|
id: hung.id,
|
|
...(hung.sessionId === undefined ? {} : { sessionId: hung.sessionId }),
|
|
result: {},
|
|
}),
|
|
);
|
|
const result = await pending;
|
|
expect(result.ok).toBe(true);
|
|
} finally {
|
|
await provider.close();
|
|
await browser.close();
|
|
}
|
|
});
|
|
|
|
it("names the fixed cleanup grace when runtime restoration stalls", async () => {
|
|
const commandTimeoutMs = 250;
|
|
const browser = await startRuntimeBrowser({
|
|
hangOnMethod: "Profiler.disable",
|
|
});
|
|
const provider = new CdpWebRuntimeProvider();
|
|
try {
|
|
const pending = provider.observeExecution(
|
|
observeWebExecutionInputSchema.parse({
|
|
cdp_endpoint: browser.endpoint,
|
|
target_id: "allowed-page",
|
|
observation_ms: 1,
|
|
command_timeout_ms: commandTimeoutMs,
|
|
}),
|
|
{ signal: AbortSignal.timeout(10_000) },
|
|
);
|
|
await vi.waitFor(
|
|
() =>
|
|
expect(browser.commands.map(({ method }) => method)).toContain(
|
|
"Profiler.disable",
|
|
),
|
|
{ timeout: 5_000 },
|
|
);
|
|
expect(activeTimers(commandTimeoutMs)).toHaveLength(0);
|
|
expireEveryTimer(WEB_RUNTIME_LIMITS.cleanupTimeoutMs);
|
|
const result = await pending;
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) throw new Error("Expected cleanup grace failure");
|
|
const text = JSON.stringify(result.error.diagnostics);
|
|
expect(text).toContain("5000 ms");
|
|
expect(text).toContain("no runtime override");
|
|
expect(text).not.toContain(
|
|
`${String(commandTimeoutMs)} ms command_timeout_ms`,
|
|
);
|
|
expect(text).not.toContain("Increase command_timeout_ms");
|
|
} finally {
|
|
await provider.close();
|
|
await browser.close();
|
|
}
|
|
});
|