mirror of
https://github.com/morluto/rea.git
synced 2026-10-30 19:35:56 +00:00
* fix(cli): preserve import-map option descriptions * fix(process): report no remaining time after a provider deadline fires Node timers can fire while performance.now() is still a fraction of a millisecond short of the monotonic deadline. ProviderStartupDeadline then reported interruption "timeout" and an aborted signal while remainingMs() returned ceil(0.x) = 1, so polling loops guarded by remainingMs() > 0 (Hopper and Ghidra startup) could run past an elapsed deadline, and the real-timer test "aborts a bounded wait when its real monotonic deadline expires" failed intermittently in CI (expected 1 to be 0). Once the deadline's own timer has classified a timeout, report zero remaining time. A deterministic regression test fires the timer with the monotonic clock 0.4 ms short of the deadline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(process): keep terminal deadline expiry sticky --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
159 lines
5.5 KiB
TypeScript
159 lines
5.5 KiB
TypeScript
/** Result of an abort-aware interval wait. */
|
|
export type AbortableWaitResult = "elapsed" | "aborted";
|
|
|
|
/** Wait for an interval while removing the supplied abort listener on settle. */
|
|
export const waitForAbortableDelay = (
|
|
milliseconds: number,
|
|
signal?: AbortSignal,
|
|
): Promise<AbortableWaitResult> => {
|
|
if (signal?.aborted === true) return Promise.resolve("aborted");
|
|
return new Promise((resolve) => {
|
|
const settle = (result: AbortableWaitResult): void => {
|
|
clearTimeout(timer);
|
|
signal?.removeEventListener("abort", onAbort);
|
|
resolve(result);
|
|
};
|
|
const onAbort = (): void => settle("aborted");
|
|
const timer = setTimeout(
|
|
() => settle("elapsed"),
|
|
Math.max(0, milliseconds),
|
|
);
|
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
});
|
|
};
|
|
|
|
/**
|
|
* One absolute deadline shared by every phase of a provider startup handshake.
|
|
*
|
|
* 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 #externalSignal: AbortSignal | undefined;
|
|
readonly #onExternalAbort: () => void;
|
|
#timer: NodeJS.Timeout | undefined;
|
|
#interruption: "cancelled" | "timeout" | undefined;
|
|
|
|
constructor(
|
|
readonly timeoutMs: number,
|
|
signal?: AbortSignal,
|
|
) {
|
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
throw new RangeError("Provider startup timeout must be positive");
|
|
this.#deadlineAt = performance.now() + timeoutMs;
|
|
this.#externalSignal = signal;
|
|
this.#onExternalAbort = () => {
|
|
if (this.#controller.signal.aborted) return;
|
|
if (performance.now() >= this.#deadlineAt) {
|
|
this.#interruption = "timeout";
|
|
this.#abort(
|
|
new DOMException("Provider startup deadline elapsed", "TimeoutError"),
|
|
);
|
|
return;
|
|
}
|
|
this.#interruption = "cancelled";
|
|
this.#abort(signal?.reason);
|
|
};
|
|
if (signal?.aborted === true) {
|
|
this.#onExternalAbort();
|
|
return;
|
|
}
|
|
signal?.addEventListener("abort", this.#onExternalAbort, { once: true });
|
|
this.#armDeadlineTimer(timeoutMs);
|
|
}
|
|
|
|
/** Composite signal aborted by either the caller or the absolute deadline. */
|
|
get signal(): AbortSignal {
|
|
return this.#controller.signal;
|
|
}
|
|
|
|
/** First control path that interrupted startup, if any. */
|
|
get interruption(): "cancelled" | "timeout" | undefined {
|
|
return (
|
|
this.#interruption ??
|
|
(performance.now() >= this.#deadlineAt ? "timeout" : undefined)
|
|
);
|
|
}
|
|
|
|
/** Milliseconds still available to the next startup phase. */
|
|
remainingMs(): number {
|
|
// Node timers can fire a fraction of a millisecond before the monotonic
|
|
// 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()));
|
|
}
|
|
|
|
/**
|
|
* Wait for at most one polling interval under the shared deadline signal.
|
|
*
|
|
* Both timers may fire before their monotonic targets. Retain the earlier
|
|
* of the polling target and deadline, and wait again for any residual time.
|
|
*/
|
|
wait(milliseconds: number): Promise<AbortableWaitResult> {
|
|
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 poll = (): Promise<AbortableWaitResult> => {
|
|
const budget = this.remainingMs();
|
|
const residual = Math.max(0, targetAt - performance.now());
|
|
return waitForAbortableDelay(
|
|
Math.min(residual, budget),
|
|
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";
|
|
// The polling interval elapsed without expiring the deadline.
|
|
if (now >= targetAt) return "elapsed";
|
|
// A timer fired before the absolute target; keep waiting.
|
|
return poll();
|
|
});
|
|
};
|
|
return poll();
|
|
}
|
|
|
|
/** Release the deadline timer and external abort listener. */
|
|
dispose(): void {
|
|
this.#clearTimer();
|
|
this.#externalSignal?.removeEventListener("abort", this.#onExternalAbort);
|
|
}
|
|
|
|
#abort(reason: unknown): void {
|
|
this.#clearTimer();
|
|
this.#externalSignal?.removeEventListener("abort", this.#onExternalAbort);
|
|
this.#controller.abort(reason);
|
|
}
|
|
|
|
#clearTimer(): void {
|
|
if (this.#timer !== undefined) clearTimeout(this.#timer);
|
|
this.#timer = undefined;
|
|
}
|
|
|
|
/**
|
|
* Arm (or re-arm) the timer that expires the absolute monotonic deadline.
|
|
*
|
|
* Re-check the clock before classifying expiry, and retain the residual
|
|
* budget when the timer arrives early.
|
|
*/
|
|
#armDeadlineTimer(milliseconds: number): void {
|
|
this.#timer = setTimeout(() => {
|
|
this.#timer = undefined;
|
|
if (this.#controller.signal.aborted) return;
|
|
if (performance.now() >= this.#deadlineAt) {
|
|
this.#interruption = "timeout";
|
|
this.#abort(
|
|
new DOMException("Provider startup deadline elapsed", "TimeoutError"),
|
|
);
|
|
return;
|
|
}
|
|
this.#armDeadlineTimer(this.remainingMs());
|
|
}, milliseconds);
|
|
}
|
|
}
|