Skip to content

Close the post-await re-entrancy detection gap with AsyncLocalStorage #59

Description

@camcima

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

  • A re-entrant call made after an await inside a callback throws ReentrancyError rather than deadlocking, when the strategy is enabled.
  • Default build pulls in no Node built-ins and keeps the zero-dependency guarantee.
  • Concurrent external callers are still never flagged (the existing test for this must keep passing).
  • docs/errors.md gap note updated to describe when the gap is and isn't closed.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions