* 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>
317 lines
11 KiB
TypeScript
317 lines
11 KiB
TypeScript
import { access } from "node:fs/promises";
|
|
import { expect, it, vi } from "vitest";
|
|
import {
|
|
AnalysisCancelledError,
|
|
AnalysisResourceConstraintError,
|
|
AnalysisTimeoutError,
|
|
} from "../../domain/analysisErrorCore.js";
|
|
import { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js";
|
|
import {
|
|
ProviderProcessSupervisor,
|
|
spawnOwnedProviderProcess,
|
|
} from "../../process/ProviderProcess.js";
|
|
import { WEB_SOURCE_MAP_LIMITS } from "../../domain/webSourceLocation.js";
|
|
import {
|
|
runSourceMapCommand,
|
|
SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES,
|
|
} from "./SourceMapCommand.js";
|
|
|
|
const runChild = (
|
|
signal: AbortSignal | undefined,
|
|
script: string,
|
|
onRuntime?: (path: string) => void,
|
|
onLaunch?: () => void,
|
|
) =>
|
|
runSourceMapCommand(
|
|
"{}",
|
|
"/probe/source.map",
|
|
signal === undefined ? undefined : { signal },
|
|
{
|
|
prepareOwnership: async () => {},
|
|
createRuntime: async () => {
|
|
const runtime = await PrivateRuntimeRoot.create({
|
|
prefix: "rea-source-map-command-test-",
|
|
});
|
|
onRuntime?.(runtime.path);
|
|
return runtime;
|
|
},
|
|
launcher: async (options) => {
|
|
const child = await spawnOwnedProviderProcess({
|
|
...options,
|
|
arguments: ["-e", script],
|
|
});
|
|
onLaunch?.();
|
|
return child;
|
|
},
|
|
},
|
|
);
|
|
|
|
const exitsAfterHoldingOutput =
|
|
'const {spawn}=require("node:child_process"); spawn(process.execPath,["-e","setTimeout(()=>{},400)"],{stdio:["ignore","inherit","inherit"]}); process.stderr.write("physical exit 7"); process.exit(7)';
|
|
const exitsThenDescendantExceedsStderrBudget =
|
|
'const {spawn}=require("node:child_process"); spawn(process.execPath,["-e",`setTimeout(()=>{process.stderr.write("x".repeat(1024*1024+64)); setInterval(()=>{},1000)},25)`],{stdio:["ignore","inherit","inherit"]}); process.stderr.write("physical exit 7"); process.exit(7)';
|
|
|
|
it("preserves the exact object reason for an already-aborted caller", async () => {
|
|
const reason = { caller: "opaque source-map request" };
|
|
const controller = new AbortController();
|
|
controller.abort(reason);
|
|
const result = await runSourceMapCommand(
|
|
"{}",
|
|
"/probe/source.map",
|
|
{ signal: controller.signal },
|
|
{
|
|
prepareOwnership: async () => {},
|
|
launcher: async () => {
|
|
throw new Error("must not launch");
|
|
},
|
|
},
|
|
);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) throw new Error("expected cancellation");
|
|
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
|
expect(result.error.cause).toBe(reason);
|
|
});
|
|
|
|
it("preserves exact caller reason when cancellation precedes process exit", async () => {
|
|
const reason = { caller: "active source-map cancellation" };
|
|
const controller = new AbortController();
|
|
let runtimePath: string | undefined;
|
|
const result = await runChild(
|
|
controller.signal,
|
|
'process.stdout.write("ready\\n"); setInterval(()=>{},1000)',
|
|
(path) => {
|
|
runtimePath = path;
|
|
},
|
|
() => setTimeout(() => controller.abort(reason), 0),
|
|
);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) throw new Error("expected cancellation");
|
|
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
|
expect(result.error.cause).toBe(reason);
|
|
if (runtimePath === undefined) throw new Error("runtime was not acquired");
|
|
await expect(access(runtimePath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
|
|
it("preserves an observed nonzero exit before caller cancellation during output drain", async () => {
|
|
const reason = { caller: "later than owned exit" };
|
|
const controller = new AbortController();
|
|
let runtimePath: string | undefined;
|
|
let observedBeforeAbort:
|
|
| { readonly code: number | null; readonly signal: NodeJS.Signals | null }
|
|
| undefined;
|
|
const originalWait = ProviderProcessSupervisor.prototype.waitForOutputClose;
|
|
const waitSpy = vi
|
|
.spyOn(ProviderProcessSupervisor.prototype, "waitForOutputClose")
|
|
.mockImplementation(async function (
|
|
this: ProviderProcessSupervisor,
|
|
timeoutMs,
|
|
signal,
|
|
) {
|
|
const exit = this.observedExit();
|
|
if (exit?.code === 7 && !controller.signal.aborted) {
|
|
observedBeforeAbort = exit;
|
|
controller.abort(reason);
|
|
return false;
|
|
}
|
|
return originalWait.call(this, timeoutMs, signal);
|
|
});
|
|
let result;
|
|
try {
|
|
result = await runChild(
|
|
controller.signal,
|
|
exitsAfterHoldingOutput,
|
|
(path) => {
|
|
runtimePath = path;
|
|
},
|
|
);
|
|
} finally {
|
|
waitSpy.mockRestore();
|
|
}
|
|
expect(observedBeforeAbort).toEqual({ code: 7, signal: null });
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) throw new Error("expected physical failure");
|
|
expect(result.error._tag).toBe("ProviderAdapterError");
|
|
expect(result.error.diagnostics).toMatchObject({
|
|
exit_code: 7,
|
|
output_complete: false,
|
|
});
|
|
expect(result.error.capturedOutput?.truncated).toBe(true);
|
|
if (runtimePath === undefined) throw new Error("runtime was not acquired");
|
|
await expect(access(runtimePath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
|
|
it("classifies the same nonzero exit without cancellation", async () => {
|
|
const result = await runChild(undefined, exitsAfterHoldingOutput);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) throw new Error("expected physical failure");
|
|
expect(result.error).toMatchObject({
|
|
_tag: "ProviderAdapterError",
|
|
diagnostics: {
|
|
exit_code: 7,
|
|
stderr: "physical exit 7",
|
|
output_complete: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("stops a held drain after late output exceeds budget but keeps the exit primary", async () => {
|
|
const startedAt = performance.now();
|
|
const result = await runChild(
|
|
undefined,
|
|
exitsThenDescendantExceedsStderrBudget,
|
|
);
|
|
const elapsedMs = performance.now() - startedAt;
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) throw new Error("expected physical failure");
|
|
expect(result.error._tag).toBe("ProviderAdapterError");
|
|
expect(result.error.diagnostics).toMatchObject({
|
|
exit_code: 7,
|
|
output_limit_reached: true,
|
|
output_complete: false,
|
|
});
|
|
expect(result.error.diagnostics?.stderr).toContain("physical exit 7");
|
|
expect(result.error.capturedOutput?.truncated).toBe(true);
|
|
expect(elapsedMs).toBeLessThan(5_000);
|
|
});
|
|
|
|
it("preserves an observed process-memory limit over the codec exit status", async () => {
|
|
const controller = new AbortController();
|
|
const reason = { caller: "after memory-limit exit" };
|
|
let observedBeforeAbort:
|
|
| { readonly code: number | null; readonly signal: NodeJS.Signals | null }
|
|
| undefined;
|
|
const originalWait = ProviderProcessSupervisor.prototype.waitForOutputClose;
|
|
const waitSpy = vi
|
|
.spyOn(ProviderProcessSupervisor.prototype, "waitForOutputClose")
|
|
.mockImplementation(async function (
|
|
this: ProviderProcessSupervisor,
|
|
timeoutMs,
|
|
signal,
|
|
) {
|
|
const exit = this.observedExit();
|
|
if (exit?.code === 3 && !controller.signal.aborted) {
|
|
observedBeforeAbort = exit;
|
|
controller.abort(reason);
|
|
return false;
|
|
}
|
|
return originalWait.call(this, timeoutMs, signal);
|
|
});
|
|
let result;
|
|
try {
|
|
result = await runSourceMapCommand(
|
|
"{}",
|
|
"/probe/source.map",
|
|
{ signal: controller.signal },
|
|
{
|
|
prepareOwnership: async () => {},
|
|
launcher: async (options) => {
|
|
const spawned = await spawnOwnedProviderProcess({
|
|
...options,
|
|
arguments: [
|
|
"-e",
|
|
'const {spawn}=require("node:child_process"); spawn(process.execPath,["-e","setTimeout(()=>{},400)"],{stdio:["ignore","inherit","inherit"]}); process.stderr.write("memory limit reached"); process.exit(3)',
|
|
],
|
|
});
|
|
let exited = false;
|
|
spawned.process.once("exit", () => {
|
|
exited = true;
|
|
});
|
|
Object.defineProperty(spawned.process, "memoryLimitReached", {
|
|
get: () => exited,
|
|
});
|
|
return spawned;
|
|
},
|
|
},
|
|
);
|
|
} finally {
|
|
waitSpy.mockRestore();
|
|
}
|
|
expect(observedBeforeAbort).toEqual({ code: 3, signal: null });
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) throw new Error("expected memory-limit failure");
|
|
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
|
|
expect(result.error).toMatchObject({
|
|
resource: "memory",
|
|
reportedLimits: {
|
|
codec_process_memory_limit_bytes: SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES,
|
|
},
|
|
capturedOutput: { stderr: "memory limit reached" },
|
|
});
|
|
expect(result.error.capturedOutput?.truncated).toBe(true);
|
|
});
|
|
|
|
it("keeps a timeout distinct from caller cancellation", async () => {
|
|
vi.useFakeTimers({
|
|
toFake: ["setTimeout", "clearTimeout", "performance"],
|
|
});
|
|
let runtimePath: string | undefined;
|
|
try {
|
|
const result = await runSourceMapCommand(
|
|
"{}",
|
|
"/probe/source.map",
|
|
undefined,
|
|
{
|
|
prepareOwnership: async () => {},
|
|
createRuntime: async () => {
|
|
const runtime = await PrivateRuntimeRoot.create({
|
|
prefix: "rea-source-map-timeout-test-",
|
|
});
|
|
runtimePath = runtime.path;
|
|
return runtime;
|
|
},
|
|
launcher: async () => {
|
|
await vi.advanceTimersByTimeAsync(
|
|
WEB_SOURCE_MAP_LIMITS.decodeTimeoutMs + 1,
|
|
);
|
|
throw new Error("launcher settled after deadline");
|
|
},
|
|
},
|
|
);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) throw new Error("expected timeout");
|
|
expect(result.error).toBeInstanceOf(AnalysisTimeoutError);
|
|
if (runtimePath === undefined) throw new Error("runtime was not acquired");
|
|
await expect(access(runtimePath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("applies a non-default decode deadline and releases its runtime", async () => {
|
|
vi.useFakeTimers({
|
|
toFake: ["setTimeout", "clearTimeout", "performance"],
|
|
});
|
|
let runtimePath: string | undefined;
|
|
try {
|
|
const result = await runSourceMapCommand(
|
|
"{}",
|
|
"/probe/source.map",
|
|
undefined,
|
|
{
|
|
prepareOwnership: async () => {},
|
|
createRuntime: async () => {
|
|
const runtime = await PrivateRuntimeRoot.create({
|
|
prefix: "rea-source-map-selected-deadline-",
|
|
});
|
|
runtimePath = runtime.path;
|
|
return runtime;
|
|
},
|
|
launcher: async () => {
|
|
await vi.advanceTimersByTimeAsync(251);
|
|
throw new Error("launcher settled after deadline");
|
|
},
|
|
},
|
|
250,
|
|
);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) throw new Error("expected timeout");
|
|
expect(result.error).toBeInstanceOf(AnalysisTimeoutError);
|
|
expect(result.error.userMessage).toContain("250");
|
|
expect(result.error.userMessage).toContain("decode_timeout_ms");
|
|
if (runtimePath === undefined) throw new Error("runtime was not acquired");
|
|
await expect(access(runtimePath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|