From the improvement opportunities of the 2026-08 architecture review.
Problem
Statemachine.guardSync() sets a flag around the synchronous portion of every callback, so triggerEvent / checkTransitions / whenIdle called from inside an observer, condition, or selector throw ReentrancyError instead of deadlocking. (whenIdle was added to that set in #52.)
The flag is cleared as soon as the callback yields, which is deliberate — it must not leak into concurrent external callers — but it means a re-entrant call made after a prior await inside the callback is undetectable:
sm.attachAfter({
async notify() {
await somethingAsync(); // guard window closes here
await sm.triggerEvent("x"); // undetected -> permanent silent deadlock
},
});
This is documented in src/Statemachine.ts and docs/errors.md as a known gap, and it is the more likely shape in real code: an observer that does I/O and then wants to fire a follow-up event. The failure mode is the worst available one — the machine wedges permanently and silently, and every later operation queues forever behind it.
Proposed direction
package.json requires Node >= 20, where AsyncLocalStorage is stable. Running each operation inside an ALS context and checking membership would catch re-entrancy regardless of how many awaits have elapsed.
The constraint is that the core is currently runtime-agnostic (no Node built-ins, zero dependencies), and that is worth preserving for browser/edge bundles. So make it injectable rather than imported: a guard strategy on StatemachineOptions with a synchronous default and an opt-in ALS implementation exported from a subpath (e.g. @camcima/finita/node), or auto-detected behind a feature check that bundlers can drop.
Acceptance criteria
From the improvement opportunities of the 2026-08 architecture review.
Problem
Statemachine.guardSync()sets a flag around the synchronous portion of every callback, sotriggerEvent/checkTransitions/whenIdlecalled from inside an observer, condition, or selector throwReentrancyErrorinstead of deadlocking. (whenIdlewas added to that set in #52.)The flag is cleared as soon as the callback yields, which is deliberate — it must not leak into concurrent external callers — but it means a re-entrant call made after a prior
awaitinside the callback is undetectable:This is documented in
src/Statemachine.tsanddocs/errors.mdas a known gap, and it is the more likely shape in real code: an observer that does I/O and then wants to fire a follow-up event. The failure mode is the worst available one — the machine wedges permanently and silently, and every later operation queues forever behind it.Proposed direction
package.jsonrequires Node >= 20, whereAsyncLocalStorageis stable. Running each operation inside an ALS context and checking membership would catch re-entrancy regardless of how many awaits have elapsed.The constraint is that the core is currently runtime-agnostic (no Node built-ins, zero dependencies), and that is worth preserving for browser/edge bundles. So make it injectable rather than imported: a guard strategy on
StatemachineOptionswith a synchronous default and an opt-in ALS implementation exported from a subpath (e.g.@camcima/finita/node), or auto-detected behind a feature check that bundlers can drop.Acceptance criteria
awaitinside a callback throwsReentrancyErrorrather than deadlocking, when the strategy is enabled.docs/errors.mdgap note updated to describe when the gap is and isn't closed.