diff --git a/src/process/ProviderDeadline.test.ts b/src/process/ProviderDeadline.test.ts index b43ce66542..05ee691dcb 100644 --- a/src/process/ProviderDeadline.test.ts +++ b/src/process/ProviderDeadline.test.ts @@ -33,3 +33,112 @@ it("aborts a bounded wait when its real monotonic deadline expires", async () => deadline.dispose(); } }); + +it("does not expire a future monotonic deadline on an early timer callback", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + let monotonic = 0; + vi.spyOn(performance, "now").mockImplementation(() => monotonic); + const deadline = new ProviderStartupDeadline(40); + try { + monotonic = 39; + await vi.advanceTimersByTimeAsync(40); + expect(deadline.remainingMs()).toBe(1); + expect(deadline.signal.aborted).toBe(false); + expect(deadline.interruption).toBeUndefined(); + } finally { + deadline.dispose(); + vi.useRealTimers(); + } +}); + +it("aborts when the re-armed timer reaches the absolute deadline", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + let monotonic = 0; + vi.spyOn(performance, "now").mockImplementation(() => monotonic); + const deadline = new ProviderStartupDeadline(40); + try { + monotonic = 39; + await vi.advanceTimersByTimeAsync(40); + expect(deadline.signal.aborted).toBe(false); + monotonic = 40; + await vi.advanceTimersByTimeAsync(1); + expect(deadline.signal.aborted).toBe(true); + expect(deadline.interruption).toBe("timeout"); + expect(deadline.remainingMs()).toBe(0); + } finally { + deadline.dispose(); + vi.useRealTimers(); + } +}); + +it("reports a wait that resolves at the deadline as aborted, not elapsed", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + let monotonic = 0; + vi.spyOn(performance, "now").mockImplementation(() => monotonic); + const deadline = new ProviderStartupDeadline(40); + try { + const pending = deadline.wait(500); + monotonic = 40; + await vi.advanceTimersByTimeAsync(40); + await expect(pending).resolves.toBe("aborted"); + } finally { + deadline.dispose(); + vi.useRealTimers(); + } +}); + +it("resolves a polling interval shorter than the budget as elapsed", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + let monotonic = 0; + vi.spyOn(performance, "now").mockImplementation(() => monotonic); + const deadline = new ProviderStartupDeadline(40); + try { + const pending = deadline.wait(10); + monotonic = 10; + await vi.advanceTimersByTimeAsync(10); + await expect(pending).resolves.toBe("elapsed"); + expect(deadline.signal.aborted).toBe(false); + expect(deadline.remainingMs()).toBe(30); + } finally { + deadline.dispose(); + vi.useRealTimers(); + } +}); + +it("keeps caller cancellation classified without reporting an exhausted budget", () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + let monotonic = 0; + vi.spyOn(performance, "now").mockImplementation(() => monotonic); + const caller = new AbortController(); + const deadline = new ProviderStartupDeadline(40, caller.signal); + try { + monotonic = 20; + caller.abort(new Error("caller stopped startup")); + expect(deadline.signal.aborted).toBe(true); + expect(deadline.interruption).toBe("cancelled"); + expect(deadline.remainingMs()).toBe(20); + } finally { + deadline.dispose(); + vi.useRealTimers(); + } +}); + +it("releases the timer and listener on dispose with a pending wait", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + let monotonic = 0; + vi.spyOn(performance, "now").mockImplementation(() => monotonic); + const deadline = new ProviderStartupDeadline(40); + try { + const pending = deadline.wait(500); + deadline.dispose(); + monotonic = 100; + await vi.advanceTimersByTimeAsync(100); + expect(deadline.signal.aborted).toBe(false); + // The deadline expired while disposed, so the clamped wait resolves + // "aborted" from the monotonic clock instead of polling past expiry. + await expect(pending).resolves.toBe("aborted"); + } finally { + deadline.dispose(); + vi.useRealTimers(); + } +}); diff --git a/src/process/ProviderDeadline.ts b/src/process/ProviderDeadline.ts index 33cb3a8d00..0b9390190b 100644 --- a/src/process/ProviderDeadline.ts +++ b/src/process/ProviderDeadline.ts @@ -61,13 +61,7 @@ export class ProviderStartupDeadline { return; } signal?.addEventListener("abort", this.#onExternalAbort, { once: true }); - this.#timer = setTimeout(() => { - if (this.#controller.signal.aborted) return; - this.#interruption = "timeout"; - this.#abort( - new DOMException("Provider startup deadline elapsed", "TimeoutError"), - ); - }, timeoutMs); + this.#armDeadlineTimer(timeoutMs); } /** Composite signal aborted by either the caller or the absolute deadline. */ @@ -90,10 +84,18 @@ export class ProviderStartupDeadline { /** Wait for at most one polling interval under the shared deadline signal. */ wait(milliseconds: number): Promise { + const budget = this.remainingMs(); return waitForAbortableDelay( - Math.min(milliseconds, this.remainingMs()), + Math.min(milliseconds, budget), this.signal, - ); + ).then((result) => { + // A wait clamped to the full budget can resolve at the deadline before + // the re-armed abort timer fires. Report the expired deadline as an + // abort so polling stops at the boundary instead of reporting "elapsed". + if (result === "elapsed" && performance.now() >= this.#deadlineAt) + return "aborted"; + return result; + }); } /** Release the deadline timer and external abort listener. */ @@ -112,4 +114,28 @@ export class ProviderStartupDeadline { if (this.#timer !== undefined) clearTimeout(this.#timer); this.#timer = undefined; } + + /** + * Arm (or re-arm) the timer that expires the absolute monotonic deadline. + * + * Node timers can fire a fraction of a millisecond before the deadline. + * Aborting on such an early delivery would expire a future deadline, so the + * callback re-checks the monotonic clock: it only classifies "timeout" once + * the deadline has actually expired, otherwise it re-arms for the residual + * budget. The residual budget strictly decreases, so the re-arm converges. + */ + #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); + } }