DEV Community

Ruslan Gilmullin
Ruslan Gilmullin

Posted on • Originally published at mellonis.ru

A pause with two sides: the hook contract and the worker protocol

Suppose you’re writing a Turing-machine interpreter that runs in a Web Worker. The UI needs to show a trace — how the machine steps from state to state, what gets written to the tape, how the head moves. For the user to keep up with what’s on screen, the engine needs a short delay between iterations — milliseconds, regularly, on every step. That’s a throttle of the engine between iterations — regular and predictable, not the “Pause” of the UI button (which stops the machine until the user clicks “Continue”).

So where exactly in the iteration cycle does the worker apply that throttle? There are two candidates, and the choice between them pins two contracts at once: the engine’s hooks and the protocol between worker and main thread. Pick the right point, and you design both sides at once; pick the wrong one, and you break both. This article is about that choice.

Screenshot of demo.machines.mellonis.ru: a Turing machine in RUNNING_AUTO mode with the Pause button highlighted by a zoom-circle, the step-by-step execution trace visible on the left

Setup

turing-machine-js is a Turing-machine interpreter. The engine has an onStep hook — the handler runs after each machine step. That’s all the engine background you need for this article. For the rest of the engine’s design and previous API changes, see “Three majors, two mistakes: designing a pause API for a Turing-machine interpreter” (1) and “Two majors, one README, one demo: two cheap design reviews” (2).

