mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 19:23:24 +00:00
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:
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
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user