fix: address architecture review findings - #52
Conversation
Non-breaking remediation of the v4.1.0 architecture review. Each fix landed test-first; the suite grew from 369 to 388 tests. Bugs: - A mutex releaseLock() that reports failure by returning false — the signal LockAdapterInterface defines, and what the documented PostgreSQL advisory-lock adapter produces — was silently swallowed: the operation resolved, onReleaseError never fired, isLockAcquired() stayed true forever, and every later operation piggybacked on the stuck lock without ever releasing it. Both failure modes (throw and false) now flow through one path and raise the new LockCanNotBeReleasedError. - whenIdle() lacked the re-entrancy guard triggerEvent and checkTransitions have, so awaiting it inside an observer deadlocked the machine permanently and silently. It now throws ReentrancyError. API gaps: - Factory could not configure the v4 engine options, so factory-created machines silently ran on defaults with no back-pressure and no diagnostic sinks — precisely the fleet case those options exist for. Adds a FactoryStatemachineOptions template, excluding the three fields the factory derives per subject. - Statemachine.releaseLock() now reports failures through onReleaseError; the Promise<boolean> return type stays deferred to v5 because it is breaking. - Observer accessors return snapshots instead of the live arrays. - AmbiguousTransitionError carries the competing transitions, which is what identifying the culprits actually requires. - LockAdapterMutex shares one in-flight acquire, so overlapping calls cannot double-acquire a non-idempotent adapter. Also documents the passive nature of Timeout (it schedules nothing) and the cross-machine sharing of event observers, and re-dates the overdue "removed in v4" deprecation notices to v5. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JLxceiNFfXfpGoxdfQEZqw
Full-tree review of v4.1.0: two reproduced bugs, API-consistency gaps, and a deferred list for the next major. Records what this branch fixed and, for each deferred item, why — the breaking ones (Event mutability, the OnEnterObserver event-name convention, the machineName misnomer, the releaseLock return type) belong to a planned v5 scope, and the mermaid label quoting needs verification in a real renderer before changing output that tests and docs pin. Follows the precedent of CODE_REVIEW.md from the previous review cycle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JLxceiNFfXfpGoxdfQEZqw
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #52 +/- ##
=========================================
Coverage 100.00% 100.00%
=========================================
Files 61 62 +1
Lines 1801 1870 +69
Branches 268 277 +9
=========================================
+ Hits 1801 1870 +69 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Files the outstanding work as GitHub issues so the deferred rows point somewhere actionable: #53-#57 batched under the v5 milestone, #58-#63 non-breaking, indexed by #64. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JLxceiNFfXfpGoxdfQEZqw
|
Filed the deferred items so nothing here relies on the review document being re-read later: Breaking, batched under the v5 milestone: #53 (freeze Non-breaking: #58 (verify Mermaid quoting against a real renderer before touching pinned output), #59 ( #64 indexes all of them with a suggested order. The last commit here links the review's deferred rows to the issue numbers. Worth calling out: #59 is the one I'd take next. It is the same class of defect as the |
The candidates argument is optional so a custom TransitionSelectorInterface can still raise the error with only a count, as it could before #52 added candidates — but nothing exercised that path, leaving the no-detail message branch untested and dropping the repo below its 100% coverage bar. Also asserts the candidates array is frozen, which the constructor guarantees but no test checked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JLxceiNFfXfpGoxdfQEZqw
Non-breaking remediation of the v4.1.0 architecture review (added as
ARCHITECTURE_REVIEW.md). Every fix landed test-first — the failing test was observed before the change — and the suite grew from 369 to 388 tests across 48 files.Confirmed bugs
1. A failed lock release was silently swallowed (High).
MutexInterface.releaseLock()reports failure two ways: it throws, or it returnsfalse. The engine handled only the throw. Afalsereturn — what the documented PostgreSQL advisory-lock adapter produces on a failed unlock — meant the operation resolved successfully,onReleaseErrornever fired,isLockAcquired()stayedtrueforever, and because the engine skips acquisition when the mutex reports the lock is already held, every later operation piggybacked on the stuck lock and never released it. That is the exact scenario the inline comment inrunOperationsays must not happen.Both failure modes now flow through one
releaseMutex()path and raise the newLockCanNotBeReleasedError. Semantics match the existing throw path: the hook always fires, a successful operation rejects (the caller must learn the lock may still be held), and a failed operation still rejects with its own error rather than the release error.2.
whenIdle()deadlocked permanently and silently (Medium-High).triggerEvent/checkTransitionsgot a re-entrancy guard in v4;whenIdle(), added in the same release, did not. Calling it from inside a callback can never resolve — the machine cannot reach idle while the runner is blocked on that very callback. Reproduced: the transition commits, the caller's promise never settles, and every future operation queues forever. It now throwsReentrancyError, consistent with its siblings.API-consistency fixes
Factorycan now configure engine options. It previously forwarded onlyinitialStateName,transitionSelector, andmutex, so factory-created machines silently ran on defaults — no back-pressure, no diagnostic sinks — in exactly the fleet-of-machines case those options exist for. Adds aFactoryStatemachineOptionsconstructor template, typed asStatemachineOptionsminus the three fields the factory derives per subject, so a template value can never contradict the factory (enforced at compile time, pinned by a@ts-expect-errortest).Statemachine.releaseLock()now reports failures throughonReleaseError. The signature staysPromise<void>; changing it toPromise<boolean>is breaking and is deferred to v5.getBeforeObservers(),getAfterObservers(), andEvent.getObservers()handed out the live collections, so a later detach mutated a list a caller already held, and a caller could cast and clear the engine's own registrations.AmbiguousTransitionErrorcarries the competing transitions (candidates, also rendered into the message). A bare count doesn't identify the culprits, while its sibling errors are all context-rich.LockAdapterMutexshares one in-flight acquire. Theacquiredflag is only set after the adapter resolves, so two overlapping calls both passed the check and double-acquired a non-idempotent adapter. A failed acquire is still retryable.Docs
Release-failure semantics (
mutex.md,errors.md), the factory options template (factory.md),whenIdlere-entrancy and the frame-context caveat (core.md), plus two things easy to misread from the API alone:Timeoutschedules nothing — it only fires when something drives the machine (conditions.md) — and event observers are shared by every machine built from the process (observers.md).Deliberately deferred
Breaking changes are batched for a planned v5 rather than fixed piecemeal:
Eventmutability (the one back door in the frozen graph), theOnEnterObservermagic-event-name convention, themachineNamemisnomer (it is the process name), thereleaseLockreturn type, and removal of the deprecatedDispatcherinterfaces.Two non-breaking items were also left alone on purpose: the mermaid label quoting (
s_x : "label") is pinned by tests and docs and needs verification in a real renderer before changing rendered output on a hypothesis, andOperationQueue'sshift()is irrelevant at realistic queue depths. Both are recorded in the review's status table.Verification
pnpm test388/388 passing ·pnpm lintclean ·pnpm buildsucceeds with all new symbols in the emitted declarations.🤖 Generated with Claude Code
https://claude.ai/code/session_01JLxceiNFfXfpGoxdfQEZqw