Skip to main content

Deterministic testing

Most state-machine bugs are not logic bugs — they are timing bugs. A socket replies a millisecond late, two events race through a queue, a retry fires while a teardown is half-done. Such bugs reproduce once in a thousand CI runs and never on your laptop. Deterministic Simulation Testing (DST) is the discipline of removing every source of nondeterminism from a test so that the same inputs always produce the same outputs — and, crucially, so that a failure can be replayed exactly.

ihsm is built for this. A machine is a class hierarchy with serialized, run-to-completion dispatch and a single, explicit seam to the impure world. Get the structure right and your tests need no mocks of timers, no sleep, no "flaky, re-run it" annotations. This chapter builds the technique up in five runnable stages, each with a live playground you can drive yourself.

What is Deterministic Simulation Testing?

Deterministic simulation testing (DST) means running the real system inside a fully controlled, reproducible simulated environment where a single seed produces an identical execution every time. The goal is not merely to claim determinism — it is to make that claim auditable. A serious DST program can demonstrate two things: that the environment is actually deterministic, and that the simulation can actually find bugs.

The checklist below groups requirements by what each one protects. Not every project needs every item on day one — but sections A, B, and F are the definitional core. A system missing any of those is not doing DST, regardless of what the README calls it. Sections C, D, G, H, and I separate a serious program from a token one.

Scope note: items like "billions of simulated seconds" are maturity benchmarks set by projects like FoundationDB and TigerBeetle, not hard thresholds. Calibrate the bar to your system's risk profile.

A. Determinism (the non-negotiable core)

RequirementWhat it protects
Simulated clock — no Date.now(), Instant::now, gettimeofday, System.currentTimeMillis, etc. anywhere in the system under testWall-clock time cannot reorder events between runs
Seeded PRNG — all randomness flows through one seeded generator; no /dev/urandom, no unseeded RNG, no hardware entropyRandom choices replay identically
Deterministic concurrency — typically one OS thread with a cooperative/simulated scheduler (async tasks, coroutines, fibers), or a scheduler that controls all interleavings; nothing relies on OS thread schedulingRaces cannot hide behind scheduling luck
Virtualized I/O — network, disk, and filesystem go through simulated interfaces; no real sockets or files in the deterministic runExternal latency and failure modes are scripted, not ambient
No nondeterministic ordering leaks — hash-map/set iteration order, pointer/address values, and similar must not affect behaviourLogic does not accidentally depend on implementation details
No uncontrolled external inputs — real DNS, varying environment variables, and the like must not cross the simulation boundaryThe run is a closed world
Reproducibility proof — the same seed yields a byte- or event-identical execution, demonstrated continuously (run a seed twice, compare a hash of the full trace or final state)Determinism is verified, not assumed

How ihsm maps here: serialized run-to-completion dispatch pins concurrency (A3). The Port boundary virtualizes I/O (A4) — sockets, processes, the filesystem, and timers all live behind port, never in handlers directly. TestPort supplies a virtual clock (advance()) and scriptable random (feedRandom(), feedUUID(), feedRandomBytes()) so tests never touch wall clock or ambient RNG (A1, A2). The golden-trace technique (below) is your reproducibility proof for a single scenario (A7).

B. Simulation fidelity

RequirementWhat it protects
Real production logic — the system under test runs its actual code path, not a stub or reimplementationYou are testing the system, not a model of it
Realistic adversity — the simulator models message delay, reordering, duplication, partial writes, and similarTests exercise behaviour under stress, not only the happy path
Explicit seam — the boundary between the deterministic core and the simulator is defined and enforcedNondeterminism cannot creep in through a forgotten side door

How ihsm maps here: handlers and states are production code; only the Port is swapped in tests (B1, B3). Mock ports script realistic adversity — slow replies, dropped events, fault sequences — without reimplementing the machine (B2).

C. Fault injection

RequirementWhat it protects
Injectable faults — network partitions, packet loss/reorder/duplication, node crashes and restarts, disk errors, corruption, clock skew, slow nodesFailure modes are explored systematically, not only when they happen to occur
Seed-driven injection — fault schedules are themselves deterministic and varied across runsDifferent seeds explore different failure histories

How ihsm maps here: testing-04 walks through seeded fault injection via @mock + makeTestPort — the attempt stub runs a seeded PRNG and the test decides when results land.

D. Time control

RequirementWhat it protects
Virtual clock — decoupled from wall clock, advanceable arbitrarilyTimeouts, retries, and "days" of behaviour run in seconds

How ihsm maps here: TestPort.advance(ms) fires due deferredPost timers on demand; deferredPost itself delegates to port.setTimeout, so production and test share the same API with different clocks.

E. Workload generation

RequirementWhat it protects
Seed-driven scenarios — randomized workloads explore different histories per seedThe state space is sampled, not fixed to one script
Biased generation — ability to steer toward interesting regions of the state spaceRare corners are reached without abandoning randomness

ihsm does not ship a workload generator — your tests or CI harness own scenario generation — but the port/actor model keeps every generated scenario replayable once you capture the seed and trace.

F. Oracles / property checking (so bugs are actually detected)

RequirementWhat it protects
Safety invariants — pervasive asserts checked during and after each runViolations are caught at the point of failure, not only at the end
Correctness oracle — a consistency checker (linearizability, serializability) or reference modelSemantic correctness is verified, not only "no throw"
Liveness checks — the system makes progress once injected faults are healedThe system does not deadlock silently under recovery

How ihsm maps here: golden traces are a lightweight oracle for ordering and side effects; combine them with domain invariants on ctx and explicit expect assertions. TestPort.trace and Stubbed.calls make regressions local. For lifecycle hooks, assert this.currentState or call _checkInvariant() inside onEntry — the runtime adopts the entering state prototype before each hook (see src/spec/on-entry-prototype.spec.ts).

G. Reproducibility & debugging workflow

RequirementWhat it protects
Captured seed and config on failureA red run is diagnosable without guesswork
Deterministic replay — the same seed reproduces the identical failure (same binary/architecture; cross-arch reproducibility is a known caveat)Debuggers see the same history the CI saw
Maturity bonus: deterministic stepping or time-travel debuggingFailures are inspectable at arbitrary points

How ihsm maps here: VERBOSE_DEBUG tracing (the makeTestActor default) plus a golden trace and recorded seed give you a replay script; sync() barriers let you step the machine one dispatch at a time.

H. Coverage, scale, and continuous operation

RequirementWhat it protects
Volume — many distinct seeds run continuously in CI, not a one-off batchRare bugs surface over time
Coverage accounting — some measure of state-space or scenario coverageYou know what you have not tried yet

Scale is your CI policy; ihsm keeps each individual run cheap enough to run thousands of them.

I. Integrity guards (so the determinism claim does not silently rot)

RequirementWhat it protects
Determinism check in CI — re-run a seed and fail the build if execution divergesRegressions in determinism are caught before they invalidate every replay
Lint/guard rails — ban forbidden calls (real time, real RNG, real threads, real I/O) in the system under testNew code cannot bypass the port boundary unnoticed

Enforce I2 with code review and lint rules: handlers must not import node:fs, call setTimeout directly, or use Math.random() — route through this.port instead.

Distinguishing a real claim from a marketing one

Ask for evidence:

  1. A captured failing seed you can replay yourself and watch fail identically.
  2. The determinism-check job in CI and its pass history.
  3. The list of injectable fault types and the invariants/oracle actually being asserted.
  4. Confirmation that production code paths run inside the simulator — not a parallel test-only implementation.

If sections A, B, and F are not demonstrably satisfied, the program is not DST yet — it is deterministic unit testing with extra steps.

What makes a test nondeterministic

Three things, almost always:

  1. Concurrency — two pieces of work interleave in an order you did not pin.
  2. Wall-clock timesetTimeout, Date.now(), real network/disk latency.
  3. Ambient randomnessMath.random(), UUIDs, hash-map iteration order.

If a test depends on any of these, it can fail intermittently. DST's answer is to make all three explicit and controllable: serialize the work, replace the clock with a step you advance by hand, and seed the randomness so it replays.

The three properties ihsm gives you

Determinism is a first-class feature of ihsm, not an afterthought. It rests on three properties you can lean on in every test:

  1. Serialized, run-to-completion dispatch. Each handler runs to completion and never interleaves with another. await hsm.sync() resolves only once every job enqueued before it has finished, draining chained posts in order. There is no interleaving to race on — you advance the machine one barrier at a time.
  2. A Port boundary. All impurity — sockets, child processes, clocks, the filesystem, the network — lives behind a single port object. Swap it for a mock and the machine above the port is pure. The port is the only place a test has to think about the outside world.
  3. A public / internal protocol split. Events the outside world raises (open, fetch, listen) are separated from events the port raises back (onConnected, onResponse, onMouseMove). The two are required to be disjoint, enforced by the compiler. A client can never forge an internal event, and a test can drive either side directly.

Two surfaces: makeActor and makeTestActor

