Skip to content

[Docs] Both ManualScheduler samples call scheduler.advance before the actor has processed the preceding tell, so the flagship determinism examples fail with a probe timeout and the microtask explanation names the wrong queue #1025

Description

@pathosDev

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:

  1. 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

  • Every ManualScheduler sample in docs/src/content/docs/testing/ runs green when pasted into a test file.
  • manual-scheduler.mdx states that the default dispatcher is setImmediate-based and that a microtask await does not drain it.
  • Either advance drains the dispatcher itself, or a documented settle() helper exists and every sample uses it.
  • tests/unit/testkit/ contains a test that arms a timer from inside an actor after a tell and then advances — the shape the docs teach.
  • docs/src/content/docs/de/testing/manual-scheduler.mdx and de/testing/overview.mdx match 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. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationImprovements or additions to documentationpriority: mediumUseful, not urgentproduction-goalBlocks or defines the path to production readiness

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions