From 5391c1eb4b678b57dbfdab9da6a5654d87d8a55f Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Sun, 6 Sep 2026 17:40:12 +0200 Subject: [PATCH 1/3] fix(testing): run microtasks between timers in FakeTime async advancement --- async/retry_test.ts | 10 ++-- testing/time.ts | 87 +++++++++++++++++++---------- testing/time_test.ts | 130 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 191 insertions(+), 36 deletions(-) diff --git a/async/retry_test.ts b/async/retry_test.ts index c423a745cf04..0f4c4f2d9605 100644 --- a/async/retry_test.ts +++ b/async/retry_test.ts @@ -295,12 +295,9 @@ Deno.test("retry() checks backoff function timings", async (t) => { await time.nextAsync(); assertEquals(time.now - startTime, 7000); - - await time.nextAsync(); - assertEquals(time.now - startTime, 15000); assertEquals(resolved, false); - await time.runMicrotasks(); + await time.nextAsync(); assertEquals(time.now - startTime, 15000); assertEquals(resolved, true); @@ -320,6 +317,9 @@ Deno.test("retry() caps backoff at maxTimeout", async () => { multiplier: 2, jitter: 0, }); + // The final attempt rejects during the last advancement, so the handler + // must be attached before advancing. + const rejection = assertRejects(() => promise, RetryError); const startTime = time.now; await time.nextAsync(); // 1000ms (1000 * 2^0) @@ -334,7 +334,7 @@ Deno.test("retry() caps backoff at maxTimeout", async () => { await time.nextAsync(); // 1500ms capped (would be 8000) assertEquals(time.now - startTime, 5500); - await assertRejects(() => promise, RetryError); + await rejection; }); Deno.test("retry() only retries errors that are retriable with `isRetriable` option", async () => { diff --git a/testing/time.ts b/testing/time.ts index 53463e4a3e1a..cbe18437bd53 100644 --- a/testing/time.ts +++ b/testing/time.ts @@ -241,6 +241,38 @@ function nextDueNode(): DueNode | null { } } +/** + * Runs the earliest live timer due at or before `limit`, moving `now` to its + * deadline first. Returns `false` when no such timer exists. + */ +function runNextTimer(limit: number): boolean { + let dueNode: DueNode | null = dueTree.min(); + while (dueNode && dueNode.due <= limit) { + const timer: Timer | undefined = dueNode.timers.shift(); + if (timer && dueNodes.has(timer.id)) { + now = timer.due; + if (timer.repeat) { + const due: number = timer.due + timer.delay; + let dueNode: DueNode | null = dueTree.find({ due } as DueNode); + if (dueNode === null) { + dueNode = { due, timers: [] }; + dueTree.insert(dueNode); + } + dueNode.timers.push({ ...timer, due }); + dueNodes.set(timer.id, dueNode); + } else { + dueNodes.delete(timer.id); + } + timer.callback.apply(null, timer.args); + return true; + } else if (!timer) { + dueTree.remove(dueNode); + dueNode = dueTree.min(); + } + } + return false; +} + let startedAt: number; let now: number; let initializedAt: number; @@ -505,29 +537,7 @@ export class FakeTime { `Cannot set current time in the past, time must be >= ${now}: received ${value}`, ); } - let dueNode: DueNode | null = dueTree.min(); - while (dueNode && dueNode.due <= value) { - const timer: Timer | undefined = dueNode.timers.shift(); - if (timer && dueNodes.has(timer.id)) { - now = timer.due; - if (timer.repeat) { - const due: number = timer.due + timer.delay; - let dueNode: DueNode | null = dueTree.find({ due } as DueNode); - if (dueNode === null) { - dueNode = { due, timers: [] }; - dueTree.insert(dueNode); - } - dueNode.timers.push({ ...timer, due }); - dueNodes.set(timer.id, dueNode); - } else { - dueNodes.delete(timer.id); - } - timer.callback.apply(null, timer.args); - } else if (!timer) { - dueTree.remove(dueNode); - dueNode = dueTree.min(); - } - } + while (runNextTimer(value)); now = value; } @@ -661,7 +671,9 @@ export class FakeTime { /** * Runs all pending microtasks then adds the specified number of milliseconds to the fake time. - * This will call any functions waiting to be called between the current and new fake time. + * This will call any functions waiting to be called between the current and new fake time, + * running all pending microtasks after each one. Timers scheduled by those callbacks or + * microtasks are also run if they fall due before the new fake time. * * @example Usage * ```ts @@ -684,7 +696,19 @@ export class FakeTime { */ async tickAsync(ms = 0) { await this.runMicrotasks(); - this.now += ms; + await this.#advanceAsync(now + ms); + } + + /** + * Runs every timer due at or before `target`, draining microtasks after + * each one so continuations observe the deadline they were created at. + * Callers must drain microtasks before calling. + */ + async #advanceAsync(target: number) { + while (runNextTimer(target)) { + await this.runMicrotasks(); + } + now = target; } /** @@ -718,6 +742,7 @@ export class FakeTime { /** * Runs all pending microtasks then advances time to when the next scheduled timer is due. + * Every timer due at that time is run, with all pending microtasks run after each one. * If there are no pending timers, time will not be changed. * * @example Usage @@ -744,7 +769,10 @@ export class FakeTime { */ async nextAsync(): Promise { await this.runMicrotasks(); - return this.next(); + const next = nextDueNode(); + if (!next) return false; + await this.#advanceAsync(next.due); + return true; } /** @@ -781,7 +809,8 @@ export class FakeTime { * Advances time forward to the next due timer until there are no pending timers remaining. * If the timers create additional timers, they will be run too. If there is an interval, * time will keep advancing forward until the interval is cleared. - * Runs all pending microtasks before each timer. + * Runs all pending microtasks before each timer, so timers scheduled from + * microtasks are run too. * * @example Usage * ```ts @@ -804,9 +833,7 @@ export class FakeTime { * ``` */ async runAllAsync() { - while (!dueTree.isEmpty()) { - await this.nextAsync(); - } + while (await this.nextAsync()); } /** diff --git a/testing/time_test.ts b/testing/time_test.ts index 8414bbf200e0..54811ba2b597 100644 --- a/testing/time_test.ts +++ b/testing/time_test.ts @@ -12,7 +12,7 @@ import { } from "@std/assert"; import { FakeTime, TimeError } from "./time.ts"; import { _internals } from "./_time.ts"; -import { assertSpyCall, spy, type SpyCall } from "./mock.ts"; +import { assertSpyCall, assertSpyCalls, spy, type SpyCall } from "./mock.ts"; import { deadline, delay } from "@std/async"; function fromNow(): (..._args: unknown[]) => number { @@ -565,6 +565,95 @@ Deno.test("FakeTime.nextAsync() runs all microtasks and next timer", async () => assertEquals(seq, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); }); +Deno.test("FakeTime.tickAsync() runs microtasks between timers so continuations observe the deadline they were created at", async () => { + using time = new FakeTime(0); + const seq: number[] = []; + + setTimeout(async () => { + await Promise.resolve(); + seq.push(Date.now()); + setTimeout(() => seq.push(Date.now()), 10); + }, 10); + await time.tickAsync(20); + + assertEquals(seq, [10, 20]); + assertEquals(time.now, 20); +}); + +Deno.test("FakeTime.tickAsync() runs microtasks between timers with equal deadlines", async () => { + using time = new FakeTime(0); + const seq: string[] = []; + + setTimeout(() => { + seq.push("a"); + Promise.resolve().then(() => seq.push("microtask")); + }, 10); + setTimeout(() => seq.push("b"), 10); + await time.tickAsync(10); + + assertEquals(seq, ["a", "microtask", "b"]); +}); + +Deno.test("FakeTime.tickAsync() lets a microtask cancel a timer due in the same advancement", async () => { + using time = new FakeTime(0); + const cb = spy(); + + const id = setTimeout(cb, 10); + setTimeout(() => { + Promise.resolve().then(() => clearTimeout(id)); + }, 5); + await time.tickAsync(20); + + assertSpyCalls(cb, 0); + assertEquals(time.now, 20); +}); + +Deno.test("FakeTime.tickAsync() drains deep promise chains between timers", async () => { + using time = new FakeTime(0); + const seq: number[] = []; + + setTimeout(async () => { + for (let i = 0; i < 10; i++) await Promise.resolve(); + seq.push(Date.now()); + }, 5); + setTimeout(() => seq.push(Date.now()), 10); + await time.tickAsync(10); + + assertEquals(seq, [5, 10]); +}); + +Deno.test("FakeTime.tickAsync() stops at a throwing timer and leaves later timers pending", async () => { + using time = new FakeTime(0); + const cb = spy(); + + setTimeout(() => { + throw new Error("boom"); + }, 5); + setTimeout(cb, 10); + await assertRejects(() => time.tickAsync(20), Error, "boom"); + + assertEquals(time.now, 5); + assertSpyCalls(cb, 0); + await time.tickAsync(5); + assertSpyCalls(cb, 1); +}); + +Deno.test("FakeTime.nextAsync() runs microtasks between timers with equal deadlines", async () => { + using time = new FakeTime(0); + const seq: string[] = []; + + setTimeout(() => { + seq.push("a"); + Promise.resolve().then(() => seq.push("microtask")); + }, 10); + setTimeout(() => seq.push("b"), 10); + setTimeout(() => seq.push("c"), 20); + assertEquals(await time.nextAsync(), true); + + assertEquals(seq, ["a", "microtask", "b"]); + assertEquals(time.now, 10); +}); + Deno.test("FakeTime.runAll() runs all timers without running microtasks", async () => { using time: FakeTime = new FakeTime(); const start: number = Date.now(); @@ -631,6 +720,45 @@ Deno.test("FakeTime.runAllAsync() runs all microtasks and timers", async () => { const Date_ = Date; +Deno.test("FakeTime.runAllAsync() runs timers scheduled from pending microtasks", async () => { + using time = new FakeTime(0); + const cb = spy(); + + Promise.resolve().then(() => setTimeout(cb, 10)); + await time.runAllAsync(); + + assertSpyCalls(cb, 1); + assertEquals(time.now, 10); +}); + +Deno.test("FakeTime.runAllAsync() runs microtasks between timers with equal deadlines", async () => { + using time = new FakeTime(0); + const seq: string[] = []; + + setTimeout(() => { + seq.push("a"); + Promise.resolve().then(() => seq.push("microtask")); + }, 10); + setTimeout(() => seq.push("b"), 10); + await time.runAllAsync(); + + assertEquals(seq, ["a", "microtask", "b"]); +}); + +Deno.test("FakeTime.runAllAsync() runs timers scheduled by the last timer's microtasks", async () => { + using time = new FakeTime(0); + const seq: number[] = []; + + setTimeout(async () => { + await Promise.resolve(); + setTimeout(() => seq.push(Date.now()), 10); + }, 10); + await time.runAllAsync(); + + assertEquals(seq, [20]); + assertEquals(time.now, 20); +}); + Deno.test("Date from FakeTime is structured cloneable", () => { using _time: FakeTime = new FakeTime(); const date: Date = new Date(); From 8b978f558eba961a58580692a0f9552ce886cb63 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Sun, 6 Sep 2026 17:47:07 +0200 Subject: [PATCH 2/3] fix(testing): keep range check and forward-only clock in async advancement --- testing/time.ts | 18 ++++++++++++------ testing/time_test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/testing/time.ts b/testing/time.ts index cbe18437bd53..f57a1e9b97b9 100644 --- a/testing/time.ts +++ b/testing/time.ts @@ -273,6 +273,14 @@ function runNextTimer(limit: number): boolean { return false; } +function assertNotInPast(value: number) { + if (value < now) { + throw new RangeError( + `Cannot set current time in the past, time must be >= ${now}: received ${value}`, + ); + } +} + let startedAt: number; let now: number; let initializedAt: number; @@ -532,11 +540,7 @@ export class FakeTime { * @param value The current time (in milliseconds) */ set now(value: number) { - if (value < now) { - throw new RangeError( - `Cannot set current time in the past, time must be >= ${now}: received ${value}`, - ); - } + assertNotInPast(value); while (runNextTimer(value)); now = value; } @@ -705,10 +709,12 @@ export class FakeTime { * Callers must drain microtasks before calling. */ async #advanceAsync(target: number) { + assertNotInPast(target); while (runNextTimer(target)) { await this.runMicrotasks(); } - now = target; + // A callback or microtask may have moved the clock past the target. + if (now < target) now = target; } /** diff --git a/testing/time_test.ts b/testing/time_test.ts index 54811ba2b597..8a6e6fbf9aa6 100644 --- a/testing/time_test.ts +++ b/testing/time_test.ts @@ -638,6 +638,30 @@ Deno.test("FakeTime.tickAsync() stops at a throwing timer and leaves later timer assertSpyCalls(cb, 1); }); +Deno.test("FakeTime.tickAsync() rejects a negative tick and leaves time unchanged", async () => { + using time = new FakeTime(100); + await assertRejects( + () => time.tickAsync(-10), + RangeError, + "Cannot set current time in the past, time must be >= 100: received 90", + ); + assertEquals(time.now, 100); +}); + +Deno.test("FakeTime.tickAsync() keeps time advanced by a microtask beyond the target", async () => { + using time = new FakeTime(0); + const cb = spy(); + + setTimeout(() => { + Promise.resolve().then(() => time.tick(20)); + }, 10); + setTimeout(cb, 25); + await time.tickAsync(10); + + assertEquals(time.now, 30); + assertSpyCalls(cb, 1); +}); + Deno.test("FakeTime.nextAsync() runs microtasks between timers with equal deadlines", async () => { using time = new FakeTime(0); const seq: string[] = []; From 221d9b52cd96bd5d33941c74a7ea878ed0011fad Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Thu, 17 Sep 2026 13:06:44 +0200 Subject: [PATCH 3/3] test(async): handle retry rejection before advancing fake time --- async/unstable_retry_test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/async/unstable_retry_test.ts b/async/unstable_retry_test.ts index f911768eae99..57601d18ccb1 100644 --- a/async/unstable_retry_test.ts +++ b/async/unstable_retry_test.ts @@ -19,8 +19,9 @@ async function waitsUntilExhausted(options: RetryOptions): Promise { starts.push(time.now - start); throw new Error("Failure"); }, options); + const rejection = assertRejects(() => promise, RetryError); await time.runAllAsync(); - await assertRejects(() => promise, RetryError); + await rejection; return starts.slice(1).map((at, i) => at - starts[i]!); }