The split shows up as two factory functions over the same machine:

  • makeActor<C, Public, Internal, Port> returns an Actor<C, Public> — the production surface. Clients can post / call only public events; internal events are not in the callable type at all. This is what ships.
  • makeTestActor<C, Public, Internal, Port> returns a TestActor over the merged protocol plus typed access to port. This is the white-box surface: a test can pin a state, post internal events directly (no live port required), and assert on the port's recorded interactions.

Both factories take the three mandatory arguments topState, ctx, port positionally, followed by an optional options bag { initialize?, traceLevel?, traceWriter?, … }. Context, Public, and Internal are inferred straight from topState, so call sites carry no explicit generics; never wrap them in a helper and never pass undefined placeholders. Tests use makeTestActor with a makeTestPort mock; makeActor is the production surface, shown here only in compile-time checks that prove the public/internal boundary. Both are timer-free.

Import the test surface from ihsm/testing. The mock machinery, the manual clock, and makeTestActor ship in a separate entry point so they are never bundled into production code that only imports ihsm. Production code does import { makeHsm, TopState } from 'ihsm'; test files do import * as ihsm from 'ihsm/testing' (which also re-exports the entire core API, so a spec can import everything it needs from ihsm/testing alone).

makeTestActor defaults to VERBOSE_DEBUG tracing — a failing test should be fully readable out of the box. Never silence a test to a production trace level; drop verbosity only by passing an explicit options.traceLevel when you really need a quiet run.

Mock ports: @mock, makeTestPort, and per-call scripting

A mock port is built on the abstract TestPort base, which gives every mock a consistent test surface for free:

  • this.send(event, ...args) posts an internal event inward through the lazily-bound poster.
  • this.record(label, ...args) logs an outbound call for assertions (also callable from a test, e.g. inside a dispose closure).
  • messages / events / trace expose the recorded log; last and count are conveniences; clear() empties the recorded list.

TestPort<T> takes the machine's root TopState as its single type argument — every other type (context, internal protocol, and the port surface) is derived from it, so the root state is the one configuration point. You never implement the port. Declare each port method as an abstract member whose signature matches the real port, decorate the class with @mock, and build it with makeTestPort:

@ihsm.mock
abstract class WatcherMock extends ihsm.TestPort<WatcherTop> {
abstract watch(path: string): ihsm.ResultWithSubscription<number>; // signature matches the port
}

const port = ihsm.makeTestPort(WatcherMock); // typed mock; port.actor is bound lazily by makeTestActor

Each abstract method comes back as a scriptable Stubbed method — the per-method analogue of a jest.fn() / Sinon stub, fully typed from TopState. It is still callable with its exact signature (so the machine invokes it normally), and carries the scripting + introspection surface the test drives:

  • port.watch.default(impl) — the persistent implementation (every call runs it).
  • port.watch.once(impl) — a one-shot implementation, consumed by the next call; queue several to script a sequence. One-shots are consumed before the persistent default.
  • port.watch.calls — the live, typed list of argument tuples the method was called with (Parameters<typeof watch>[]), for direct assertions.
  • port.watch.reset() — clear queued/persistent scripts and recorded calls, to reuse a mock.

default / once take a closure with the method's exact parameters and return type, so scripts stay type-safe:

port.watch.default(path => ({ // script the result (fully type-safe)
value: 7,
subscription: { dispose: () => port.record(`dispose ${path}`) }, // the test controls teardown too
}));

port.watch('/etc/hosts');
expect(port.watch.calls).to.deep.equal([['/etc/hosts']]); // typed `[path: string][]`

Two things are automatic: every call is recorded first (so it shows up in trace and in method.calls), and calling an unscripted method throws a PreloadError that names it — never a silent undefined.

Two channels: default/once (outbound) vs send (inbound)

Keep them distinct. default / once script what a method the machine calls returns; send pushes an internal event inward. The cardinal rule is do not deliver an event from inside the synchronous call unless the test asked for it: a stub for request returns the request id and abort handle but delivers no response, so the test can observe the in-flight Fetching state and then settle it on its own command with port.send('onResponse', …). That is what lets one mock serve every scenario — the happy path, the slow reply, the error, and the cancellation.

ExampleRecords (inbound calls)How the tester drives the back-channel
testing-01 timersclock.advance(ms) — fire due deferredPost timers
testing-02 networkrequest / abortrequest.default returns an id; port.send('onResponse', …) settles it
testing-03 streamsubscribe / unsubscribeport.moveTo(x, y) drives device state; delivers only while live
testing-04 faultsattemptattempt.default runs a seeded fault and port.send('onResult', ok)
testing-05 subscriptionswatch / disposeport.watch.default(impl) — script the result + its Disposable

Device state lives in the mock

A mock is a test instrument, not a passive stub — it can model the simulated outside world in public fields the test reads and drives. In testing-03 the OS pointer lives in the mock (cursor, live), with drive commands the tester calls; the machine stores only what it observed while subscribed. The two legitimately diverge.

@ihsm.mock
abstract class MockMouseStream extends ihsm.TestPort<MouseTop> {
abstract subscribe(): ihsm.ResultWithSubscription<number>;
cursor: Point = { x: 0, y: 0 }; // public device state — the simulated OS pointer
live = false;
moveTo(x: number, y: number): void {
this.cursor = { x, y };
if (this.live) this.send('onMouseMove', x, y); // delivered only while subscribed
else this.record('drop', x, y); // moved while unsubscribed — not delivered
}
}

Watching a machine: subscribeTestPort.record

When you want a golden trace of every event posted through the machine, wire TestActor.subscribe to your TestPort:

const port = new ihsm.TestPort<HeartbeatTop>();
const test = ihsm.makeTestActor(HeartbeatTop, new HeartbeatCtx(), port);
const sub = test.subscribe(m => port.record(m.event, ...m.payload));
test.post('start'); await test.sync();
expect(port.events).to.include('start');
port.clear();
sub.dispose();

subscribe fires for every event — client posts, handler self-posts, and port-driven internal events alike — and is absent from the production Actor, so tracing never leaks into shipping code.

The golden-trace technique

Because dispatch is serialized and run-to-completion, and the port records what it was asked to do, a test can capture an exact, ordered transcript of everything that happened — a golden trace — and assert on it. Two runs that should be identical produce byte-identical traces; a diff localizes the regression precisely. This is far stronger than asserting a final value, and it is what makes a replayed DST failure debuggable.

Two rules that keep every test deterministic

Never perform I/O outside a port, and never sleep on wall-clock time in a test.

Advance the machine with sync() and feed internal events yourself instead of waiting. Every example below obeys these two rules — and that is the entire reason they cannot flake.

How this chapter is organized

The stages grow in complexity. Each is a complete, runnable example under examples/ with its own tutorial.spec.ts; the headless command is shown with each playground.

  1. Deferred timers & simulated time — the foundation: never wait on the wall clock.
  2. Network fetch behind a port — control what the response is and when it lands.
  3. Event streaming behind a port — gate a push source so it goes quiet on unsubscribe.
  4. Fault injection & seeded DST — manufacture reproducible failure.
  5. Subscriptions & disposables — own a Disposable and prove it is released exactly once.

1. Deferred timers & simulated time

The foundation of everything else: never block on real time. A Heartbeat machine ticks every hour with deferredPost, backed by the machine's standard port timer service. A test swaps in a TestPort and advance()s it to simulate 48 hours in microseconds — establishing the two test surfaces (test actor vs. test port) you reuse for the rest of the chapter.

When and why: Deferred timers & simulated time

Start every testable machine here: never wait on the wall clock. A Heartbeat machine ticks every hour via deferredPost, which is backed by the machine's standard port timer service. In a test you substitute a controllable clock and simulate 48 hours in microseconds — zero flakiness.

Test actor vs. test port: makeTestActor returns the test surface — the merged protocol (post the internal onTick directly), typed access to port, and a subscribe() channel that observes every event. A production Actor from makeActor exposes only the public protocol. A test port (TestPort) records what flows through it and supplies a virtual clock you advance() by hand to fire due deferredPost timers deterministically. Wire TestActor.subscribe to port.record to trace every posted event.

Positional arguments, no wrappers: the factories take the three mandatory arguments — topState, ctx, port — positionally, then an optional options bag. Set only what you need, never wrap makeActor in a helper, never pass undefined placeholders. Import the test surface from ihsm/testing.

State diagram

UML state diagram

Machine source, then the unit tests

Runnable code lives under testing-01-deferred-timers. First the machine (the code under test), then the mocha + chai spec that drives it — the same tests run headlessly with npm run test:examples -- --grep 'Testing 01'.

examples/testing-01-deferred-timers/machine.ts

/**
* Deferred timers & simulated time — the foundational deterministic-testing example.
*
* A `Heartbeat` machine emits one tick **every hour**. It does not own a domain port: the hourly
* follow-up is scheduled with {@link ihsm.State.deferredPost | deferredPost}, which is backed by
* the machine's **standard port timer service** ({@link ihsm.Port} in production, real
* `setTimeout`). Because the timer is a port service, a test can substitute a controllable clock
* ({@link ihsm.TestPort}) and simulate days of ticks in microseconds — no real waiting, no
* flakiness.
*
* - Public protocol: `start`, `stop` — what a client posts.
* - Internal protocol: `onTick` — raised only by the deferred timer, never by a client.
*
* The two protocols are disjoint (enforced at compile time), so `onTick` never appears on the
* public {@link ihsm.Actor} surface.
*/
import * as ihsm from '../../src';
import * as self from './machine';

