refactor(process): own deadlines as one monotonic type with a finalize budget (#10723)

* refactor(process): own deadlines as one monotonic type with a finalize budget

Deadline (src/domain/deadline) holds a monotonic work budget and a separate finalize budget that starts when finalization begins. ProviderStartupDeadline is built on it and exposes finalize(), so a failed Ghidra startup drains launcher output under its own budget and the caller's signal instead of the expired startup signal. The six wall-clock deadline sums in src move onto Deadline; a counted guard keeps the one page-clock conversion and rejects new copies.

* refactor(process): share one finalize window across repeated beginFinalize calls
This commit is contained in:
morluto authored and GitHub committed 2026-11-15 13:45:27 +08:00
1 parent 52d6725d63
commit a67dbe07a4
15 files changed
+292 -56

No files matched your search

@@ -392,5 +392,8 @@
"src/domain/apiContract/parseGraphqlContract.ts": 1,
"src/javascript/analysis/JavaScriptAnalysisWorkerServer.ts": 2,
"src/wasm/WabtFailures.ts": 1
},
"wall-clock-deadline": {
"src/browser/PlaywrightScenarioRequest.ts": 1
}
}
+12
View File
@@ -225,6 +225,18 @@ checkCounted(
["src/domain/analysisErrorCore.ts"],
);
// A deadline is a monotonic budget owned by `Deadline` in src/domain/deadline;
// a wall-clock sum moves with clock changes and has no finalize phase. The
// remaining copy converts a monotonic budget for a browser page, which can
// only read its own wall clock.
checkCounted(
"wall-clock deadline computation (use Deadline from src/domain/deadline)",
"wall-clock-deadline",
files,
/Date\.now\(\)\s*\+/gu,
[],
);
// Byte-format parsers read untrusted input. Bounds, truncation and failure
// kind belong to BinaryReader (src/native/metadata/BinaryContainer.ts) and
// the ParseFailure algebra (src/domain/parseFailure.ts), not to each parser.
@@ -411,12 +411,15 @@ describe("request route cleanup", () => {
const page = fakePage({});
const secrets = resolved(parsed);
const cancel = new AbortController();
const began = Date.now();
const began = performance.now();
const unroute = vi.spyOn(page, "unroute").mockImplementation(async () => {
if (end === "cancellation")
cancel.abort("cancelled during final route cleanup");
else {
const now = vi.spyOn(Date, "now").mockReturnValue(began + 5001);
// The step deadline is monotonic; move that clock past it.
const now = vi
.spyOn(performance, "now")
.mockReturnValue(began + 6_000);
onTestFinished(() => now.mockRestore());
}
});
+20 -21
View File
@@ -1,3 +1,4 @@
import { Deadline } from "../domain/deadline.js";
import { jsonStringByteLength } from "../domain/jsonStringByteLength.js";
import { jsonValueByteLength } from "../domain/jsonValueByteLength.js";
import type { JsonValue } from "../domain/jsonValue.js";
@@ -197,7 +198,12 @@ interface PageRequest {
readonly method: string;
readonly headers: [string, string][];
readonly body: string | undefined;
readonly deadline: number | undefined;
/**
* The step deadline on the page's wall clock. The host keeps a monotonic
* deadline and converts what is left of it when the request is built,
* because the page cannot read the host's monotonic clock.
*/
readonly deadlineEpochMs: number | undefined;
/** Return the response text up to this many characters, for a step that saves values from it. */
readonly readLimit: number | undefined;
}
@@ -224,9 +230,9 @@ const sendFromPage = (
"Request steps require a page without a controlling service worker so cancellation can guard dispatch.",
};
const remaining =
request.deadline === undefined
request.deadlineEpochMs === undefined
? undefined
: request.deadline - Date.now();
: request.deadlineEpochMs - Date.now();
if (remaining !== undefined && remaining <= 0)
return { error: "The request ran out of time before it was sent." };
const controller = new AbortController();
@@ -305,7 +311,7 @@ export const performScenarioRequest = async (input: {
const deadline =
action.timeout_ms === undefined
? undefined
: Date.now() + action.timeout_ms;
: Deadline.after(action.timeout_ms);
const pageOrigin = originOf(page.url());
if (pageOrigin !== startOrigin)
throw new ScenarioRequestError(
@@ -355,8 +361,7 @@ export const performScenarioRequest = async (input: {
if (action.json !== undefined && !hasContentType)
appendHeader("content-type", "application/json");
// A step the boundary abandoned, or whose time ran out while values were read, sends nothing.
const remaining = deadline === undefined ? undefined : deadline - Date.now();
if (signal?.aborted === true || (remaining !== undefined && remaining <= 0))
if (signal?.aborted === true || deadline?.expired() === true)
throw new ScenarioRequestError(
`Request step ${action.step_id} ran out of time before its request was sent.`,
);
@@ -369,7 +374,8 @@ export const performScenarioRequest = async (input: {
method: action.method,
headers,
body: json === undefined ? undefined : JSON.stringify(json),
deadline,
deadlineEpochMs:
deadline === undefined ? undefined : Date.now() + deadline.remainingMs(),
readLimit:
action.save.length > 0 ? MAX_SAVED_RESPONSE_CHARACTERS : undefined,
};
@@ -381,7 +387,7 @@ export const performScenarioRequest = async (input: {
method: action.method,
headers: [],
body: request.body ?? null,
deadline: deadline ?? null,
deadlineEpochMs: request.deadlineEpochMs ?? null,
readLimit: request.readLimit ?? null,
},
envelopeLimit,
@@ -419,10 +425,7 @@ export const performScenarioRequest = async (input: {
await route.fallback();
return;
}
if (
signal?.aborted === true ||
(deadline !== undefined && Date.now() >= deadline)
)
if (signal?.aborted === true || deadline?.expired() === true)
await route.abort("aborted");
else {
await route.fallback();
@@ -563,13 +566,10 @@ const namesResponse = (
/** Publication must still belong to the step after its last asynchronous boundary. */
const assertResponseSaveActive = (
stepId: string,
deadline: number | undefined,
deadline: Deadline | undefined,
signal: AbortSignal | undefined,
): void => {
if (
signal?.aborted === true ||
(deadline !== undefined && Date.now() >= deadline)
)
if (signal?.aborted === true || deadline?.expired() === true)
throw new ScenarioRequestError(
`Step ${stepId} ended before its saved response was read, so nothing was saved.`,
);
@@ -579,7 +579,7 @@ const assertResponseSaveActive = (
const beforeStepEnds = <T>(
work: Promise<T>,
stepId: string,
deadline: number | undefined,
deadline: Deadline | undefined,
signal: AbortSignal | undefined,
): Promise<T> =>
new Promise<T>((resolve, reject) => {
@@ -597,8 +597,7 @@ const beforeStepEnds = <T>(
);
};
work.catch(() => undefined);
const remaining =
deadline === undefined ? undefined : deadline - Date.now();
const remaining = deadline?.remainingMs();
if (
signal?.aborted === true ||
(remaining !== undefined && remaining <= 0)
@@ -643,7 +642,7 @@ export const awaitSavedResponse = (
},
): SavedResponseWait => {
const { stepId, spec, startOrigin, timeout, secrets, signal } = step;
const deadline = timeout === undefined ? undefined : Date.now() + timeout;
const deadline = timeout === undefined ? undefined : Deadline.after(timeout);
const pattern = pathPattern(spec.path);
const started = new Set<Request>();
const protectResponse = secrets.responseProtector(stepId, spec.save);
+3 -2
View File
@@ -6,6 +6,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
import { z } from "zod";
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
import { Deadline } from "../domain/deadline.js";
import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js";
import { StructuredContentMcpClient } from "../providers/StructuredContentMcpClient.js";
import { validateMcpHttpResponse } from "../providers/Utf8McpResponse.js";
@@ -640,7 +641,7 @@ export class AgentEnvComputer {
}
async #waitReady(signal: AbortSignal): Promise<void> {
const deadline = Date.now() + READY_TIMEOUT_MS;
const deadline = Deadline.after(READY_TIMEOUT_MS);
for (;;) {
const status = readinessSchema.parse(
await this.#request(
@@ -652,7 +653,7 @@ export class AgentEnvComputer {
if (status.ready) return;
const waiting = status.checks.filter((check) => !check.ok);
const failed = waiting.find((check) => check.final === true);
if (failed !== undefined || Date.now() > deadline)
if (failed !== undefined || deadline.expired())
throw new ComputerStartFailure(
this.redacted(
failed === undefined
+4 -3
View File
@@ -2,6 +2,7 @@ import { setTimeout as delay } from "node:timers/promises";
import { isOneColourPng } from "../browser/PngVisualDiff.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { Deadline } from "../domain/deadline.js";
/** How often REA retakes a first screenshot the desktop has not drawn on, and the time the retries share. */
const DRAWN_POLL_MS = 500;
@@ -31,15 +32,15 @@ export const drawnScreenshot = async <Shot extends Screenshot>(
take: (signal: AbortSignal) => Promise<Shot>,
signal: AbortSignal,
): Promise<Shot> => {
const deadline = Date.now() + DRAWN_WAIT_MS;
const deadline = Deadline.after(DRAWN_WAIT_MS);
let shot = await take(signal);
while (!shot.isError && undrawn(shot)) {
const left = deadline - Date.now();
const left = deadline.remainingMs();
if (left <= 0) break;
await delay(Math.min(DRAWN_POLL_MS, left), undefined, { signal });
const retry = AbortSignal.any([
signal,
AbortSignal.timeout(Math.max(1, deadline - Date.now())),
AbortSignal.timeout(Math.max(1, deadline.remainingMs())),
]);
try {
shot = await take(retry);
+61
View File
@@ -0,0 +1,61 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { Deadline } from "./deadline.js";
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "performance"] });
});
afterEach(() => {
vi.useRealTimers();
});
it.each([-3_600_000, 3_600_000])(
"keeps the work budget when the wall clock jumps by %i ms",
(jump) => {
const deadline = Deadline.after(1_000);
vi.advanceTimersByTime(250);
vi.setSystemTime(Date.now() + jump);
expect(deadline.remainingMs()).toBe(750);
expect(deadline.expired()).toBe(false);
vi.advanceTimersByTime(750);
expect(deadline.remainingMs()).toBe(0);
expect(deadline.expired()).toBe(true);
},
);
it("starts the finalize budget when finalization begins, after work expired", () => {
const deadline = Deadline.after(100, { finalizeMs: 500 });
vi.advanceTimersByTime(300);
expect(deadline.expired()).toBe(true);
const finalize = deadline.beginFinalize();
expect(finalize.remainingMs()).toBe(500);
vi.advanceTimersByTime(499);
expect(finalize.expired()).toBe(false);
vi.advanceTimersByTime(1);
expect(finalize.expired()).toBe(true);
expect(finalize.beginFinalize().remainingMs()).toBe(0);
});
it("shares one finalize window across repeated beginFinalize calls", () => {
const deadline = Deadline.after(100, { finalizeMs: 500 });
const first = deadline.beginFinalize();
vi.advanceTimersByTime(300);
const second = deadline.beginFinalize();
expect(second).toBe(first);
expect(second.remainingMs()).toBe(200);
});
it("grants no finalize budget unless one is declared", () => {
expect(Deadline.after(100).beginFinalize().expired()).toBe(true);
});
it.each([-1, Number.NaN, Number.POSITIVE_INFINITY, 2_147_483_648])(
"rejects a budget outside the Node timer range (%d)",
(milliseconds) => {
expect(() => Deadline.after(milliseconds)).toThrow(RangeError);
expect(() => Deadline.after(1, { finalizeMs: milliseconds })).toThrow(
RangeError,
);
},
);
+67
View File
@@ -0,0 +1,67 @@
import { MAX_NODE_TIMER_DELAY_MS } from "./analysisResourceLimits.js";
const validBudget = (name: string, milliseconds: number): number => {
if (
!Number.isFinite(milliseconds) ||
milliseconds < 0 ||
milliseconds > MAX_NODE_TIMER_DELAY_MS
)
throw new RangeError(
`${name} must be a finite millisecond count from 0 to ${String(MAX_NODE_TIMER_DELAY_MS)}`,
);
return milliseconds;
};
/**
* One monotonic deadline with a work budget and a separate finalize budget.
*
* The work budget starts when the deadline is created. The finalize budget
* starts when finalization begins, so work that used its whole budget, or
* overran it, still gets a bounded window to drain output, record evidence and
* release what it owns. Wall-clock changes never move either budget.
*/
export class Deadline {
readonly #endsAt: number;
#finalize: Deadline | undefined;
private constructor(
/** Work budget in milliseconds. */
readonly workMs: number,
/** Budget a finalizer gets once work stops, in milliseconds. */
readonly finalizeMs: number,
) {
this.#endsAt = performance.now() + workMs;
}
/** A deadline whose work budget starts now. Omitting `finalizeMs` grants none. */
static after(
workMs: number,
options: { readonly finalizeMs?: number } = {},
): Deadline {
return new Deadline(
validBudget("Deadline work budget", workMs),
validBudget("Deadline finalize budget", options.finalizeMs ?? 0),
);
}
/** Whole milliseconds of work budget left; zero once it has elapsed. */
remainingMs(): number {
return Math.max(0, Math.ceil(this.#endsAt - performance.now()));
}
/** Whether the work budget has elapsed. */
expired(): boolean {
return performance.now() >= this.#endsAt;
}
/**
* Start the finalize budget, whether or not work expired. The first call
* starts it; later calls return the same deadline, so cleanup stages share
* one window instead of each restarting it. The result grants no further
* budget.
*/
beginFinalize(): Deadline {
this.#finalize ??= new Deadline(this.finalizeMs, 0);
return this.#finalize;
}
}
+27 -11
View File
@@ -123,6 +123,12 @@ import {
} from "./GhidraCheckpoint.js";
const SHUTDOWN_TIMEOUT_MS = 10_000;
/**
* Bound for draining launcher output into failure evidence. A failed startup
* gets it as its finalize budget, separate from the startup budget it may
* have exhausted.
*/
const OUTPUT_DRAIN_MS = 1_000;
const SESSION_ROOT = ghidraSessionRoot();
type GhidraStartupAttempt = SharedStartupAttempt<GhidraStartResult>;
@@ -819,7 +825,11 @@ export class GhidraClient {
primary: GhidraSessionError,
deadline?: ProviderStartupDeadline,
): Promise<GhidraStartResult> {
await this.#drainOutputBounded(deadline?.signal);
const finalization = deadline?.finalize();
await this.#drainOutputBounded(
finalization?.signal,
finalization?.deadline.remainingMs(),
);
const refreshed = this.#failure(
primary.kind,
primary.message,
@@ -936,6 +946,7 @@ export class GhidraClient {
const deadline = new ProviderStartupDeadline(
this.#options.readStartupTimeoutMs?.() ?? this.#options.startupTimeoutMs,
signal,
{ finalizeMs: OUTPUT_DRAIN_MS },
);
try {
const result = await this.#startWithin(deadline);
@@ -1687,19 +1698,24 @@ export class GhidraClient {
});
}
#drainOutputBounded(signal?: AbortSignal): Promise<boolean> {
#drainOutputBounded(
signal?: AbortSignal,
budgetMs = OUTPUT_DRAIN_MS,
): Promise<boolean> {
if (this.#outputDrainPromise !== undefined) return this.#outputDrainPromise;
const process = this.#process;
if (process === undefined) return Promise.resolve(true);
const pending = process.waitForOutputClose(1_000, signal).then((closed) => {
if (this.#process !== process) return;
if (!closed) this.#outputDrainIncomplete = true;
const snapshot = process.snapshot();
this.#processSnapshot = this.#outputDrainIncomplete
? { ...snapshot, diagnosticTruncated: true, outputIncomplete: true }
: snapshot;
return closed;
});
const pending = process
.waitForOutputClose(budgetMs, signal)
.then((closed) => {
if (this.#process !== process) return;
if (!closed) this.#outputDrainIncomplete = true;
const snapshot = process.snapshot();
this.#processSnapshot = this.#outputDrainIncomplete
? { ...snapshot, diagnosticTruncated: true, outputIncomplete: true }
: snapshot;
return closed;
});
this.#outputDrainPromise = pending.then((closed) => closed ?? false);
return this.#outputDrainPromise;
}
+3 -2
View File
@@ -16,6 +16,7 @@ import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { Deadline } from "../domain/deadline.js";
import {
AnalysisArtifactChangedError,
cancelledBy,
@@ -274,7 +275,7 @@ export const exportKunaProject = async (
configuration,
request.deadline,
);
const deadline = Date.now() + acceptedDeadline.ms;
const deadline = Deadline.after(acceptedDeadline.ms);
const input = join(request.workspace, "input", basename(request.targetPath));
await mkdir(join(request.workspace, "input"), { mode: 0o700 });
await mkdir(request.output, { recursive: true, mode: 0o700 });
@@ -301,7 +302,7 @@ export const exportKunaProject = async (
arguments: arguments_,
workspace: request.workspace,
operation: KUNA_EXPORT_OPERATION,
timeoutMs: deadline - Date.now(),
timeoutMs: deadline.remainingMs(),
deadline: acceptedDeadline,
outputBytes,
signal,
+20
View File
@@ -303,3 +303,23 @@ it("reports no remaining time after terminal expiry near the monotonic boundary"
vi.useRealTimers();
}
});
it("finalizes an expired startup under its own budget and the caller's signal", async () => {
const caller = new AbortController();
const deadline = new ProviderStartupDeadline(100, caller.signal, {
finalizeMs: 1_000,
});
try {
await vi.advanceTimersByTimeAsync(100);
expect(deadline.signal.aborted).toBe(true);
expect(deadline.interruption).toBe("timeout");
const finalization = deadline.finalize();
// Startup expiry does not interrupt finalization; only the caller does.
expect(finalization.signal).toBe(caller.signal);
expect(finalization.signal?.aborted).toBe(false);
expect(finalization.deadline.remainingMs()).toBe(1_000);
} finally {
deadline.dispose();
}
});
+36 -12
View File
@@ -1,3 +1,5 @@
import { Deadline } from "../domain/deadline.js";
/** Result of an abort-aware interval wait. */
export type AbortableWaitResult = "elapsed" | "aborted";
@@ -22,15 +24,24 @@ export const waitForAbortableDelay = (
});
};
/** The window a startup failure finalizes under. */
export interface StartupFinalization {
/** Monotonic finalize budget, started when finalization began. */
readonly deadline: Deadline;
/** The caller's signal. Startup expiry does not interrupt finalization. */
readonly signal: AbortSignal | undefined;
}
/**
* One absolute deadline shared by every phase of a provider startup handshake.
* One absolute deadline shared by every phase of a provider startup handshake,
* with a separate finalize budget for the work a failed startup still owes.
*
* The deadline owns its timer and the listener attached to an optional caller
* signal. Adapters must dispose it after startup succeeds or fails.
*/
export class ProviderStartupDeadline {
readonly #controller = new AbortController();
readonly #deadlineAt: number;
readonly #budget: Deadline;
readonly #externalSignal: AbortSignal | undefined;
readonly #onExternalAbort: () => void;
#timer: NodeJS.Timeout | undefined;
@@ -39,14 +50,15 @@ export class ProviderStartupDeadline {
constructor(
readonly timeoutMs: number,
signal?: AbortSignal,
options: { readonly finalizeMs?: number } = {},
) {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
throw new RangeError("Provider startup timeout must be positive");
this.#deadlineAt = performance.now() + timeoutMs;
this.#budget = Deadline.after(timeoutMs, options);
this.#externalSignal = signal;
this.#onExternalAbort = () => {
if (this.#controller.signal.aborted) return;
if (performance.now() >= this.#deadlineAt) {
if (this.#budget.expired()) {
this.#interruption = "timeout";
this.#abort(
new DOMException("Provider startup deadline elapsed", "TimeoutError"),
@@ -72,8 +84,7 @@ export class ProviderStartupDeadline {
/** First control path that interrupted startup, if any. */
get interruption(): "cancelled" | "timeout" | undefined {
return (
this.#interruption ??
(performance.now() >= this.#deadlineAt ? "timeout" : undefined)
this.#interruption ?? (this.#budget.expired() ? "timeout" : undefined)
);
}
@@ -83,7 +94,7 @@ export class ProviderStartupDeadline {
// deadline, so an elapsed timer must not report time left to spend. A
// terminal timeout also cannot regain budget after later clock queries.
if (this.#interruption === "timeout") return 0;
return Math.max(0, Math.ceil(this.#deadlineAt - performance.now()));
return this.#budget.remainingMs();
}
/**
@@ -96,7 +107,8 @@ export class ProviderStartupDeadline {
const startedAt = performance.now();
// Absolute target: the earlier of the requested polling interval and the
// absolute monotonic deadline.
const targetAt = Math.min(startedAt + milliseconds, this.#deadlineAt);
const targetAt =
startedAt + Math.min(milliseconds, this.#budget.remainingMs());
const poll = (): Promise<AbortableWaitResult> => {
const budget = this.remainingMs();
const residual = Math.max(0, targetAt - performance.now());
@@ -105,12 +117,11 @@ export class ProviderStartupDeadline {
this.signal,
).then((result) => {
if (result === "aborted") return "aborted";
const now = performance.now();
// The monotonic deadline expired: a full-budget wait resolves as an
// abort so polling stops at the boundary.
if (now >= this.#deadlineAt) return "aborted";
if (this.#budget.expired()) return "aborted";
// The polling interval elapsed without expiring the deadline.
if (now >= targetAt) return "elapsed";
if (performance.now() >= targetAt) return "elapsed";
// A timer fired before the absolute target; keep waiting.
return poll();
});
@@ -118,6 +129,19 @@ export class ProviderStartupDeadline {
return poll();
}
/**
* Begin finalizing a failed or interrupted startup. The finalize budget
* starts now and only the caller's signal interrupts it, so work that
* exhausted the startup budget can still drain evidence and release what it
* owns.
*/
finalize(): StartupFinalization {
return {
deadline: this.#budget.beginFinalize(),
signal: this.#externalSignal,
};
}
/** Release the deadline timer and external abort listener. */
dispose(): void {
this.#clearTimer();
@@ -145,7 +169,7 @@ export class ProviderStartupDeadline {
this.#timer = setTimeout(() => {
this.#timer = undefined;
if (this.#controller.signal.aborted) return;
if (performance.now() >= this.#deadlineAt) {
if (this.#budget.expired()) {
this.#interruption = "timeout";
this.#abort(
new DOMException("Provider startup deadline elapsed", "TimeoutError"),
+3 -2
View File
@@ -1,5 +1,6 @@
import { RIZIN_DEBUGGER_PROVIDER_IDENTITY } from "./RizinDebuggerProviderIdentity.js";
import { persistentDebuggerHostSupported } from "../process/PersistentDebuggerHost.js";
import { Deadline } from "../domain/deadline.js";
import { randomUUID } from "node:crypto";
import { isUtf8 } from "node:buffer";
import { open, readlink, realpath, stat } from "node:fs/promises";
@@ -1811,7 +1812,7 @@ const waitForCommandFrames = async (
signal?: AbortSignal,
): Promise<Result<RizinCommandCapture, RizinCommandInterruption>> => {
const maxCommandOutputBytes = commandOutput.value;
const deadline = Date.now() + timeoutMs;
const deadline = Deadline.after(timeoutMs);
const output: string[] = [];
let outputBytes = 0;
let outputInvalidUtf8 = false;
@@ -1892,7 +1893,7 @@ const waitForCommandFrames = async (
capture,
});
}
const remaining = deadline - Date.now();
const remaining = deadline.remainingMs();
if (remaining <= 0)
return err({
kind: "timeout",
@@ -102,7 +102,9 @@ it.skipIf(process.platform === "win32")(
expect(started.ok).toBe(true);
if (!started.ok) return;
const clock = vi.spyOn(Date, "now");
// The command deadline is monotonic: it starts on the first reading and
// expires on the fourth, while the marker frame is still queued.
const clock = vi.spyOn(performance, "now");
clock
.mockReturnValueOnce(100)
.mockReturnValueOnce(100)
@@ -1996,6 +1996,31 @@ describe("GhidraClient process output drain before startup failure", () => {
});
});
it("drains output under a finalize budget after the startup deadline expires", async () => {
const { launcher, finishOutput } = preExitedProcessLauncher({
output: oomeOutput,
closeOutput: true,
// Output closes 700 ms after launch: after the 400 ms startup deadline
// whenever launch beat it, and inside the 1 s finalize budget.
outputDelayMs: 700,
});
onTestFinished(finishOutput);
const client = clientFor(launcher, { startupTimeoutMs: 400 });
const result = await client.start();
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.diagnostics).toMatchObject({
exit_code: 73,
stdout: { text: oomeOutput },
failure_output: { complete: true, stdout: { text: oomeOutput } },
});
expect(result.error.diagnostics).not.toHaveProperty(
"output_incomplete",
true,
);
});
it("preserves an observed process exit when cancellation interrupts diagnostic drain", async () => {
const { launcher, finishOutput } = preExitedProcessLauncher({
output: oomeOutput,