turing-machine-js is a Turing-machine interpreter. Over the last two majors, a two-part debugging API took shape: some hooks observe execution, others coordinate it. The first part is the subject of “Three majors, two mistakes: designing a pause API for a Turing-machine interpreter” (1). The second is the subject of “A pause with two sides: the hook contract and the worker protocol” (2). Article (2) ended on a working protocol between the main thread and the machines-demo worker: an idle/busy message pair, a stepRequested flag, a synthetic paused event on click, an intervalMs field on resume. Four mechanisms, each closing one concrete problem. This article is about the next step.

Suppose a second consumer comes to the engine. Not the demo’s worker — say, an IDE extension, or an in-process educational debugger without a Web Worker. What from article (2)’s protocol does it carry over? It doesn’t need the watchdog timer — there’s no thread boundary. It doesn’t need a message queue — the handlers live in the same thread. But the “step flag”, the “pause coordination”, the “paused event with multiple causes” — all of that, it does need. And it, without having read article (2), will reinvent the whole thing.

This article is about why coordination keeps getting reinvented. Not because the worker is “special”, but because in v6 coordination had no home of its own in the library. v7 gave it one: a companion class, DebugSession, sitting next to TuringMachine in the same package.

What the worker reinvented

Re-read article (2)’s protocol through the new lens. Four mechanisms:

  1. idle/busy around the async throttle in onIter — wrapping for the watchdog timer.
  2. stepRequested flag inside the worker — the “step” modeled as a one-shot request.
  3. Synthetic paused event on click-Pause — handling an external pause command.
  4. intervalMs field on resume — reading the speed “at click time”, not “at run start”.

Sort them by ownership. The first and the fourth are about the worker: the watchdog timer lives on the main thread because there’s a boundary between threads; intervalMs arrives with every resume because the interval input and the engine sit on different threads. The second and third are about coordination: the step modeled as a one-shot event, the translation of an external pause command into a standard event. Web Workers have nothing to do with any of that — the same mechanisms would be needed in any debugger that switches the engine between paused and running.

Two out of four mechanisms — candidates for lifting out of the consumer and into the library. The worker would be left with two: the watchdog wrapping and the speed parameter on resume. That isn’t “reinvention of coordination” anymore — it’s an adapter between two interfaces: between the worker’s message protocol and the session’s API.

Where coordination can live

In v6, there were two places to put coordination:

  • On the consumer side. Every new debugger builds it from scratch. With only one consumer, its choice of where to put coordination looks principled: there’s nothing to compare it against. Two consumers would highlight the duplication; three would confirm the pattern.
  • In the engine’s hooks. This temptation is laid out in article (2): widening onStep to an async signature (v6.2.0) conflated observation with coordination. The appearance of a dedicated onIter hook (v6.4.0) solved the “where does the throttle live” question, but didn’t lift the underlying constraint: a hook is a notification point, not a state machine. A hook has no internal state of its own, no lifecycle, no control methods — it hands the listener the current moment and awaits the listener’s Promise. Coordination, however, is exactly a state machine: “pause on breakpoint → continue / step-in / step-over / step-out / stop”. For step-over and step-out, where the next pause lands is determined by stack depth.

The third option didn’t appear in v6 because the question wasn’t forced yet. With two consumers (or three, or N) the answer becomes obvious: coordination lives in a sibling class next to the engine. Not down the dependency graph (in the consumer), not inside the engine class itself (as yet another hook), but sideways — in a companion class on the same level of the library.

A side effect that’s also evidence: in v7, TuringMachine.run() is sync-and-void again — no callbacks. In v4 the same method became async to host the async pause hook (onDebugBreak, renamed to onPause in v5). Lifting coordination out erased that reason, and the signature reverted to its earlier shape. If async had been intrinsic to the engine, removing onPause wouldn’t have restored run() to its pre-v4 shape — but it did. So the async in run() was carried for that hook, not for the engine’s own execution logic.

Architecture: DebugSession as a sibling of TuringMachine in the engine package; a consumer composes both.
Architecture: DebugSession as a sibling of TuringMachine in the engine package; a consumer composes both.

What DebugSession owns

