Problem
ManualScheduler is the tool the docs offer for removing timing flakiness from tests. Both of its documented examples are races, and the page's explanation of why they are not races is wrong about how the framework works.
The pattern in every sample is ref.tell(…) → scheduler.advance(…) → await probe.expectMessage(…). tell enqueues; the actor is dispatched later. advance is synchronous and returns before any dispatch has happened. So at the moment virtual time jumps, the actor has not yet run its handler and has not yet armed the timer that advance is supposed to fire. The timer is created afterwards, relative to an already-advanced clock, and the message never arrives.
Run verbatim, both samples fail with TestProbe timeout after 3000ms.
The page explains the interaction and gets the mechanism wrong:
- The
tell is processed by the dispatcher on the next microtask turn, which runs when you await something.
The default dispatcher is ImmediateDispatcher, which schedules via setImmediate — a macrotask. A microtask await does not reach it. Measured: after await Promise.resolve() the timer is still unarmed; after ten of them it is still unarmed; after await new Promise(r => setTimeout(r, 0)) it is armed and the test passes. The advice at the end of the section — "await something to yield" — is therefore only correct for a yield that reaches the macrotask queue, which is precisely the distinction the section exists to explain.
TestKit.withManualScheduler is used by no test in the repository. Every ManualScheduler test constructs the scheduler directly and calls scheduleOnce/scheduleOnceFunction from the test body, so no test ever arms a timer from inside an actor after a tell — which is why the documented pattern was never exercised. A reader following the page hits a 3-second timeout on their first attempt with the framework's own headline example.
Evidence
The page's opening example, docs/src/content/docs/testing/manual-scheduler.mdx:12-29:
docs/src/content/docs/testing/manual-scheduler.mdx:12-29
```ts
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.
The "practical test pattern", docs/src/content/docs/testing/manual-scheduler.mdx:175-185:
docs/src/content/docs/testing/manual-scheduler.mdx:175-185
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 explanation, docs/src/content/docs/testing/manual-scheduler.mdx:192-213:
docs/src/content/docs/testing/manual-scheduler.mdx:192-213
## Interaction with `await` and microtasks
```ts
ref.tell({ kind: 'start' });
scheduler.advance(5_000);
await probe.expectMessage('tick');
```
Two-stage processing:
1. `advance` fires the timer synchronously — it `tell`s 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.
Step 1 is also inverted for the samples above: the user's tell comes before advance, and it is that tell — not the timer's — that has not been dispatched.
The dispatcher the claim is about. ActorSystem resolves immediate when the config says nothing:
src/ActorSystem.ts:471-484
function dispatcherFromConfig(config: Config): Dispatcher {
const kind = config.hasPath(ConfigKeys.dispatcher.default)
? config.getString(ConfigKeys.dispatcher.default).toLowerCase()
: 'immediate';
return match(kind)
.with('microtask', () => new MicrotaskDispatcher() as Dispatcher)
.with('throughput', () => {
const throughput = config.hasPath(ConfigKeys.dispatcher.throughput)
? config.getInt(ConfigKeys.dispatcher.throughput)
: 16;
return new ThroughputDispatcher(throughput) as Dispatcher;
})
.otherwise(() => new ImmediateDispatcher() as Dispatcher);
}
and ImmediateDispatcher is a macrotask:
src/Dispatcher.ts:37-48
/**
* Runs work via setImmediate (or setTimeout(0) in browsers). Lets I/O and
…
export class ImmediateDispatcher implements Dispatcher {
…
execute(fn: () => void | Promise<void>): void {
if (typeof setImmediate === 'function') {
setImmediate(() => runSafely(fn));
} else {
setTimeout(() => runSafely(fn), 0);
}
}
MicrotaskDispatcher exists but is opt-in via actor-ts.dispatcher.default = microtask; the docs' samples do not set it.
Reproduction. A Session actor implementing exactly what the page implies (startSingleTimer('expiry', { kind: 'expire' }, 15 * 60_000) on activity, probe.tell({ kind: 'session-expired', userId }) on expire), and a Heartbeat implementing what manual-scheduler.mdx:27-28 states, run through six variants:
[A] verbatim (manual-scheduler.mdx:175) pending after tell = 0 FAIL TestProbe timeout after 3000ms
[B] + await Promise.resolve() pending after tell = 0 FAIL TestProbe timeout after 3000ms
[B2] + 10 x await Promise.resolve() pending after tell = 0 FAIL TestProbe timeout after 3000ms
[C] + await new Promise(r=>setTimeout(r,0)) pending after tell = 1 PASS
[E] verbatim Heartbeat (manual-scheduler.mdx:12 / overview.mdx:102)
pending after advance = 0, now = 5000 FAIL
[F] Heartbeat + macrotask await PASS
scheduler.pendingCount is 0 at the moment advance is called in every failing variant — the timer does not exist yet. In [A] it is armed only when the first real await lands, by which time virtual time is already at 840 000; it is then scheduled for 1 740 000 and the following advance(60_001) reaches 900 001.
The same samples in the mirror: docs/src/content/docs/de/testing/manual-scheduler.mdx:22, :184, :196-208, and docs/src/content/docs/de/testing/overview.mdx:112.
The second page carrying the broken shape:
docs/src/content/docs/testing/overview.mdx:102-110
```ts
const { kit, scheduler } = TestKit.withManualScheduler();
const probe = kit.createTestProbe();
const ref = kit.system.spawnAnonymous(() => new ScheduledThing(probe));
ref.tell({ kind: 'start' });
scheduler.advance(5_000); // virtual time jumps 5 seconds
await probe.expectMessage({ kind: 'fired' });
```
Proposal
Fix the samples, fix the explanation, and — because a doc fix does not stop the next reader from hitting this — close the gap in the API.
- Every sample yields between
tell and advance. The clearest form is a testkit helper rather than a bare setTimeout: add kit.settle() (or probe.settle()) that drains the dispatcher, and write session.tell({ kind: 'activity' }); await kit.settle(); scheduler.advance(14 * 60_000);. It reads as intent instead of as a workaround, and it survives a change of default dispatcher.
- Better still, make
advance do it. ManualScheduler.advance could be async and drain the dispatcher before jumping. That removes the trap rather than documenting it, and every sample on the page becomes correct as written with one await added. This is a breaking change to a testkit API; pre-1.0 that is a hard cut, and it is the right one — the current signature makes the tool's primary use case race by default.
- Correct the "Interaction with
await and microtasks" section. Name the dispatcher, say setImmediate rather than "microtask turn", and state explicitly that await Promise.resolve() is not sufficient. The section title itself should change, because microtasks are not the mechanism.
- Add one test. The
Session example from the page, as a real test under tests/unit/testkit/. TestKit.withManualScheduler has no test today, and that is why this shipped.
- Mirror everything into
docs/src/content/docs/de/.
Acceptance sketch
Verification status
Found in the ten-lens production-readiness review of 2026-08-05 (v0.13.0) and re-verified before filing: confirmed by execution. Both Session and Heartbeat were implemented from the page's own descriptions and run through the six variants tabulated above, with scheduler.pendingCount logged at each decision point. The review's claim that "inserting one await before advance makes it pass" is corrected here: a microtask await does not — ten of them do not — and only a yield that reaches the macrotask queue works, which is what identified ImmediateDispatcher as the mechanism. The probe file was deleted; git status --porcelain is empty. grep -rn "withManualScheduler" tests/ returns no hits, which is the reason the samples were never exercised.
Adjacent: #418 replaces sleep-based waits with a shared awaitCondition helper — the same goal (remove wall-clock dependence from tests) approached from the assertion side; ManualScheduler is the scheduler side, and this issue is why the scheduler side does not currently work as documented. #290 catalogues parallel-test flakes, several of which are timing-sensitive; a working ManualScheduler pattern is part of retiring them.
Part of the production-readiness review batch — tracked in #913.
Problem
ManualScheduleris the tool the docs offer for removing timing flakiness from tests. Both of its documented examples are races, and the page's explanation of why they are not races is wrong about how the framework works.The pattern in every sample is
ref.tell(…)→scheduler.advance(…)→await probe.expectMessage(…).tellenqueues; the actor is dispatched later.advanceis synchronous and returns before any dispatch has happened. So at the moment virtual time jumps, the actor has not yet run its handler and has not yet armed the timer thatadvanceis supposed to fire. The timer is created afterwards, relative to an already-advanced clock, and the message never arrives.Run verbatim, both samples fail with
TestProbe timeout after 3000ms.The page explains the interaction and gets the mechanism wrong:
The default dispatcher is
ImmediateDispatcher, which schedules viasetImmediate— a macrotask. A microtaskawaitdoes not reach it. Measured: afterawait Promise.resolve()the timer is still unarmed; after ten of them it is still unarmed; afterawait new Promise(r => setTimeout(r, 0))it is armed and the test passes. The advice at the end of the section — "awaitsomething to yield" — is therefore only correct for a yield that reaches the macrotask queue, which is precisely the distinction the section exists to explain.TestKit.withManualScheduleris used by no test in the repository. EveryManualSchedulertest constructs the scheduler directly and callsscheduleOnce/scheduleOnceFunctionfrom the test body, so no test ever arms a timer from inside an actor after atell— which is why the documented pattern was never exercised. A reader following the page hits a 3-second timeout on their first attempt with the framework's own headline example.Evidence
The page's opening example,
docs/src/content/docs/testing/manual-scheduler.mdx:12-29:The "practical test pattern",
docs/src/content/docs/testing/manual-scheduler.mdx:175-185:docs/src/content/docs/testing/manual-scheduler.mdx:175-185 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 explanation,
docs/src/content/docs/testing/manual-scheduler.mdx:192-213:Step 1 is also inverted for the samples above: the user's
tellcomes beforeadvance, and it is thattell— not the timer's — that has not been dispatched.The dispatcher the claim is about.
ActorSystemresolvesimmediatewhen the config says nothing:and
ImmediateDispatcheris a macrotask:MicrotaskDispatcherexists but is opt-in viaactor-ts.dispatcher.default = microtask; the docs' samples do not set it.Reproduction. A
Sessionactor implementing exactly what the page implies (startSingleTimer('expiry', { kind: 'expire' }, 15 * 60_000)onactivity,probe.tell({ kind: 'session-expired', userId })onexpire), and aHeartbeatimplementing whatmanual-scheduler.mdx:27-28states, run through six variants:scheduler.pendingCountis0at the momentadvanceis called in every failing variant — the timer does not exist yet. In[A]it is armed only when the first realawaitlands, by which time virtual time is already at 840 000; it is then scheduled for 1 740 000 and the followingadvance(60_001)reaches 900 001.The same samples in the mirror:
docs/src/content/docs/de/testing/manual-scheduler.mdx:22,:184,:196-208, anddocs/src/content/docs/de/testing/overview.mdx:112.The second page carrying the broken shape:
Proposal
Fix the samples, fix the explanation, and — because a doc fix does not stop the next reader from hitting this — close the gap in the API.
tellandadvance. The clearest form is a testkit helper rather than a baresetTimeout: addkit.settle()(orprobe.settle()) that drains the dispatcher, and writesession.tell({ kind: 'activity' }); await kit.settle(); scheduler.advance(14 * 60_000);. It reads as intent instead of as a workaround, and it survives a change of default dispatcher.advancedo it.ManualScheduler.advancecould be async and drain the dispatcher before jumping. That removes the trap rather than documenting it, and every sample on the page becomes correct as written with oneawaitadded. This is a breaking change to a testkit API; pre-1.0 that is a hard cut, and it is the right one — the current signature makes the tool's primary use case race by default.awaitand microtasks" section. Name the dispatcher, saysetImmediaterather than "microtask turn", and state explicitly thatawait Promise.resolve()is not sufficient. The section title itself should change, because microtasks are not the mechanism.Sessionexample from the page, as a real test undertests/unit/testkit/.TestKit.withManualSchedulerhas no test today, and that is why this shipped.docs/src/content/docs/de/.Acceptance sketch
ManualSchedulersample indocs/src/content/docs/testing/runs green when pasted into a test file.manual-scheduler.mdxstates that the default dispatcher issetImmediate-based and that a microtaskawaitdoes not drain it.advancedrains the dispatcher itself, or a documentedsettle()helper exists and every sample uses it.tests/unit/testkit/contains a test that arms a timer from inside an actor after atelland then advances — the shape the docs teach.docs/src/content/docs/de/testing/manual-scheduler.mdxandde/testing/overview.mdxmatch the English page line for line.Verification status
Found in the ten-lens production-readiness review of 2026-08-05 (
v0.13.0) and re-verified before filing: confirmed by execution. BothSessionandHeartbeatwere implemented from the page's own descriptions and run through the six variants tabulated above, withscheduler.pendingCountlogged at each decision point. The review's claim that "inserting oneawaitbeforeadvancemakes it pass" is corrected here: a microtaskawaitdoes not — ten of them do not — and only a yield that reaches the macrotask queue works, which is what identifiedImmediateDispatcheras the mechanism. The probe file was deleted;git status --porcelainis empty.grep -rn "withManualScheduler" tests/returns no hits, which is the reason the samples were never exercised.Adjacent: #418 replaces sleep-based waits with a shared
awaitConditionhelper — the same goal (remove wall-clock dependence from tests) approached from the assertion side;ManualScheduleris the scheduler side, and this issue is why the scheduler side does not currently work as documented. #290 catalogues parallel-test flakes, several of which are timing-sensitive; a workingManualSchedulerpattern is part of retiring them.Part of the production-readiness review batch — tracked in #913.