Skip to content
English

ManualScheduler

ManualScheduler is a Scheduler implementation where time doesn’t advance on its own. Schedule a 5-second timer; nothing fires until you explicitly call scheduler.advance(5_000). The test runs in deterministic virtual time, with no real-clock dependencies — no flakey “sleep for long enough” patterns.

import { TestKit } from 'actor-ts/testkit';
const { kit, scheduler } = TestKit.withManualScheduler();
const probe = kit.createTestProbe();
const ref = kit.system.spawnAnonymous(() => new Heartbeat(probe));
ref.tell({ kind: 'start' });
scheduler.advance(5_000); // virtual time jumps 5 seconds
await probe.expectMessage('tick');
await kit.shutdown();

Heartbeat schedules a tick via context.timers.startSingleTimer('tick', tickMessage, 5_000). Real time never elapses; the advance(5_000) fires the timer.

// Without ManualScheduler:
ref.tell({ kind: 'start' });
await new Promise(r => setTimeout(r, 5_100)); // hope real time elapsed
await probe.expectMessage('tick');

That works most of the time. Then CI runs slower; the test sleeps for 5.1 s but the scheduler fires at 5.0 s vs the expectMessage checking at 5.1 s — flakey. Or your local machine is fast; the test waits 5.1 s every time even though the timer fires at 5.0 s — slow.

Widening the tolerance doesn’t rescue it either, because timers also fire early. Platform timer quanta are coarse — Windows’ default is 15.625 ms — and an event loop that checks deadlines on the tick boundary can consider a timer due before its nominal delay has passed: a 30 ms setTimeout measured as low as 18.7 ms on Bun/Windows, and as high as 201 ms under CPU load. One nominal delay, an order of magnitude of spread. No wall-clock bound is both tight enough to mean something and loose enough to hold, and a more precise clock doesn’t help — the timer is early, not the measurement.

ManualScheduler removes the dependency on real time. Tests are fast (no waits) and deterministic (advance fires timers in order).

class ManualScheduler extends Scheduler {
now(): number; // current virtual time (ms)
advance(durationMs: number): void; // jump forward, fire matured timers
advanceToNext(): void; // jump to the next pending timer's fire time
pendingCount: number; // non-cancelled scheduled tasks
}

Inherited from Scheduler:

scheduleOnce(delayMs, target, message, sender?): Cancellable
scheduleOnceFunction(delayMs, task): Cancellable
scheduleAtFixedRate(initialDelay, interval, target, message, sender?): Cancellable
scheduleAtFixedRateFunction(initialDelay, interval, task): Cancellable

Real-time setTimeout is not used. Every schedule is a virtual-time task that only fires via advance.

const { kit, scheduler } = TestKit.withManualScheduler('my-spec');

Two values:

  • kit — a normal TestKit whose system has the manual scheduler wired in.
  • scheduler — the manual scheduler. Call advance on this to fire timers.

When the actor under test uses context.timers.startSingleTimer or system.scheduler.scheduleOnce, the framework routes through the system’s scheduler — which here is the manual one. Virtual time controls all actor timers.

import { ActorSystem, ActorSystemOptions, ManualScheduler } from 'actor-ts';
const scheduler = new ManualScheduler();
const actorSystemOptions = ActorSystemOptions.create().withScheduler(scheduler);
const system = ActorSystem.create('my-spec', actorSystemOptions);
// ... test code ...
await system.terminate();

Same idea — pass the manual scheduler to ActorSystem.create via the settings.

scheduler.advance(1_000); // jump 1 second, fire timers maturing in that window
scheduler.advanceToNext(); // jump straight to the next pending timer and fire it
scheduler.now(); // → current virtual time

advance(ms) is the common case. Tasks scheduled to fire in the advanced window run in order, sequentially, before advance returns.

advanceToNext() fast-forwards straight to the fire time of the next pending timer (and fires it), skipping any idle gap — handy when you don’t want to compute the exact delay by hand. It is a no-op when nothing is scheduled.

ref.tell({ kind: 'start-heartbeat' }); // schedules every 1s
scheduler.advance(3_500);
// → fires at virtual 1000, 2000, 3000 (three times)

A scheduleAtFixedRate task fires every interval as virtual time crosses each multiple. advance(3500) from t=0 fires the task three times.

Inside one advance, tasks fire in their scheduled order (earliest first; ties broken by insertion order). After firing a task, if it schedules further tasks (e.g., a heartbeat re-arms), those are added to the queue and fire when their schedule time falls within the same advance window.

import { describe, it, beforeEach, afterEach } from 'bun:test';
import { TestKit, type ManualScheduler } from 'actor-ts/testkit';
describe('Session expiry', () => {
let kit: TestKit;
let scheduler: ManualScheduler;
let probe: TestProbe;
beforeEach(() => {
const setup = TestKit.withManualScheduler();
kit = setup.kit;
scheduler = setup.scheduler;
probe = kit.createTestProbe();
});
afterEach(async () => { await kit.shutdown(); });
it('expires after 15 minutes of inactivity', async () => {
const session = kit.system.spawnAnonymous(() =>
new Session(probe, 'user-42'));
session.tell({ kind: 'activity' }); // resets the timer
scheduler.advance(14 * 60_000);
await probe.expectNoMessage(0); // not yet expired
scheduler.advance(60_000 + 1); // crosses 15 min
await probe.expectMessage({ kind: 'session-expired', userId: 'user-42' });
});
});

The test runs in microseconds of wall-clock time but exercises a 15-minute idle timeout precisely.

ref.tell({ kind: 'start' });
scheduler.advance(5_000);
await probe.expectMessage('tick');

Two-stage processing:

  1. advance fires the timer synchronously — it tells the actor.
  2. The tell is processed by the dispatcher on the next microtask turn, which runs when you await something.

expectMessage returns a Promise that does await internally — so by the time it resolves, the tick has been processed and the message is in the probe’s buffer.

You generally don’t need to think about this. But if you reach for probe.messageCount synchronously right after advance and see 0, the actor hasn’t been dispatched yet — await something to yield.

The ManualScheduler API reference covers the full surface.