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

+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"),