/** One hour, in milliseconds — the tick interval. */
export const HOUR_MS = 60 * 60 * 1000;

/** Mutable domain data shared across all states (a class, constructed fresh per actor). */
export class HeartbeatCtx {
/** Number of hourly ticks observed so far. */
ticks = 0;
/** Whether the heartbeat is currently running. */
running = false;
}

/** Public protocol — the only events clients may post. */
export interface HeartbeatPublic {
start(): void;
stop(): void;
}

/** Internal protocol — raised only by the deferred timer (never by a client). */
export interface HeartbeatInternal {
onTick(): void;
}

/** Root state. Stray events in the "wrong" state are safe no-ops. */
export class HeartbeatTop extends ihsm.TopState<HeartbeatCtx, HeartbeatPublic, HeartbeatInternal, undefined> {
start(): void {} // ignored unless Stopped
stop(): void {} // ignored unless Running
onTick(): void {} // ignored unless Running (e.g. a tick scheduled just before stop)
}

@ihsm.InitialState
export class Stopped extends HeartbeatTop {
start(): void {
this.ctx.running = true;
this.transition(Running);
}
}

export class Running extends HeartbeatTop {
/** On entry, arm the first hourly tick through the port timer service. */
onEntry(): void {
this.deferredPost(HOUR_MS, 'onTick');
}

onTick(): void {
this.ctx.ticks += 1;
this.deferredPost(HOUR_MS, 'onTick'); // recur: arm the next hour
}

stop(): void {
this.ctx.running = false;
this.transition(Stopped);
}
}

ihsm.registerStateNames(self);

examples/testing-01-deferred-timers/tutorial.spec.ts — mocha + chai tests, executed against the mocks

import { expect } from 'chai';
import 'mocha';

import * as ihsm from '../../src/testing';
import { HeartbeatTop, Stopped, Running, HeartbeatCtx, HeartbeatPublic, HOUR_MS } from './machine';

/**
* Testing 01 — deferred timers & simulated time.
*
* This first example establishes the two test surfaces you will use throughout the chapter:
*
* - **Test actor** ({@link ihsm.makeTestActor}): the machine handle for white-box tests. It
* exposes the **merged** protocol (so you can post internal events like `onTick` directly, with
* no live timer), grants typed access to the machine's `port`, and adds a `subscribe()` channel
* that observes every event. (A production {@link ihsm.Actor} from {@link ihsm.makeActor} exposes
* only the public protocol and none of those test affordances.)
*
* - **Test port** ({@link ihsm.TestPort}): a port test double that
* *records* what flows through it (`messages` / `events` / `trace`) and can `send` internal
* events inward. Here we also use {@link ihsm.TestPort} — a port whose virtual clock the
* test advances by hand — to fire the machine's hourly `deferredPost` deterministically.
*
* Note we never wrap {@link ihsm.makeActor} in a helper and never pass `undefined` placeholders:
* the factories take a single **named-parameters** object, so each test reads as its own setup.
*/
describe('Testing 01: deferred timers & simulated time', () => {
it('simulates 48 hours of an hourly timer in microseconds (makeTestActor + TestPort)', async () => {
// The hourly `deferredPost` is backed by the port timer service. Swap the real clock for a
// manually-advanced one so "every hour" becomes "whenever the test says so".
const clock = new ihsm.TestPort<HeartbeatTop>();
// No traceLevel given → makeTestActor defaults to VERBOSE_DEBUG, so a failing run is fully readable.
const sm = ihsm.makeTestActor(HeartbeatTop, new HeartbeatCtx(), clock);
await sm.sync();
expect(sm.currentState).equals(Stopped);

sm.post('start');
await sm.sync();
expect(sm.currentState).equals(Running);
expect(clock.pending).equals(1); // the first hourly tick is armed, not yet fired

// Drive 48 hours: advance the virtual clock one hour, drain pending events, repeat.
for (let hour = 1; hour <= 48; hour++) {
clock.advance(HOUR_MS);
await sm.sync();
}

expect(sm.ctx.ticks).equals(48);
expect(clock.now).equals(48 * HOUR_MS);
expect(clock.pending).equals(1); // hour 49 is already armed — the heartbeat keeps recurring

// Stopping leaves the stray armed tick harmless: Stopped ignores onTick (top-state no-op).
sm.post('stop');
await sm.sync();
expect(sm.currentState).equals(Stopped);
clock.advance(HOUR_MS);
await sm.sync();
expect(sm.ctx.ticks).equals(48); // no further ticks counted after stop
});

it('drives the internal onTick directly with makeTestActor (the test actor exposes the merged protocol)', async () => {
const test = ihsm.makeTestActor(HeartbeatTop, new HeartbeatCtx(), new ihsm.TestPort<HeartbeatTop>());
await test.sync();

test.post('start');
await test.sync();
expect(test.currentState).equals(Running);

// No clock, no timer: a test actor can post the internal `onTick` itself.
test.post('onTick');
test.post('onTick');
test.post('onTick');
await test.sync();
expect(test.ctx.ticks).equals(3);

// The test actor also exposes the typed port and a subscribe() channel — neither exists on
// the public Actor surface.
expect(test.port).to.be.instanceOf(ihsm.TestPort);
expect(typeof test.subscribe).to.equal('function');
});

it('traces every event via subscribe → TestPort.record (unique to the test actor)', async () => {
const port = new ihsm.TestPort<HeartbeatTop>();
const test = ihsm.makeTestActor(HeartbeatTop, new HeartbeatCtx(), port);
const sub = test.subscribe(m => port.record(m.event, ...m.payload));
await test.sync();

test.post('start');
await test.sync();
test.post('onTick');
await test.sync();

expect(port.events).to.deep.equal(['start', 'onTick']);
expect(port.last?.event).to.equal('onTick');

port.clear();
expect(port.count).to.equal(0);
sub.dispose();
test.post('stop');
await test.sync();
expect(port.count).to.equal(0);
});

it('keeps internal events out of the public surface and enforces disjoint protocols (compile-time)', () => {
// These checks are validated by `tsc` (the examples project is type-checked). They run
// under ts-node transpile-only at test time, so the body must stay side-effect free:
// it is declared but never invoked.
const _typeChecks = (): void => {
// Inferred production surface: makeActor exposes only the public protocol.
const sm = ihsm.makeActor(HeartbeatTop, new HeartbeatCtx(), new ihsm.TestPort<HeartbeatTop>());

// @ts-expect-error 'onTick' is internal — not callable on the public Actor surface.
sm.post('onTick');
// @ts-expect-error 'start' takes no arguments.
sm.post('start', 1);
sm.post('start'); // valid public event

interface CollidingInternal {
// Collides with HeartbeatPublic.start — must be rejected by the disjointness gate.
start(): void;
}
// @ts-expect-error public and internal protocols must not share keys ('start').
ihsm.makeActor<HeartbeatCtx, HeartbeatPublic, CollidingInternal>(HeartbeatTop, new HeartbeatCtx(), new ihsm.TestPort<HeartbeatTop>());
};

expect(typeof _typeChecks).to.equal('function');
});
});

Try it

Dispatch events in the Trace panel and compare output to the diagram and source. Run npm run test:examples -- --grep 'Testing 01' for a headless check.

UML state diagram

StateState: HeartbeatTop · running: false · ticks: 0 · simulated: 0h

2. Network fetch behind a port

The network is the canonical flaky dependency. Behind a port, a test decides what the response is — request is a @mock method scripted with request.default — and exactly when it arrives, by pushing onResponse / onFailure inward with send, so the in-flight state is reachable and a cancelled request provably can never mutate state.

When and why: Network fetch behind a port