The class sits in packages/machine/src/classes/DebugSession.ts, alongside TuringMachine. Constructed directly — new DebugSession(machine, {initialState}). The engine knows nothing about the session; the session knows about the engine only through its public sync generator runStepByStep, plus one package-private accessor keyed by the symbol MACHINE_STATE_INTERNAL, which sibling modules can read and the public index.ts doesn’t re-export. Same package-private-via-Symbol pattern as STATE_INTERNAL (turing-machine-js#180) — it lets the session reach the per-iter halt-stack snapshot without exposing it through the public API.

The session listens for four events, each with its own dispatch contract:

  • step — fire-and-forget. Sync, hot-loop observation; the listener’s Promise (if any) is not awaited. One-to-one with the v6 onStep contract.
  • iter — awaited. Per-iter coordination: a throttle between iterations, step-boundary synthesis, anywhere the engine genuinely needs to wait on the listener. One-to-one with the v6 onIter contract from article (2).
  • pause — listeners are called fire-and-forget (just like for step and halt: the Promise that an async listener would return isn’t awaited). But after dispatching them, the session itself blocks on its own internal Promise — and until that Promise resolves, the #drive loop doesn’t pull the next step out of the generator. The Promise is resolved by one of the session’s control methods: continue() / stepIn() / stepOver() / stepOut() / stop(). All of them, by design, are called from outside — a UI click, a message from the main thread (when the session lives in a worker), or a timer.
  • halt — fire-and-forget. Terminal session event.

Session controls mirror DevTools conventions where that makes sense. pause() is an external pause request; it fires on the before-side of the next iter with cause: 'manual'. stepIn() — pause on the next iter. stepOver() — pause on the next iter where depth <= clickTimeDepth: frames the current iter pushes onto the stack are played through to return without stopping. stepOut() — pause when the halt-stack depth drops below the click-time depth (depth < clickTimeDepth): the current wrapper has finished, control has passed to its override. stepOut() from depth 0 throws an exception: “step out of nothing” is a programming error in the consumer’s code, not a silent no-op. setRunInterval(ms) sets a per-iter delay (in ms) between iterations. stop() is terminal — the session resolves. The session is single-use: start() is called once; to re-run, construct a fresh session.

Breakpoint detection also moved here. In v6 the runStepByStep generator itself decided which iteration should “fire” by state.debug. In v7 the generator yields a minimal MachineState, and the session reads the current tape symbol via MACHINE_STATE_INTERNAL, checks it against the before/after filters in state.debug with matchFilter, and dispatches pause itself with cause: 'breakpoint'. The engine’s generator has no “debug” logic left — it’s pure execution. The session, in turn, carries all three pause causes (breakpoint, step, manual) in one descriptor PauseInfo { side, cause } with the precedence order breakpoint > step > manual for when several causes coincide on one iter.

This is exactly the “coordination as a state machine” that didn’t fit into a hook: internal state (the pause-Promise that holds the engine, the currently active step-in/over/out mode, the halt-stack depth captured at click time), control methods, a lifecycle — all in one object with its own constructor and its own start() method.

What stays in the worker

After the coordination lift, the worker isn’t empty. What it’s now responsible for:

  • The Web Worker boundary itself — the message protocol, the user-code sandbox, the lifecycle of build / run / step requests. The worker is the natural home for that layer.
  • A per-iter delay wrapped with idle/busy messages for the WORKER_TIMEOUT_MS watchdog. The session has its own simple throttle — setRunInterval(ms). But it doesn’t fit the worker: that wait runs inside the session’s #drive loop, and there’s no way to bracket it with idle/busy from the outside — and without the bracketing, the main-thread watchdog trips on a legitimate delay as if the worker were hung. So the worker implements the wait in its own listener for the iter event (which the session awaits asynchronously): inside the listener, an idle / busy pair brackets await setTimeout(intervalMs) exactly where it needs to be.
  • Holding and updating intervalMs. The “withPause is read at click time” semantics from article (2) didn’t go anywhere — the interval input in the UI is a consumer-level concern. The worker keeps the current value in its own local variable and updates it on every resume message from the main thread.
  • Translating UI clicks to session methods: Pause click → activeSession.pause(), Step click → activeSession.stepIn(), Continue click → activeSession.continue(). The worker no longer synthesizes paused from onIter — all pauses (breakpoint, step, manual) come back through the single pause event on the session, with a cause discriminant.

The line is clean. What stayed is what the session can’t own without stepping onto the consumer’s territory. What left was never worker-specific to begin with — it was coordination, disguised as worker-specifics. A different consumer — an in-process debugger built into an IDE, a DAP server, an educational demo without a Worker — will compose DebugSession differently (no postMessage, no watchdog), but consume the same session API.

The lift generalizes

PostMachine.debugRun() returns a PostDebugSession, which wraps the engine’s DebugSession. Same four events, same step methods, same lifecycle. On top: wrapping MachineState in post-specific arrivalPath / candidatePaths, and filtering by the PostMachine breakpoint registry before forwarding pause events outward. The demo’s worker handles both sessions through a structural SessionLike type — both look the same from the outside.

This is companion-of-companion: the pattern generalizes one library level up. The engine library lifts coordination once into its own companion class; the next library on top of it lifts its own specifics into its own companion class wrapping the engine’s. Evidence that “companion class” isn’t a one-off trick under one library — it’s a reproducible shape.

What follows from this

In the lineage of articles about the engine’s API, three lessons appear in a row:

  • From article (1): naming the lifecycle pins decisions about the shape of the API — name the states correctly, and the surface lands in the right shape on its own.
  • From article (2): observation and coordination are different jobs, and each one needs its own hook contract with its own dispatch semantics.
  • From this one: when N consumers reinvent the same coordination scaffolding on top of your hooks, your hook contract isn’t incomplete — it’s at the wrong granularity. A coordination state machine is a neighbor to hooks, not an extension of them.

The diagnostic for a library author: if you find yourself widening a hook’s signature so the consumer can do work the consumer already has to do (v6.2.0’s major from article (2)) — or if the same handler shape repeats from consumer to consumer without meaningful differences (the case that triggered this refactor) — then the hook is being asked to do something a hook fundamentally can’t.

For this engine, that landed as a clean boundary: a public sync runStepByStep, its coordination companion DebugSession, and any consumer — the demo’s worker, a DAP server, an in-process debug panel — writes an adapter between its own entry point and the session API, not a reinvented coordination layer.


Code: turing-machine-js (engine, v7 — DebugSession), post-machine-js (PostDebugSession), and machines-demo (the demo worker, rewritten around the session in PR #80).