Skip to content

core: drop the dead version counter + hardening/perf pass over notify, teardown, and computed - #65

Merged
ivanbanov merged 16 commits into
mainfrom
refactor/drop-dead-version-counter
Aug 20, 2026
Merged

core: drop the dead version counter + hardening/perf pass over notify, teardown, and computed#65
ivanbanov merged 16 commits into
mainfrom
refactor/drop-dead-version-counter

Conversation

@ivanbanov

@ivanbanov ivanbanov commented Aug 19, 2026

Copy link
Copy Markdown
Member

What

Started as removing MachineClass.version (write-only, bumped every notify, read by nothing). The code-review pass that confirmed it was dead surfaced a set of real defects and hot-path waste next to it — this PR lands the whole sweep over machine.ts, compose.ts, connector.ts, store.ts, and computed.ts.

Fixes

  • Unsubscribing is final, even mid-notify. A nested notify (a send from inside a subscriber) rebuilt the iteration snapshot and resurrected listeners removed earlier in the pass. The machine, connector, and store all had a variant of this; the hardened loop now lives in one primitive (broadcast.ts) they all share:

    for (const l of snap) {
      if ((!dirty && snap === snapshot) || listeners.has(l)) l()
    }
  • A throwing effect cleanup no longer leaks the rest. Every cleanup runs, the list clears, the first error rethrows after the pass — the next stop can't double-run them.

  • Manual sync()/combine().subscribe() disposers detach from the group registry. Long-lived compositions with subscribe/unsubscribe churn no longer grow it without bound; stop() no longer re-runs hand-run disposers.

Perf

  • Computed recompute allocates nothing (was 7 objects/hit): dep keys/values sit in reused parallel buffers, captured at read time inside the tracking proxies — the post-pass that re-read every dep is gone.
  • machine.select is built once (stable identity, safe for dependency arrays); watcher field sources bind once at start; store.set/setContext share one shouldPatch probe.

Benchmark on the same machine — main measured once in a worktree, branch is the average of 4 clean runs:

Scenario main branch (4-run avg)
Single-event throughput 8.98 M 11.6 M
Irrelevant write, 5000 cells 1.44 M 4.4 M
Computed recompute 3.26 M 4.9 M
4-deep computed chain 869 K 1.5 M
Memory, 64-field (KB/machine) 4.10 4.35

The memory row is the one debit (+0.25 KB/machine from the per-instance broadcast closures). The documented tables (benchmark README, core README, website, root README) were re-baselined in this PR on the same 4-run average.

Naming

bump()notify() (nothing left to bump), installComputeddefineComputed (it defines getters).

Checks

  • Core: 175/175 tests (13 new — re-entrancy, cleanup-throw, broadcast contract, compose/store dispose, select identity). Monorepo: 2307/2307.
  • Typecheck, lint, format — clean.
  • Changeset: one patch for @dunky.dev/state-machine covering the consumer-visible fixes.

🤖 Generated with Claude Code

The version field was bumped on every notify but never read anywhere —
its comment claimed computed memoizes through it, while computed.ts
actually tracks per-field deps via proxies and value snapshots. Leftover
from the pre-proxy memoization design.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
dunky-state-machine Ready Ready Preview Aug 20, 2026 3:32pm

A nested bump() rebuilds busSnapshot and clears busDirty, so the outer
pass lost its mid-pass-churn signal and could call a listener that was
unsubscribed during the pass. Detect the swap by snapshot identity too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
stopEffects() bailed on the first throwing cleanup: the remaining
cleanups leaked (timers, subscriptions) and the list stayed populated,
so the next stop re-ran the whole pass. Run every cleanup, clear the
list, rethrow the first error after the pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
get select() rebuilt the facade (function + 3 method closures) on every
access. Build once, reuse — the facade is stateless, and the stable
identity is now guaranteed (safe for dependency arrays).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
readField() probed `key in ctx` on every watcher notify to pick
ctx-vs-computed, but the answer is static. Bind the source object per
key in startWatchers instead. Membership is probed against computed —
its keys are frozen at construction — because an optional context field
can be patched in after start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
startWatchers re-implemented the Selection dedupe (prev seed,
Object.is compare, update) inline. A watcher is now a
makeSelection(...).subscribe(...) over its bound source — one home for
the dedupe semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There is no version counter to bump since 9dfa91a — the method only
notifies the bus. The old name also collided with the "bump" event type
used across tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sync()/combine().subscribe() disposers stayed registered after a manual
unsubscribe: long-lived groups with subscribe/unsubscribe churn grew
the array without bound, and stop() re-ran every stale disposer. The
registry is now a Set and each disposer deletes itself; the duplicated
dispose blocks collapsed into one register() helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
combine() re-implemented makeSelection's prev/equals/notify loop — the
third copy after the machine's own and the watchers'. Extract
makeSelection(selector, attach) to selection.ts; the machine attaches
to its bus, compose attaches via the group registry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cating

connector's wake() copied [...listeners] on every notify AND had the
mid-pass resurrect bug the machine's bus already solved (a listener
unsubscribed during a pass still fired from the stale copy). Extract
that bus as makeBroadcast() in broadcast.ts — allocation-free
steady-state notify, membership-checked mode under mid-pass churn —
and use it in both the machine and the connector. Direct contract
tests added for the primitive, incl. mid-pass add deferral and clear().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
store.set() duplicated setContext's Object.is changed-loop and copied
[...listeners] on every write. Extract shouldPatch() (patch.ts) for
both, move the listeners onto makeBroadcast() — no copy per notify,
and mid-pass unsubscribes are final here too. The fresh state identity
per set() stays on purpose: get() serves as a useSyncExternalStore
snapshot, so the identity change is the re-render signal (now pinned
by a test).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ivanbanov and others added 2 commits August 20, 2026 15:14
A recompute allocated 7 objects (2 tracking Sets, the def params, 2
dep-array spreads, 2 snapshot objects) and then re-read every dep it
had just computed to snapshot it. Dep keys/values now live in parallel
buffers reused across recomputes and are captured at read time inside
the tracking proxies — a recompute allocates nothing but the def's own
return value. Also renames installComputed -> defineComputed (it
defines getters, nothing gets installed).

Benchmark (same machine, before -> after): recompute 3.4M -> 5.0M
ops/s, 4-deep chain 929K -> 1.5M, cached read 35M -> 43M.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ivanbanov ivanbanov changed the title refactor(machine): drop the write-only version counter core: drop the dead version counter + hardening/perf pass over notify, teardown, and computed Aug 20, 2026
New numbers from one clean run on this branch (M-series, Node 24) —
the old tables predated the hardening/perf pass and came from different
hardware. Ratio claims rechecked: throughput ~8x -> ~7x, propagation at
5000 ~10x -> ~11x, memory vs Zag ~33x -> ~31x, construction gap ~1.2x
-> ~1.1x. The memory rows now show the small broadcast-closure debit
honestly (3.9/4.4 KB vs XState's 3.6/4.1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The previous refresh came from a single run taken under machine load —
the irrelevant-write@5000 row understated Dunky by ~25% and sync@50 by
~12%. All tables now carry the average of four clean runs (the header
says so), ratio claims rechecked. Also fixes the benchmark skill: adds
the root README's table as step D, corrects the website page path, and
drops a nonexistent core-README memory claim from the checklist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ivanbanov
ivanbanov merged commit b70bedc into main Aug 20, 2026
8 checks passed
@ivanbanov
ivanbanov deleted the refactor/drop-dead-version-counter branch August 20, 2026 15:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant