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
@@ -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"),
|
||||
|
||||
Reference in new issue
Block a user