Files
rea/src/javascript/sourceMaps/SourceMapCommand.test.ts
T
0155b89953 fix(browser): name web-bundle budgets and recover web deadlines (#6036)
* 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>
2026-10-22 05:47:38 +08:00

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();
}
});