Network calls are the classic flaky dependency. Put fetch() (against, say, https://google.com) behind a port and a test decides what the response is and when it arrives — no sockets, no DNS, no latency.

Why stub + send: request is an abstract @mock method scripted with port.request.default(...) to return an id and an abort Disposable — but it delivers no response from the synchronous call. The test settles the request when it wants by pushing onResponse / onFailure inward with port.send(...). That separation makes the in-flight Fetching state reachable and the whole flow timer-free; cancel() disposes the request so a late response is provably dropped.

How to test it: one abstract @mock serves every scenario — drive it through the public path (fetch → assert Fetchingsend('onResponse', …) → assert Done/Failed), or pin Fetching directly with initialize: false and post the settled event.

State diagram

UML state diagram

Machine source, then the unit tests

Runnable code lives under testing-02-network-fetch. First the machine (the code under test), then the mocha + chai spec that drives it — the same tests run headlessly with npm run test:examples -- --grep 'Testing 02'.

examples/testing-02-network-fetch/machine.ts

/**
* Network fetch behind a Port — the canonical "I/O you don't control the timing of" case.
*
* A machine issues an HTTP request and reacts to the response *whenever it arrives*. The real
* world here is `fetch()` against, say, `https://google.com`; in tests we never touch the
* network. All of it lives behind a {@link ihsm.Port}:
*
* - Public protocol: `fetch(url)`, `cancel()`, and a `body` service clients may call.
* - Internal protocol: `onResponse` / `onFailure` — pushed by the port when the request settles.
* (Note: `onFailure`, not `onError` — `onError` is a reserved ihsm lifecycle hook.)
* - Port: `request(url)` returns a `ResultWithSubscription` whose `dispose()` aborts the request.
*
* The decisive trick for determinism: the mock lets the test choose *when* the response lands
* (`flush()`), so "in flight" and "settled" are both reachable without a single timer.
*/
import * as ihsm from '../../src';
import * as self from './machine';

export interface FetchCtx {
url: string;
requestId: number;
status: number;
body: string;
error: string;
/** Abort handle for the in-flight request; owned by the machine. */
subscription?: ihsm.Disposable;
}

/** Public protocol — what UI / clients may post or call. */
export interface FetchPublic {
fetch(url: string): void;
cancel(): void;
body(resolve: ihsm.ResolveCallback<string>, reject: ihsm.RejectCallback): void;
}

/** Internal protocol — settled-request events, pushed by the port only. */
export interface FetchInternal {
onResponse(status: number, body: string): void;
onFailure(message: string): void;
}

/** Outbound boundary to the (impure) network. */
export interface FetchPort extends ihsm.PortHandle<FetchCtx, FetchInternal> {
/** Start a request; `dispose()` on the result aborts it. Returns a request id. */
request(url: string): ihsm.ResultWithSubscription<number>;
}

/**
* Root state. `fetch` (start a request) is the shared behaviour of every *resting* state —
* `Idle`, `Done`, `Failed` all inherit it — so a re-fetch from any settled state just works.
* `Fetching` overrides `fetch` to a no-op to reject a second request while one is in flight.
* Late settled-events (`onResponse` / `onFailure` after a `cancel`) are safe no-ops here.
*/
export class FetchTop extends ihsm.TopState<FetchCtx, FetchPublic, FetchInternal, FetchPort> {
fetch(url: string): void {
this.ctx.url = url;
this.ctx.error = '';
// All network I/O flows through the port — never `fetch()` directly in a handler.
const { value, subscription } = this.port.request(url);
this.ctx.requestId = value;
this.ctx.subscription = subscription;
this.transition(Fetching);
}

cancel(): void {} // ignored unless Fetching
onResponse(_status: number, _body: string): void {} // ignored unless Fetching
onFailure(_message: string): void {} // ignored unless Fetching

/** Reading the last body is always allowed. */
body(resolve: ihsm.ResolveCallback<string>): void {
resolve(this.ctx.body);
}
}

@ihsm.InitialState
export class Idle extends FetchTop {}

export class Done extends FetchTop {}

export class Failed extends FetchTop {}

export class Fetching extends FetchTop {
fetch(_url: string): void {} // ignored: a request is already in flight

onResponse(status: number, body: string): void {
this.clearSubscription();
this.ctx.status = status;
this.ctx.body = body;
this.transition(status >= 200 && status < 300 ? Done : Failed);
}

onFailure(message: string): void {
this.clearSubscription();
this.ctx.error = message;
this.transition(Failed);
}

cancel(): void {
this.clearSubscription();
this.transition(Idle);
}

private clearSubscription(): void {
this.ctx.subscription?.dispose();
this.ctx.subscription = undefined;
}
}

ihsm.registerStateNames(self);

export function freshCtx(): FetchCtx {
return { url: '', requestId: 0, status: 0, body: '', error: '' };
}

examples/testing-02-network-fetch/tutorial.spec.ts — mocha + chai tests, executed against the mocks

import { expect } from 'chai';
import 'mocha';

import * as ihsm from '../../src/testing';
import { FetchTop, Idle, Fetching, Done, Failed, freshCtx } from './machine';

/**
* Deterministic mock network port. `request` is declared **`abstract` with the exact port
* signature** and the class is decorated `@`{@link ihsm.mock} — no body. A test scripts what
* `request` returns with `port.request.default(...)` (the request id and the abort `Disposable`),
* but **no response is delivered from inside the call**. The test then settles the request *when it
* wants* by pushing `onResponse` / `onFailure` inward via {@link ihsm.BasePort.send | send}. That
* separation is what makes the in-flight `Fetching` state observable and the whole thing
* timer-free — one mock serves the success, failure, and cancellation scenarios.
*/
@ihsm.mock
abstract class MockFetchPort extends ihsm.TestPort<FetchTop> {
abstract request(url: string): ihsm.ResultWithSubscription<number>;
}

describe('Testing 02: network fetch behind a port', () => {
// One mock per test: `beforeEach` rebuilds it fresh (no clearing needed) and arms `request`.
let port: ihsm.Mock<MockFetchPort, FetchTop>;
let nextId: number;

beforeEach(() => {
port = ihsm.makeTestPort(MockFetchPort);
nextId = 0;
// Hand back an id and an abort handle that records when disposed — but deliver **no** response.
port.request.default(() => {
const requestId = ++nextId;
return {
value: requestId,
subscription: { dispose: () => port.record('abort', requestId) },
};
});
});

it('drives a successful fetch, observing the in-flight state before settling it', async () => {
const fetcher = ihsm.makeTestActor(FetchTop, freshCtx(), port);
await fetcher.sync();
expect(fetcher.currentState).equals(Idle);

fetcher.post('fetch', 'https://google.com');
await fetcher.sync();
// Request issued, but no response was delivered from the sync call — we control when it lands.
expect(fetcher.currentState).equals(Fetching);
expect(port.trace).to.deep.equal(['request:https://google.com']);
// `request.calls` is typed exactly as the port method's parameters — `[url: string][]`.
expect(port.request.calls).to.deep.equal([['https://google.com']]);

port.send('onResponse', 200, '<!doctype html><title>google</title>'); // network "replies" now
await fetcher.sync();
expect(fetcher.currentState).equals(Done);

const body = await fetcher.call('body');
expect(body).to.contain('google');
});

it('routes a non-2xx response to Failed', async () => {
const fetcher = ihsm.makeTestActor(FetchTop, freshCtx(), port);
await fetcher.sync();

fetcher.post('fetch', 'https://google.com/down');
await fetcher.sync();
port.send('onResponse', 503, 'unavailable');
await fetcher.sync();

expect(fetcher.currentState).equals(Failed);
});

it('routes a transport error to Failed via onFailure', async () => {
const fetcher = ihsm.makeTestActor(FetchTop, freshCtx(), port);
await fetcher.sync();

fetcher.post('fetch', 'https://nope.invalid');
await fetcher.sync();
port.send('onFailure', 'ENOTFOUND');
await fetcher.sync();

expect(fetcher.currentState).equals(Failed);
expect(fetcher.ctx.error).equals('ENOTFOUND');
});

it('cancel() aborts the request so a late response is never applied', async () => {
const fetcher = ihsm.makeTestActor(FetchTop, freshCtx(), port);
await fetcher.sync();

fetcher.post('fetch', 'https://google.com');
await fetcher.sync();
expect(fetcher.currentState).equals(Fetching);

fetcher.post('cancel');
await fetcher.sync();
expect(fetcher.currentState).equals(Idle);
expect(port.trace).to.include('abort:1'); // dispose() ran when the machine cancelled

// The source replies after the abort — Idle ignores onResponse (top-state no-op), so it is dropped.
port.send('onResponse', 200, 'too late');
await fetcher.sync();
expect(fetcher.currentState).equals(Idle);
expect(fetcher.ctx.body).equals('');
});

it('pins the in-flight state directly with makeTestActor (no fetch needed)', async () => {
const test = ihsm.makeTestActor(
Fetching, // pin the in-flight state directly
freshCtx(), // fresh domain context
port,
{ initialize: false } // skip the @InitialState walk — start in Fetching
);
await test.sync();
expect(test.currentState).equals(Fetching);

// No live port needed: post the settled-response event the port would have raised.
test.post('onResponse', 200, 'pong');
await test.sync();
expect(test.currentState).equals(Done);
expect(test.ctx.body).equals('pong');
});

it('enforces the public surface and disjoint protocols (compile-time)', () => {
// Validated by `tsc` (the examples project is type-checked); the body never runs. The
// production `makeActor` surface here is what demonstrates the public/internal boundary.
const _typeChecks = (): void => {
const fetcher = ihsm.makeActor(FetchTop, freshCtx(), ihsm.makeTestPort(MockFetchPort));

// @ts-expect-error 'onResponse' is internal — not callable on the public Actor surface.
fetcher.post('onResponse', 200, 'x');
// @ts-expect-error 'fetch' requires a url argument.
fetcher.post('fetch');
fetcher.post('fetch', 'https://google.com'); // valid public event

// T2 — services are invoked with call(), plain events with post():
// @ts-expect-error 'body' is a service (resolve/reject signature); it is not postable.
fetcher.post('body');
void fetcher.call('body'); // valid: 'body' is a service
// @ts-expect-error 'fetch' is a void event; it is not callable.
void fetcher.call('fetch', 'https://google.com');

interface CollidingInternal {
// Collides with FetchPublic.fetch — must be rejected by the disjointness gate.
fetch(url: string): void;
}
// @ts-expect-error public and internal protocols must not share keys ('fetch').
ihsm.makeActor<ReturnType<typeof freshCtx>, { fetch(u: string): void }, CollidingInternal>(FetchTop, freshCtx(), ihsm.makeTestPort(MockFetchPort));
};

expect(typeof _typeChecks).to.equal('function');
});
});

Try it

Dispatch events in the Trace panel and compare output to the diagram and source. Run npm run test:examples -- --grep 'Testing 02' for a headless check.

UML state diagram

StateState: FetchTop · url: — · status: — · body: 0 bytes

3. Event streaming behind a port

A push source emits on its own schedule, which is what makes naive code flaky. Behind a port, the source can only deliver while a subscription is live, so "stop listening" provably detaches — and a test can drive the stream itself with no timers.

When and why: Event streaming behind a port

Use a port whenever the machine depends on a push source whose timing you do not control — OS input, a file watcher, a network socket, a WebSocket/SSE feed. The port is the single seam where impurity lives; everything above it is pure and deterministically testable.

Why a public/internal protocol split: clients post listen / stopListening; the source pushes onMouseMove. Keeping them separate means a client can never forge a stream event, and a test can drive either side. stopListening dispose()s the subscription, so the source provably goes quiet.

Device state lives in the mock, not the actor: the OS owns the cursor and keeps moving it whether or not you are subscribed, so the abstract @mock holds the pointer position in public fields (cursor, live) and exposes drive commands (moveTo / moveBy / path) the tester calls; the machine stores only the moves it observed while listening. The two legitimately diverge — model the simulated world inside the test double, and let the machine own only what it perceived.

How to test it: script subscribe with port.subscribe.default(...) so it only delivers while live, then drive the mock and post internal events directly with makeTestActor. Either way there are no timers and no races — advance with sync(). Press listen below, then move the pointer over the pad (or run simulated session) and watch the trace.

State diagram

UML state diagram

Machine source, then the unit tests

Runnable code lives under testing-03-event-streaming. First the machine (the code under test), then the mocha + chai spec that drives it — the same tests run headlessly with npm run test:examples -- --grep 'Testing 03'.

examples/testing-03-event-streaming/machine.ts

/**
* Event-streaming source behind a Port — "listen" / "stop listening" for mouse moves.
*
* The same shape applies to any push source you cannot control the timing of: a file watcher,
* a network socket, a WebSocket / SSE feed, or OS input. All of it lives behind a {@link ihsm.Port}
* so the machine stays pure and tests can deliver the stream deterministically.
*
* - Public protocol: `listen`, `stopListening` (what a UI button posts).
* - Internal protocol: `onMouseMove` (what the *source* pushes inward — never posted by clients).
* - Port: `subscribe()` opens the stream and returns a `ResultWithSubscription` whose `dispose()`
* closes it. Disposing is how "stop listening" guarantees the source goes quiet.
*/
import * as ihsm from '../../src';
import * as self from './machine';

export interface Point {
x: number;
y: number;
}

export interface MouseCtx {
moves: Point[];
listening: boolean;
streamId: number;
/** Teardown handle for the active stream; owned by the machine. */
subscription?: ihsm.Disposable;
}

/** Public protocol — posted by UI buttons / clients. */
export interface MousePublic {
listen(): void;
stopListening(): void;
}

/** Internal protocol — pushed by the stream source only. */
export interface MouseInternal {
onMouseMove(x: number, y: number): void;
}

/** Outbound boundary to the (impure) event source. */
export interface MouseStreamPort extends ihsm.PortHandle<MouseCtx, MouseInternal> {
/** Open the stream; `dispose()` on the result closes it. Returns a stream id. */
subscribe(): ihsm.ResultWithSubscription<number>;
}

/**
* Root state. The "wrong state" cases are safe no-ops so the live demo never crashes:
* a move that arrives while idle, or a redundant listen/stop, is simply ignored.
*/
export class MouseTop extends ihsm.TopState<MouseCtx, MousePublic, MouseInternal, MouseStreamPort> {
listen(): void {} // ignored unless Idle
stopListening(): void {} // ignored unless Listening
onMouseMove(_x: number, _y: number): void {} // ignored unless Listening
}

@ihsm.InitialState
export class Idle extends MouseTop {
listen(): void {
const { value, subscription } = this.port.subscribe();
this.ctx.streamId = value;
this.ctx.subscription = subscription;
this.ctx.listening = true;
this.transition(Listening);
}
}

export class Listening extends MouseTop {
onMouseMove(x: number, y: number): void {
this.ctx.moves.push({ x, y });
}

stopListening(): void {
this.ctx.subscription?.dispose();
this.ctx.subscription = undefined;
this.ctx.listening = false;
this.transition(Idle);
}
}

ihsm.registerStateNames(self);

export function freshCtx(): MouseCtx {
return { moves: [], listening: false, streamId: 0 };
}

examples/testing-03-event-streaming/tutorial.spec.ts — mocha + chai tests, executed against the mocks

import { expect } from 'chai';
import 'mocha';

import * as ihsm from '../../src/testing';
import { MouseTop, Idle, Listening, Point, freshCtx } from './machine';

/**
* Deterministic mock stream source. `subscribe` is declared **`abstract` with the exact port
* signature** and the class is decorated `@`{@link ihsm.mock}; the test scripts what it returns with
* `port.subscribe.default(...)`.
*
* The decisive design point: **the pointer's real position lives in the mock, not in the actor** —
* and it is held in **public** fields the test reads and drives directly. The OS owns the cursor and
* keeps moving it whether or not your app is subscribed, so the mock models that device state
* (`cursor`, `live`) and exposes drive commands (`moveTo` / `moveBy` / `path`). The actor only ever
* stores the moves it *observed while listening*; the two can legitimately diverge. Nothing is
* emitted from `subscribe`; a move is delivered inward only while the subscription is live.
*/
@ihsm.mock
abstract class MockMouseStream extends ihsm.TestPort<MouseTop> {
abstract subscribe(): ihsm.ResultWithSubscription<number>;

/** The simulated OS pointer — device state owned by the mock, not the actor's business. */
cursor: Point = { x: 0, y: 0 };
/** Whether the stream is currently subscribed (toggled by the scripted subscribe/dispose). */
live = false;

/** Move the simulated pointer to an absolute position; delivered only while listening. */
moveTo(x: number, y: number): void {
this.cursor = { x, y };
this.deliver();
}

/** Nudge the simulated pointer relative to its stored position; delivered only while listening. */
moveBy(dx: number, dy: number): void {
this.cursor = { x: this.cursor.x + dx, y: this.cursor.y + dy };
this.deliver();
}

/** Replay a gesture: a sequence of absolute points, one delivered move each. */
path(points: Point[]): void {
for (const point of points) {
this.moveTo(point.x, point.y);
}
}

// The device always moves; only a live subscription delivers the event inward to the machine.
private deliver(): void {
if (this.live) {
this.send('onMouseMove', this.cursor.x, this.cursor.y);
} else {
this.record('drop', this.cursor.x, this.cursor.y); // moved while unsubscribed — not delivered
}
}
}

describe('Testing 03: event streaming (mouse)', () => {
// One mock stream per test: `beforeEach` rebuilds it fresh (so device state starts clean) and
// arms `subscribe`.
let stream: ihsm.Mock<MockMouseStream, MouseTop>;
let nextId: number;

beforeEach(() => {
stream = ihsm.makeTestPort(MockMouseStream);
nextId = 0;
// Script `subscribe` so it opens the stream (`live = true`) and closes it on dispose.
stream.subscribe.default(() => {
const streamId = ++nextId;
stream.live = true;
return {
value: streamId,
subscription: {
dispose: () => {
stream.live = false;
stream.record('unsubscribe', streamId);
},
},
};
});
});

it('streams mouse moves only while listening, and stops on stopListening', async () => {
const sm = ihsm.makeTestActor(MouseTop, freshCtx(), stream);
await sm.sync();
expect(sm.currentState).equals(Idle);

// The source is closed before "listen": the pointer moves but nothing is delivered.
stream.moveTo(1, 1);
await sm.sync();
expect(sm.ctx.moves).to.deep.equal([]);
expect(stream.trace).to.include('drop:1,1');

// Press "listen" → the machine subscribes through the port (running the scripted subscribe).
sm.post('listen');
await sm.sync();
expect(sm.currentState).equals(Listening);
expect(sm.ctx.listening).equals(true);
expect(stream.trace).to.include('subscribe');
expect(stream.live).equals(true);

// Now the source streams; replay a gesture from the mock's stored device state.
stream.path([
{ x: 10, y: 20 },
{ x: 11, y: 22 },
{ x: 12, y: 24 },
]);
await sm.sync();
expect(sm.ctx.moves).to.deep.equal([
{ x: 10, y: 20 },
{ x: 11, y: 22 },
{ x: 12, y: 24 },
]);

// Press "stop listening" → subscription disposed, source goes quiet.
sm.post('stopListening');
await sm.sync();
expect(sm.currentState).equals(Idle);
expect(sm.ctx.listening).equals(false);
expect(stream.trace).to.include('unsubscribe:1');
expect(stream.live).equals(false);

// Moves after stopping are dropped again — count is unchanged.
stream.moveTo(99, 99);
await sm.sync();
expect(sm.ctx.moves).to.have.length(3);
});

it('keeps the pointer position in the mock, not the actor (device state vs. observed state)', async () => {
const sm = ihsm.makeTestActor(MouseTop, freshCtx(), stream);
await sm.sync();

// The OS moves the pointer before we ever listen: the *device* position advances in the mock,
// but the actor observed nothing — its state owns only what arrived while subscribed.
stream.moveBy(5, 0);
stream.moveBy(0, 5);
await sm.sync();
expect(stream.cursor).to.deep.equal({ x: 5, y: 5 }); // device state lives in the mock
expect(sm.ctx.moves).to.deep.equal([]); // actor saw nothing

// Subscribe, then nudge relative to where the device actually is — not where the actor "left off".
sm.post('listen');
await sm.sync();
stream.moveBy(10, 10);
await sm.sync();
expect(stream.cursor).to.deep.equal({ x: 15, y: 15 });
expect(sm.ctx.moves).to.deep.equal([{ x: 15, y: 15 }]);

// Stop listening; the device keeps moving (mock position advances) while the actor stays put.
sm.post('stopListening');
await sm.sync();
stream.moveBy(100, 100);
await sm.sync();
expect(stream.cursor).to.deep.equal({ x: 115, y: 115 }); // device moved on
expect(sm.ctx.moves).to.deep.equal([{ x: 15, y: 15 }]); // actor unchanged
});

it('drives the stream directly with makeTestActor (post internal events, no device needed)', async () => {
const test = ihsm.makeTestActor(MouseTop, freshCtx(), stream);
await test.sync();

expect(test.port.hsm()).to.not.equal(undefined);

test.post('listen');
await test.sync();
expect(test.currentState).equals(Listening);

// Internal events posted directly on the full test surface.
test.post('onMouseMove', 5, 6);
test.post('onMouseMove', 7, 8);
await test.sync();
expect(test.ctx.moves).to.deep.equal([
{ x: 5, y: 6 },
{ x: 7, y: 8 },
]);

// A move while idle is ignored by the machine (top-state no-op).
test.post('stopListening');
await test.sync();
test.post('onMouseMove', 1, 1);
await test.sync();
expect(test.ctx.moves).to.have.length(2);
});
});

Try it

Dispatch events in the Trace panel and compare output to the diagram and source. Run npm run test:examples -- --grep 'Testing 03' for a headless check.

UML state diagram

StateState: MouseTop · listening: false · moves: 0 · last: —
Mouse padPress “listen”, then move the pointer here to stream moves. Press “stop listening” to go quiet.

4. Fault injection & seeded DST

The payoff: make failure reproducible. A retrying worker's faults come from a seeded PRNG scripted into the @mock's attempt with attempt.default — never Math.random() or the clock — so the same seed replays the exact fault sequence. Keep the seed, replay it, debug a perfectly reproducible red run.

When and why: Fault injection & seeded DST

Deterministic Simulation Testing (DST) makes failure reproducible. A worker retries a flaky operation; whether each attempt fails is decided by a seeded PRNG — never Math.random() or the clock. Same seed ⇒ same fault sequence ⇒ a red run you can replay byte-for-byte.

One @mock, scripted per scenario: attempt is an abstract @mock method whose calls are auto-recorded (port.trace is the golden list of attempts that ran). The test scripts it with port.attempt.default(...) — either port.feedRandom(...) plus port.random() for a seeded fault injector that pushes onResult inward, or a no-op so the test drives onResult by hand. Retries are ordinary run-to-completion events, so there is nothing to race.

How to test it: seeded (run twice with one seed; assert port.trace, ctx.log, and outcome are identical; pin failRate to 0/1 for guaranteed terminals), or hand-injected (a no-op attempt.default; post onResult(false)/onResult(true) to walk the retry budget, asserting port.attempt.calls).

State diagram

UML state diagram

Machine source, then the unit tests

Runnable code lives under testing-04-fault-injection. First the machine (the code under test), then the mocha + chai spec that drives it — the same tests run headlessly with npm run test:examples -- --grep 'Testing 04'.

examples/testing-04-fault-injection/machine.ts

/**
* Fault injection & seeded Deterministic Simulation Testing (DST).
*
* The point of DST is to make *failure* reproducible. A worker performs an operation that can
* fail (a flaky RPC, a disk hiccup) and retries up to a budget. The flakiness lives entirely in
* the {@link ihsm.Port}, driven by a **seeded** pseudo-random generator — never `Math.random()`
* or the clock. Same seed ⇒ identical fault sequence ⇒ a red test you can replay byte-for-byte.
*
* - Public protocol: `run()` — kick off the operation.
* - Internal protocol: `onResult(ok)` — the outcome of one attempt, pushed by the port.
* - Port: `attempt(n)` performs attempt `n` and reports back via the inbound poster.
*/
import * as ihsm from '../../src';
import * as self from './machine';

export interface WorkerCtx {
/** Total attempts allowed before giving up. */
maxAttempts: number;
/** Attempts made so far. */
attempts: number;
/** Human-readable outcome of each attempt — handy for golden-trace assertions. */
log: string[];
}

/** Public protocol — what a client posts. */
export interface WorkerPublic {
run(): void;
}

/** Internal protocol — per-attempt outcome, pushed by the (fault-injecting) port. */
export interface WorkerInternal {
onResult(ok: boolean): void;
}

/** Outbound boundary to the impure, occasionally-failing operation. */
export interface FaultPort extends ihsm.PortHandle<WorkerCtx, WorkerInternal> {
/** Perform attempt number `n`; the result arrives later as an internal `onResult`. */
attempt(n: number): void;
}

/** Root state. A stray `onResult` outside `Working` is a safe no-op. */
export class WorkerTop extends ihsm.TopState<WorkerCtx, WorkerPublic, WorkerInternal, FaultPort> {
run(): void {} // ignored unless Idle/Succeeded/Failed
onResult(_ok: boolean): void {} // ignored unless Working
}

@ihsm.InitialState
export class Idle extends WorkerTop {
run(): void {
this.ctx.attempts = 1;
this.ctx.log = [];
this.port.attempt(this.ctx.attempts);
this.transition(Working);
}
}

export class Working extends WorkerTop {
onResult(ok: boolean): void {
this.ctx.log.push(`attempt ${this.ctx.attempts}: ${ok ? 'ok' : 'fail'}`);
if (ok) {
this.transition(Succeeded);
return;
}
if (this.ctx.attempts < this.ctx.maxAttempts) {
this.ctx.attempts += 1;
this.port.attempt(this.ctx.attempts); // retry — result comes back as another onResult
return;
}
this.transition(Failed);
}
}

/** A re-runnable terminal: `run()` (inherited from the top) restarts the operation. */
export class Succeeded extends WorkerTop {
run(): void {
Idle.prototype.run.call(this);
}
}

export class Failed extends WorkerTop {
run(): void {
Idle.prototype.run.call(this);
}
}

ihsm.registerStateNames(self);

export function freshCtx(maxAttempts = 5): WorkerCtx {
return { maxAttempts, attempts: 0, log: [] };
}

examples/testing-04-fault-injection/tutorial.spec.ts — mocha + chai tests, executed against the mocks

import { expect } from 'chai';
import 'mocha';

import * as ihsm from '../../src/testing';
import { WorkerTop, Working, Succeeded, Failed, freshCtx } from './machine';

/**
* Tiny seeded PRNG (mulberry32). Pure and deterministic — the whole point of DST is that the
* randomness is reproducible, so it must never come from `Math.random()` or the clock.
*/
function mulberry32(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b79f5) | 0;
let t = Math.imul(state ^ (state >>> 15), 1 | state);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}