The first real consumer of the engine is machines-demo, an interactive debugger. The user’s machine runs in a Web Worker; the UI on the main thread shows the trace. The same demo that, in article (2), surfaced two engine majors (#109, #108). This article is the next episode in the life of the same engine: v6.2 → v6.3 → v6.4.

The demo has a RUNNING_AUTO mode: the user picks an interval, clicks “Start”, and the machine steps automatically. Originally the mode was straightforward — the main thread drove step-by-step execution (via runner.step(), a wrapper over the engine’s runStepByStep() in the worker), with setTimeout(intervalMs) inserted between steps for the trace. Simple, and it worked — until the user set a breakpoint.

Breakpoints in the engine fire inside machine.run(): the iteration loop lives in the engine itself, and on hitting a debug-marked state, it pauses itself. runStepByStep(), on the other hand, is a generator: the consumer drives the loop, and breakpoints are invisible to it. machines-demo#43 was this hole — the user sets a breakpoint in RUNNING_AUTO mode, clicks “Start”, and the breakpoint is ignored. For breakpoints to fire, RUNNING_AUTO had to switch from runStepByStep() to run().

But run() keeps its iteration loop inside the engine and doesn’t yield control back. Previously the throttle lived between runner.step() calls on the main thread — that is, on the consumer side, where the consumer controlled the interval between steps. With the switch to run(), that consumer-side gap is gone — the throttle has to either be moved inside the engine (into an existing or new hook) or be dropped. That became the design question.

One more architectural detail we need: the main thread holds a watchdog timer over the worker. If the worker stays silent on messages longer than WORKER_TIMEOUT_MS, the main thread considers it hung (e.g., the user’s machine never reaches halt) and kills it. Whatever place we put the throttle in has to live with that timer.

Where the throttle can live

Two candidates:

  1. Inside onStep — the synchronous hook the engine calls after each step. Its current signature is (state) => void. You could widen it to (state) => void | Promise<void>, and then the handler can await the delay right there.
  2. At the iteration boundary — add a new hook, designed for async waits. Naming aside, what matters is that it fires once at the end of each engine iteration, and the engine awaits the returned Promise.

Both candidates answer “where does the throttle live”. But at the same time they dictate what the worker-main protocol looks like.

If the throttle sits in onStep, it ends up in the middle of each engine iteration — at the point where onStep is called. Everything else in the protocol has to be pinned to that same point: the watchdog timer and the user’s click on “Pause” both hang off this mid-iter delay.

If the throttle sits at the iteration boundary, the worker has a moment at the end of every iteration — the engine isn’t running, it’s awaiting a Promise. In that moment, it’s easy to make peace with the watchdog timer and to be ready for a click on “Pause”.

Now both contracts, one at a time.

Contract “Inside onStep

v6.2.0 took the first path — it widened onStep’s signature from (state) => void to (state) => void | Promise<void>. The handler could now await the delay right where it used to only look at state. From the worker’s side it looked clean — one hook, one wait point, no new entities.

Within a few hours, I marked v6.2.0 as deprecated on npm. Not “a newer version is out, upgrade at your convenience”, but an explicit warning: this version is wrong.

v6.3.0 reverted the signature widening. The reason was straightforward: onStep was meant as an observation hook, not a coordination one. It runs synchronously, in the middle of each iteration, so the consumer can look at state and record it somewhere. Adding await to its signature glues two different jobs into one: observe and hold the engine. A reader of the signature can no longer tell: is this hook “look at this state” or “hold the engine here”? The hook’s purpose stops being visible from the signature.

The symptom that something was wrong showed up in the docs. The README had to explain how to use the widened onStep. An honest paragraph opened with a hedge: “onStep is an observation hook, but you can return a Promise from it, and the engine will await it before the next iteration.” Neither the “but” nor the second half could be cut. The docs were doing exactly what’s described in article (2): prose was pushing on shape — and the shape cracked.

v6.4.0 added a separate hook — onIter. Signature: (state) => void | Promise<void>. Contract: fires once at the end of every iteration, asynchronously, unconditionally. The name — iteration boundary — describes what the hook does (gives a point at the end of an iteration where you can wait), not why a consumer would use it. And it can be used in different ways: for throttling between trace steps, for syncing with the UI, for aborting a run (e.g., via AbortSignal).

The engine’s hook contract now distinguishes three jobs:

— onStep — synchronous, mid-iteration. Observation of state.
— onPause — asynchronous, conditional (fires only on breakpoints). The boundary between running and stopped.
— onIter — asynchronous, unconditional (fires on every iteration). Coordination: holding the engine for the consumer’s async work.

Three hooks, three jobs. Observation shouldn’t hold the engine; for that, there’s a separate asynchronous hook. Holding shouldn’t hide inside an observation hook; for that, there’s onIter. A pause from a breakpoint is its own scenario altogether; for that, there’s onPause, whose firing event is spelled out in its name.

Contract “At the iteration boundary”

Where the throttle lives is now clear: inside the onIter handler. But the onIter handler runs inside the worker, while the user’s pause comes from outside — from the main thread, on a click. They need a protocol.

This protocol has four mechanisms: a pair of idle/busy messages, a stepRequested flag, a synthetic paused event, and an intervalMs field in resume. Each one closes a specific problem that the throttle’s location creates.

The idle and busy messages: pausing the watchdog

The watchdog timer mentioned earlier starts to get in the way once we’re in onIter.

A throttle in onIter, on every iteration, can easily exceed the timeout — especially if the user dials intervalMs to “slow” (the demo lets you set the interval to, say, two seconds). The result is a false alarm: the worker isn’t hung, it’s just waiting.

The solution: the worker itself tells the main thread when it’s “working” and when it’s “waiting”. Before await-ing the throttle in onIter, the worker sends an idle message. After the throttle resolves, it sends a busy. The main thread suspends the watchdog for the gap between idle and busy. The watchdog still catches real hangs (e.g., an unreachable halt state) but no longer confuses them with legitimate trace slowdown.

Without this wrapping, the watchdog has two bad options. Either pick a generous threshold — then real hangs go undetected (or detected too late). Or pick a tight threshold — and you raise the probability of a false alarm (the worker gets killed on a legitimate delay). The idle/busy messages resolve both at once.

Step is a flag inside the worker, not engine state

When the user clicks “Step”, the main thread sends the worker a resume message with a step: true flag — “advance the machine one iteration and pause again”. The implementation: the worker sets stepRequested = true. On the next onIter, the handler sees the flag, resets it to false, synthesizes a paused event, and waits again. The machine has run exactly one iteration.

The key point: “step” is a flag inside the worker, not part of the engine’s state. The engine doesn’t know what “step” means — it simply finds itself in a waiting state again on the next iteration. The coordination lives outside the engine. onIter remains a thin await hook — it doesn’t mutate the engine, it only pauses the engine’s execution. That’s what made onIter viable as a contract: there’s no “step-specific logic” in it, only a Promise the worker controls itself. If “step” had been part of the engine’s state, the onIter contract would have to extend to “and also flip this flag”, and the hook would stop being thin.

No throttle needed when Step is clicked

When the user clicks “Step”, a throttle is redundant — the user plays the role of the timer, clicking as many times and as fast as they need. The implementation reflects this: on Step, intervalMs is ignored, and the onIter handler doesn’t insert a wait. This is the cleanest expression of “observation ≠ coordination” inside the worker: when the user is in control, the engine follows.

Synthetic pause on click

The scenario: the machine is in RUNNING_AUTO — the engine is running with onIter awaiting intervalMs between iterations. The user clicks the “Pause” button in the UI. The click arrives on the main thread. The main thread sends the worker a pause message. At that moment, the worker is holding the engine in a throttle — the Promise hasn’t resolved yet. What do we do?

The solution is to synthesize the same kind of pause as one from a breakpoint. The UI already has a handler in place: on paused from the worker, it switches to “stopped”. If the “Pause” click went down a different path, a second branch with the same behavior would appear. That’s why the worker synthesizes paused — to the UI, a “Pause” click is indistinguishable from a breakpoint firing.

The worker cancels the throttle (resolves it forcibly, ahead of schedule), sets stepRequested = false, and, from inside onIter after returning from the wait, dispatches a paused event — synthetic, not one that arose from the engine’s normal flow. Under the hood, the two scenarios differ (one cancels the throttle, the other lets it finish naturally), but the contract from outside is one — a single “pause” scenario.

intervalMs is a parameter of resume, not run()

The throttle in onIter is sized by intervalMs. Where does the worker get that value from? Not from the parameters passed to run() at start. From the latest resume message: each one arrives carrying a current intervalMs from the main thread.

The reason is that the user must be able to change the delay between throttles during the run. The value in the UI is read at click time, not at run() start; the next resume carries the new value, and starting with the next iteration, the throttle adjusts. A small cost for the protocol, a big win for the UX.

Duality, explicit

The throttle lives in one point of an iteration. From the engine’s perspective, that point is the onIter handler: a hook called once at the end of each iteration, asynchronously. From the protocol’s perspective, the same point is the gap between idle and busy messages — a gap where the watchdog is suspended, a “Pause” click can softly cancel the throttle, and intervalMs is read from the latest resume.

One point, two APIs. Every downstream design choice depends on this one. Put it in the middle of an observation hook, and you break both sides at once: the docs on a widened onStep can’t be written without a “but”, the idle/busy wrapping has nothing to attach to (the same hook stands for both observation and waiting), and the synthetic paused event has to be dispatched from an unnatural place. Without a clean iteration boundary, both the hook and the protocol suffer.

Conclusion

Separating observation from coordination is the reason the engine’s hook contract distinguishes three jobs instead of merging them into one. Gluing them together is the temptation v6.2.0 succumbed to. And the only thing that let me realize the approach was wrong within a few hours was that the engine’s README couldn’t be written honestly, and the demo’s smoke test wouldn’t pass. Two almost-free design reviews (article (2)).


Code: turing-machine-js (engine, v6.2 → v6.4) and machines-demo (the demo).

Top comments (0)