Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions async/retry_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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)
Expand All @@ -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 () => {
Expand Down
3 changes: 2 additions & 1 deletion async/unstable_retry_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ async function waitsUntilExhausted(options: RetryOptions): Promise<number[]> {
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]!);
}

Expand Down
103 changes: 68 additions & 35 deletions testing/time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,46 @@ 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;
}

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;
Expand Down Expand Up @@ -500,34 +540,8 @@ 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}`,
);
}
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();
}
}
assertNotInPast(value);
while (runNextTimer(value));
now = value;
}

Expand Down Expand Up @@ -661,7 +675,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
Expand All @@ -684,7 +700,21 @@ 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) {
assertNotInPast(target);
while (runNextTimer(target)) {
await this.runMicrotasks();
}
// A callback or microtask may have moved the clock past the target.
if (now < target) now = target;
}

/**
Expand Down Expand Up @@ -718,6 +748,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
Expand All @@ -744,7 +775,10 @@ export class FakeTime {
*/
async nextAsync(): Promise<boolean> {
await this.runMicrotasks();
return this.next();
const next = nextDueNode();
if (!next) return false;
await this.#advanceAsync(next.due);
return true;
}

/**
Expand Down Expand Up @@ -781,7 +815,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
Expand All @@ -804,9 +839,7 @@ export class FakeTime {
* ```
*/
async runAllAsync() {
while (!dueTree.isEmpty()) {
await this.nextAsync();
}
while (await this.nextAsync());
}

/**
Expand Down
154 changes: 153 additions & 1 deletion testing/time_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -565,6 +565,119 @@ 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.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[] = [];

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();
Expand Down Expand Up @@ -631,6 +744,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();
Expand Down
Loading