/**
* Mock for the flaky operation. `attempt` is declared **`abstract` with the exact port signature**
* and the class is decorated `@`{@link ihsm.mock}; the call is auto-recorded (the golden trace of
* which attempts ran). One mock serves every scenario: the test scripts each `attempt` with
* `port.attempt.default(...)` — either a seeded fault injector that pushes `onResult` inward, or a
* no-op so the test can drive `onResult` by hand.
*/
@ihsm.mock
abstract class FaultMock extends ihsm.TestPort<WorkerTop> {
abstract attempt(n: number): void;
}

/** Drive the actor until it reaches a terminal state (bounded so a bug can't hang the suite). */
async function runToCompletion(sm: { sync(): Promise<void>; currentState: unknown }, budget = 50): Promise<void> {
for (let i = 0; i < budget; i++) {
await sm.sync();
if (sm.currentState === Succeeded || sm.currentState === Failed) {
return;
}
}
throw new Error('worker did not settle within budget');
}

describe('Testing 04: fault injection & seeded DST', () => {
it('is reproducible: the same seed replays the exact fault sequence and outcome', async () => {
const runOnce = async (): Promise<{ state: unknown; calls: string[]; log: string[] }> => {
const port = ihsm.makeTestPort(FaultMock);
const rng = mulberry32(0x1234abcd);
const failRate = 0.5;
for (let i = 0; i < 20; i++) {
port.feedRandom(rng()); // script TestPort.random() — never Math.random() at decision time
}
port.attempt.default(() => port.send('onResult', port.random() >= failRate));

const worker = ihsm.makeTestActor(WorkerTop, freshCtx(5), port);
await worker.sync();
worker.post('run');
await runToCompletion(worker);
return { state: worker.currentState, calls: [...port.trace], log: worker.ctx.log };
};

const a = await runOnce();
const b = await runOnce();

expect(b.calls).to.deep.equal(a.calls); // identical sequence of attempt() calls
expect(b.state).to.equal(a.state); // identical outcome
expect(b.log).to.deep.equal(a.log); // identical per-attempt pass/fail log
});

it('exhausts the retry budget and Fails when every attempt faults (failRate = 1)', async () => {
const port = ihsm.makeTestPort(FaultMock);
port.attempt.default(() => port.send('onResult', false)); // always fail

const worker = ihsm.makeTestActor(WorkerTop, freshCtx(3), port);
await worker.sync();
worker.post('run');
await runToCompletion(worker);

expect(worker.currentState).equals(Failed);
expect(worker.ctx.attempts).equals(3);
expect(port.trace).to.deep.equal(['attempt:1', 'attempt:2', 'attempt:3']);
expect(worker.ctx.log).to.deep.equal(['attempt 1: fail', 'attempt 2: fail', 'attempt 3: fail']);
});

it('succeeds on the first attempt when no fault is injected (failRate = 0)', async () => {
const port = ihsm.makeTestPort(FaultMock);
port.attempt.default(() => port.send('onResult', true)); // always succeed

const worker = ihsm.makeTestActor(WorkerTop, freshCtx(3), port);
await worker.sync();
worker.post('run');
await runToCompletion(worker);

expect(worker.currentState).equals(Succeeded);
expect(worker.ctx.attempts).equals(1);
});

it('injects faults by hand: a no-op script records the retries, the test settles them', async () => {
const port = ihsm.makeTestPort(FaultMock);
port.attempt.default(() => {}); // record the call (automatic), but report nothing — the test drives onResult

const test = ihsm.makeTestActor(WorkerTop, freshCtx(2), port);
await test.sync();

test.post('run');
await test.sync();
expect(test.currentState).equals(Working);

test.post('onResult', false); // inject a fault → retry
await test.sync();
expect(test.currentState).equals(Working);
expect(test.ctx.attempts).equals(2);

test.post('onResult', false); // fault again → budget exhausted
await test.sync();
expect(test.currentState).equals(Failed);
expect(port.trace).to.deep.equal(['attempt:1', 'attempt:2']); // the recorded retries
// `attempt.calls` is typed `[n: number][]` — the exact arguments of each retry.
expect(port.attempt.calls).to.deep.equal([[1], [2]]);
expect(test.ctx.log).to.deep.equal(['attempt 1: fail', 'attempt 2: fail']);
});

it('enforces the public surface and disjoint protocols (compile-time)', () => {
// Validated by `tsc` (the examples project is type-checked); the body never runs. The
// production `makeActor` surface here is what demonstrates the public/internal boundary.
const _typeChecks = (): void => {
const worker = ihsm.makeActor(WorkerTop, freshCtx(), ihsm.makeTestPort(FaultMock));

// @ts-expect-error 'onResult' is internal — not callable on the public Actor surface.
worker.post('onResult', true);
worker.post('run'); // valid public event

interface CollidingInternal {
// Collides with WorkerPublic.run — must be rejected by the disjointness gate.
run(): void;
}
// @ts-expect-error public and internal protocols must not share keys ('run').
ihsm.makeActor<ReturnType<typeof freshCtx>, { run(): void }, CollidingInternal>(WorkerTop, freshCtx(), ihsm.makeTestPort(FaultMock));
};

expect(typeof _typeChecks).to.equal('function');
});
});

