mirror of
https://github.com/morluto/rea.git
synced 2026-11-08 07:46:28 +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();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
signal?.addEventListener("abort", this.#onExternalAbort, { once: true });
|
signal?.addEventListener("abort", this.#onExternalAbort, { once: true });
|
||||||
this.#timer = setTimeout(() => {
|
this.#armDeadlineTimer(timeoutMs);
|
||||||
if (this.#controller.signal.aborted) return;
|
|
||||||
this.#interruption = "timeout";
|
|
||||||
this.#abort(
|
|
||||||
new DOMException("Provider startup deadline elapsed", "TimeoutError"),
|
|
||||||
);
|
|
||||||
}, timeoutMs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Composite signal aborted by either the caller or the absolute deadline. */
|
/** 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 for at most one polling interval under the shared deadline signal. */
|
||||||
wait(milliseconds: number): Promise<AbortableWaitResult> {
|
wait(milliseconds: number): Promise<AbortableWaitResult> {
|
||||||
|
const budget = this.remainingMs();
|
||||||
return waitForAbortableDelay(
|
return waitForAbortableDelay(
|
||||||
Math.min(milliseconds, this.remainingMs()),
|
Math.min(milliseconds, budget),
|
||||||
this.signal,
|
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. */
|
/** Release the deadline timer and external abort listener. */
|
||||||
@@ -112,4 +114,28 @@ export class ProviderStartupDeadline {
|
|||||||
if (this.#timer !== undefined) clearTimeout(this.#timer);
|
if (this.#timer !== undefined) clearTimeout(this.#timer);
|
||||||
this.#timer = undefined;
|
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