diff --git a/scripts/verify/architecture-guard-baselines.json b/scripts/verify/architecture-guard-baselines.json index 9f4b7fac61..9eaeda6605 100644 --- a/scripts/verify/architecture-guard-baselines.json +++ b/scripts/verify/architecture-guard-baselines.json @@ -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 } } diff --git a/scripts/verify/architecture-guards.mjs b/scripts/verify/architecture-guards.mjs index f2d3c49019..caaa0e83ec 100644 --- a/scripts/verify/architecture-guards.mjs +++ b/scripts/verify/architecture-guards.mjs @@ -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. diff --git a/src/browser/PlaywrightScenarioRequest.test.ts b/src/browser/PlaywrightScenarioRequest.test.ts index 3fb7105855..2ac2450a30 100644 --- a/src/browser/PlaywrightScenarioRequest.test.ts +++ b/src/browser/PlaywrightScenarioRequest.test.ts @@ -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()); } }); diff --git a/src/browser/PlaywrightScenarioRequest.ts b/src/browser/PlaywrightScenarioRequest.ts index 8c53335b1e..a71c2aa633 100644 --- a/src/browser/PlaywrightScenarioRequest.ts +++ b/src/browser/PlaywrightScenarioRequest.ts @@ -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 = ( work: Promise, stepId: string, - deadline: number | undefined, + deadline: Deadline | undefined, signal: AbortSignal | undefined, ): Promise => new Promise((resolve, reject) => { @@ -597,8 +597,7 @@ const beforeStepEnds = ( ); }; 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(); const protectResponse = secrets.responseProtector(stepId, spec.save); diff --git a/src/desktop/AgentEnvComputer.ts b/src/desktop/AgentEnvComputer.ts index af747b679b..c1d7e78ea3 100644 --- a/src/desktop/AgentEnvComputer.ts +++ b/src/desktop/AgentEnvComputer.ts @@ -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 { - 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 diff --git a/src/desktop/DrawnScreenshot.ts b/src/desktop/DrawnScreenshot.ts index 9a0c5997a0..8911473f63 100644 --- a/src/desktop/DrawnScreenshot.ts +++ b/src/desktop/DrawnScreenshot.ts @@ -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 ( take: (signal: AbortSignal) => Promise, signal: AbortSignal, ): Promise => { - 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); diff --git a/src/domain/deadline.test.ts b/src/domain/deadline.test.ts new file mode 100644 index 0000000000..df4563eca9 --- /dev/null +++ b/src/domain/deadline.test.ts @@ -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, + ); + }, +); diff --git a/src/domain/deadline.ts b/src/domain/deadline.ts new file mode 100644 index 0000000000..14fe9c52fb --- /dev/null +++ b/src/domain/deadline.ts @@ -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; + } +} diff --git a/src/ghidra/GhidraClient.ts b/src/ghidra/GhidraClient.ts index 8852b4f165..00ba95cf21 100644 --- a/src/ghidra/GhidraClient.ts +++ b/src/ghidra/GhidraClient.ts @@ -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; @@ -819,7 +825,11 @@ export class GhidraClient { primary: GhidraSessionError, deadline?: ProviderStartupDeadline, ): Promise { - 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 { + #drainOutputBounded( + signal?: AbortSignal, + budgetMs = OUTPUT_DRAIN_MS, + ): Promise { 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; } diff --git a/src/kuna/KunaProjectExport.ts b/src/kuna/KunaProjectExport.ts index 9d684cb2b6..0a8848c3ae 100644 --- a/src/kuna/KunaProjectExport.ts +++ b/src/kuna/KunaProjectExport.ts @@ -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, diff --git a/src/process/ProviderDeadline.test.ts b/src/process/ProviderDeadline.test.ts index 7271328999..1e3d7697bc 100644 --- a/src/process/ProviderDeadline.test.ts +++ b/src/process/ProviderDeadline.test.ts @@ -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(); + } +}); diff --git a/src/process/ProviderDeadline.ts b/src/process/ProviderDeadline.ts index 26e1b0fb02..635a6b8435 100644 --- a/src/process/ProviderDeadline.ts +++ b/src/process/ProviderDeadline.ts @@ -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 => { 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"), diff --git a/src/rizin/RizinDebugSessionManager.ts b/src/rizin/RizinDebugSessionManager.ts index 9e42c484dd..944271c089 100644 --- a/src/rizin/RizinDebugSessionManager.ts +++ b/src/rizin/RizinDebugSessionManager.ts @@ -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> => { 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", diff --git a/tests/boundary/providers/RizinDebugSessionCoverage.part3.test.ts b/tests/boundary/providers/RizinDebugSessionCoverage.part3.test.ts index 0f433bdefd..55ed424ebe 100644 --- a/tests/boundary/providers/RizinDebugSessionCoverage.part3.test.ts +++ b/tests/boundary/providers/RizinDebugSessionCoverage.part3.test.ts @@ -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) diff --git a/tests/boundary/providers/ghidra/ghidraClient.test.ts b/tests/boundary/providers/ghidra/ghidraClient.test.ts index ab01de80e1..fbf67f08d9 100644 --- a/tests/boundary/providers/ghidra/ghidraClient.test.ts +++ b/tests/boundary/providers/ghidra/ghidraClient.test.ts @@ -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,