Try it

Dispatch events in the Trace panel and compare output to the diagram and source. Run npm run test:examples -- --grep 'Testing 04' for a headless check.

UML state diagram

StateState: WorkerTop · attempt 0/4 · last: —

5. Subscriptions & disposables

A subscription outlives the call that created it, so it needs a teardown handle — a Disposable — and an owner. ihsm models the VS Code pattern exactly: a port method returns ResultWithSubscription (a value plus a Disposable), the machine owns the handle in its context, and disposes it on stop or a source-initiated onClosed. The mock is built with @mock + makeTestPort, so the test scripts each watch result with port.watch.default(...) — including its Disposable — and then proves the handle is released exactly once, with a byte-identical golden trace across runs.

When and why: Subscriptions & disposables

A subscription outlives the call that created it, so every one needs a teardown handle — a Disposable — and somebody must own it. ihsm models exactly the VS Code pattern: a port method returns ResultWithSubscription (a value plus a Disposable), the machine stores the handle in its context (its own context.subscriptions), and disposes it on stop or a source-initiated onClosed. dispose() is idempotent, so overlapping teardown is always safe.

Authoring the mock — @mock + makeTestPort: declare each port method abstract with the exact port signature and decorate the class with @mock — no bodies, the port surface is inferred from the machine's TopState. Build the mock with makeTestPort(WatcherMock), then script each call with port.watch.default(impl) (persistent) or port.watch.once(impl) (one-shot, FIFO) — including the Disposable it returns, so the test controls teardown; inspect port.watch.calls (typed args) and port.watch.reset() to reuse the mock. Two separate channels: default/once script what an outbound method returns; port.send('onChange', v) pushes inbound internal events. An unscripted method throws PreloadError naming the method — never a silent undefined.

