fix(process): re-arm the startup deadline timer instead of aborting on early delivery

The abort timer could fire a fraction of a millisecond before the absolute
monotonic deadline, classifying 'timeout' and aborting the shared signal
while remainingMs() still reported a positive budget. The callback now
re-checks the monotonic clock and re-arms for the residual budget when the
timer delivers early, so a future deadline can never expire prematurely.

wait() clamped to the full budget can still resolve at the deadline before
the re-armed abort timer fires; it now reports 'aborted' in that case so
polling stops at the boundary instead of reporting 'elapsed'. Caller
cancellation classification and the remaining budget reporting are
unchanged.

Fixes morluto/rea#2760
This commit is contained in:
nosmile99 committed 2026-10-13 12:23:19 +00:00
1 parent eeeb4ca379
commit 3e1a366340
2 files changed
+144 -9

No files matched your search

+109
View File
@@ -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();
}
});
+35 -9
View File
@@ -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<AbortableWaitResult> {
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);
}
}