mirror of
https://github.com/morluto/rea.git
synced 2026-10-27 01:45:32 +00:00
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:
1 parent
eeeb4ca379
commit
3e1a366340
2 files changed
+144
-9
No files matched your search
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user