DST is the payoff: subscribe, push changes, stop, and prove the handle was disposed exactly once with no leak; a late change after teardown is dropped; the golden trace (['watch:/etc/hosts', 'dispose watch /etc/hosts']) is byte-identical across runs. No setTimeout, no real filesystem, no Math.random() — advance with sync() and decide every event yourself.

State diagram

UML state diagram

Machine source, then the unit tests

Runnable code lives under testing-05-subscriptions-and-disposables. First the machine (the code under test), then the mocha + chai spec that drives it — the same tests run headlessly with npm run test:examples -- --grep 'Testing 05'.

examples/testing-05-subscriptions-and-disposables/machine.ts

/**
* Subscriptions & `Disposable` — owning a teardown handle, the VS Code way.
*
* If you have written a VS Code extension you already know this shape. You subscribe to something,
* you get a `Disposable` back, and you are responsible for calling `dispose()` to detach:
*
* ```ts
* // VS Code: the subscription IS a Disposable; you must dispose it (or push it to context.subscriptions)
* const sub: vscode.Disposable = vscode.workspace.onDidChangeTextDocument(e => report(e));
* context.subscriptions.push(sub); // disposed automatically when the extension deactivates
* // ...later, to stop listening early:
* sub.dispose();
* ```
*
* ihsm models exactly this. A port method that opens an ongoing observation returns a
* {@link ihsm.ResultWithSubscription} — a `value` **plus** a `Disposable`. The machine stores the
* `Disposable` in its context (it *owns* it, like `context.subscriptions`) and calls `dispose()`
* when it stops watching. This `Watcher` machine watches a path for changes:
*
* - Public protocol: `start(path)`, `stop()` — what a client posts.
* - Internal protocol: `onChange(version)`, `onClosed()` — what the *source* pushes back.
* - Port: `watch(path)` opens the watch and hands back the `Disposable` that closes it.
*
* The whole point: subscriptions are resources. Own the `Disposable`, dispose it exactly once on
* teardown, and a deterministic test can *prove* you did — no leaks, no late events.
*/
import * as ihsm from '../../src';
import * as self from './machine';

/** Mutable domain data shared across all states (a class, constructed fresh per actor). */
export class WatcherCtx {
/** The path currently being watched (empty when idle). */
path = '';
/** The id the source handed back for the active watch. */
watchId = 0;
/** Versions observed via `onChange`, in order — only while watching. */
changes: number[] = [];
/**
* The teardown handle for the active watch, **owned by the machine** (cf. VS Code's
* `context.subscriptions`). Disposed on `stop` / `onClosed`, then cleared.
*/
subscription?: ihsm.Disposable;
}

/** Public protocol — the only events clients may post. */
export interface WatcherPublic {
start(path: string): void;
stop(): void;
}

/** Internal protocol — pushed by the watch source only (never by a client). */
export interface WatcherInternal {
onChange(version: number): void;
onClosed(): void;
}

/** Outbound boundary to the (impure) watch source. */
export interface WatcherPort extends ihsm.PortHandle<WatcherCtx, WatcherInternal> {
/** Open a watch on `path`; `dispose()` on the result closes it. Returns a watch id. */
watch(path: string): ihsm.ResultWithSubscription<number>;
}

/** Root state. "Wrong state" events are safe no-ops so a late change can never corrupt Idle. */
export class WatcherTop extends ihsm.TopState<WatcherCtx, WatcherPublic, WatcherInternal, WatcherPort> {
start(_path: string): void {} // ignored unless Idle
stop(): void {} // ignored unless Watching
onChange(_version: number): void {} // ignored unless Watching
onClosed(): void {} // ignored unless Watching

/** Dispose the owned subscription exactly once, then forget it. Shared teardown. */
protected releaseSubscription(): void {
this.ctx.subscription?.dispose();
this.ctx.subscription = undefined;
}
}

@ihsm.InitialState
export class Idle extends WatcherTop {
start(path: string): void {
// Open the watch; take ownership of the Disposable it returns.
const { value, subscription } = this.port.watch(path);
this.ctx.path = path;
this.ctx.watchId = value;
this.ctx.subscription = subscription;
this.ctx.changes = [];
this.transition(Watching);
}
}

export class Watching extends WatcherTop {
onChange(version: number): void {
this.ctx.changes.push(version);
}

stop(): void {
this.releaseSubscription(); // client asked to stop — we dispose our handle
this.transition(Idle);
}

onClosed(): void {
this.releaseSubscription(); // source closed on its own — dispose is idempotent, so this is safe
this.transition(Idle);
}
}

ihsm.registerStateNames(self);

examples/testing-05-subscriptions-and-disposables/tutorial.spec.ts — mocha + chai tests, executed against the mocks

import { expect } from 'chai';
import 'mocha';

import * as ihsm from '../../src/testing';
import { WatcherTop, Idle, Watching, WatcherCtx } from './machine';

/**
* Mock watch source. Each method is declared **`abstract` with the exact port signature** and the
* class is decorated `@`{@link ihsm.mock}; there are no bodies. A test scripts what `watch` returns
* per call with `port.watch.default(...)` / `port.watch.once(...)` — including the
* `Disposable`, so the test controls teardown. Pushing `onChange` / `onClosed` *inward* is the
* separate, explicit {@link ihsm.BasePort.send | send} channel. One mock, many tests.
*/
@ihsm.mock
abstract class WatcherMock extends ihsm.TestPort<WatcherTop> {
abstract watch(path: string): ihsm.ResultWithSubscription<number>;
}

describe('Testing 05: subscriptions & disposables', () => {
it('owns the Disposable and disposes it exactly once on stop (DST + golden trace)', async () => {
const port = ihsm.makeTestPort(WatcherMock);

// Script the watch result: a tracked, IDEMPOTENT Disposable — exactly what a real one must be.
let disposeCount = 0;
let disposed = false;
port.watch.default(path => ({
value: 7,
subscription: {
dispose: (): void => {
if (disposed) {
return; // idempotent: a second dispose() is a harmless no-op
}
disposed = true;
disposeCount += 1;
port.record(`dispose watch ${path}`); // record teardown into the golden trace
},
},
}));

const sm = ihsm.makeTestActor(WatcherTop, new WatcherCtx(), port);
await sm.sync();
expect(sm.currentState).equals(Idle);

sm.post('start', '/etc/hosts');
await sm.sync();
expect(sm.currentState).equals(Watching);
expect(sm.ctx.watchId).equals(7); // the value the test scripted

// The source pushes changes inward — explicit, on the test's command (the `send` channel).
port.send('onChange', 1);
port.send('onChange', 2);
await sm.sync();
expect(sm.ctx.changes).to.deep.equal([1, 2]);

sm.post('stop');
await sm.sync();
expect(sm.currentState).equals(Idle);
expect(disposeCount).equals(1); // disposed exactly once — no leak, no double-free
expect(sm.ctx.subscription).equals(undefined); // the machine released its handle

// Golden trace: an exact, ordered transcript of every outbound interaction.
expect(port.trace).to.deep.equal(['watch:/etc/hosts', 'dispose watch /etc/hosts']);
});

it('drops a change that arrives after teardown — the source has gone quiet', async () => {
const port = ihsm.makeTestPort(WatcherMock);
port.watch.default(() => ({ value: 1, subscription: { dispose: () => port.record('dispose') } }));

const sm = ihsm.makeTestActor(WatcherTop, new WatcherCtx(), port);
await sm.sync();
sm.post('start', '/var/log');
await sm.sync();
port.send('onChange', 10);
await sm.sync();
sm.post('stop');
await sm.sync();
expect(sm.currentState).equals(Idle);

// A late change after dispose: a real source would not send it, but the machine must not
// corrupt Idle even if one slips through. Top-state `onChange` is a no-op.
port.send('onChange', 99);
await sm.sync();
expect(sm.ctx.changes).to.deep.equal([10]); // unchanged
});

it('releases the subscription on a source-initiated close (onClosed)', async () => {
const port = ihsm.makeTestPort(WatcherMock);
let disposed = false;
port.watch.default(() => ({
value: 3,
subscription: {
dispose: (): void => {
disposed = true;
port.record('dispose');
},
},
}));

const sm = ihsm.makeTestActor(WatcherTop, new WatcherCtx(), port);
await sm.sync();
sm.post('start', '/tmp');
await sm.sync();
expect(sm.currentState).equals(Watching);

// The source closes itself (e.g. the watched file was deleted) — it pushes onClosed inward.
port.send('onClosed');
await sm.sync();
expect(sm.currentState).equals(Idle);
expect(disposed).equals(true); // dispose() is idempotent, so releasing again is safe
expect(sm.ctx.subscription).equals(undefined);
});

it('is reproducible: the same script replays a byte-identical golden trace', async () => {
const runOnce = async (): Promise<readonly string[]> => {
const port = ihsm.makeTestPort(WatcherMock);
port.watch.default(() => ({ value: 1, subscription: { dispose: () => port.record('dispose') } }));
const sm = ihsm.makeTestActor(WatcherTop, new WatcherCtx(), port);
await sm.sync();
sm.post('start', '/p');
await sm.sync();
port.send('onChange', 1);
port.send('onChange', 2);
await sm.sync();
sm.post('stop');
await sm.sync();
return [...port.trace];
};

const a = await runOnce();
const b = await runOnce();
expect(b).to.deep.equal(a);
expect(a).to.deep.equal(['watch:/p', 'dispose']);
});

it('an unscripted abstract method throws PreloadError that names the method', () => {
const port = ihsm.makeTestPort(WatcherMock);
// Nothing was scripted: calling watch() throws, and the call is still recorded for diagnostics.
expect(() => port.watch('/x')).to.throw(ihsm.PreloadError, "'watch()'");
expect(port.trace).to.deep.equal(['watch:/x']);
});

it('once() queues one-shot results consumed in order; default() is the persistent fallback', () => {
const port = ihsm.makeTestPort(WatcherMock);
port.watch.once(() => ({ value: 1, subscription: { dispose: () => {} } }));
port.watch.once(() => ({ value: 2, subscription: { dispose: () => {} } }));
port.watch.default(() => ({ value: 9, subscription: { dispose: () => {} } })); // persistent fallback

expect(port.watch('/a').value).equals(1); // first one-shot
expect(port.watch('/b').value).equals(2); // second one-shot
expect(port.watch('/c').value).equals(9); // queue exhausted → persistent fallback
expect(port.watch('/d').value).equals(9); // fallback again

// `calls` is the typed transcript of every invocation — `[path: string][]`.
expect(port.watch.calls).to.deep.equal([['/a'], ['/b'], ['/c'], ['/d']]);

// `reset()` clears queued/persistent scripts AND the recorded calls, so the same mock is reusable.
port.watch.reset();
expect(port.watch.calls).to.deep.equal([]);
expect(() => port.watch('/e')).to.throw(ihsm.PreloadError); // back to unscripted
});

it('enforces preload result types and protocol disjointness (compile-time)', () => {
// Validated by `tsc` (the examples project is type-checked); the body never runs.
const _typeChecks = (): void => {
const port = ihsm.makeTestPort(WatcherMock);

// @ts-expect-error a stub must return the method's type (ResultWithSubscription<number>).
port.watch.default(() => 123);
port.watch.default(() => ({ value: 1, subscription: { dispose: () => {} } })); // ok

interface CollidingInternal {
// Collides with WatcherPublic.start — must be rejected by the disjointness gate.
start(path: string): void;
}
// @ts-expect-error public and internal protocols must not share keys ('start').
ihsm.makeActor<WatcherCtx, { start(p: string): void }, CollidingInternal>(WatcherTop, new WatcherCtx(), port);
};

expect(typeof _typeChecks).to.equal('function');
});
});

Try it

Dispatch events in the Trace panel and compare output to the diagram and source. Run npm run test:examples -- --grep 'Testing 05' for a headless check.

UML state diagram

StateState: WatcherTop · watching: false · path: — · changes: 0