Skip to content

fix: clear the transition when the context is canceled - #117

Merged
maxekman merged 1 commit into
looplab:mainfrom
dunv:fix/clear-transition-on-canceled-context
Aug 27, 2026
Merged

fix: clear the transition when the context is canceled#117
maxekman merged 1 commit into
looplab:mainfrom
dunv:fix/clear-transition-on-canceled-context

Conversation

@dunv

@dunv dunv commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Fixes #115.

transitionFunc returns early when ctx.Err() != nil, but only the completed path below it clears f.transition:

transitionFunc := func(ctx context.Context, async bool) func() {
    return func() {
        if ctx.Err() != nil {
            if e.Err == nil {
                e.Err = ctx.Err()
            }
            return                     // f.transition left set
        }

        f.stateMu.Lock()
        f.current = dst
        f.transition = nil             // only cleared here
        f.stateMu.Unlock()
        ...

Since f.transition is published before the context is ever checked, a canceled context leaves the FSM in transition — and permanently, because there is no way back:

  • every later Event returns InTransitionError,
  • Can() returns false (ok && (f.transition == nil)),
  • and Transition() cannot help either: it re-runs this same closure, which captured the same dead context and takes the same early return.

Only constructing a new FSM recovers.

Why it looks intermittent

Whether a canceled context bricks the FSM depends on which state is being left, which is not something a caller would think to check:

  • a state whose leave callback cancels is spared — leaveStateCallbacks returning CanceledError does clear the flag;
  • a state with no callbacks is not.

So the same cancellation is harmless in one state and terminal in another.

The fix

Clear f.transition on the canceled path, taking the same stateMu lock the completed path takes. e.Err is left untouched, so Event still returns the context error to the caller exactly as before — this only stops the FSM being unusable afterwards.

Test

TestCanceledContextDoesNotLeaveTheFSMInTransition — an FSM with no callbacks, one event with an already-canceled context, then a second event with a live one. Before the fix it fails with:

expected the FSM to still accept events, got event run inappropriate because
previous transition did not complete

After it passes, and the full suite passes under -race.

Where this came from

A cart-fleet sync service, one FSM per sync rule, with the context being the gRPC stream. A stream dying while an event was in flight left that rule's handler refusing every transition until it was rebuilt, so it silently stopped syncing and queued everything it received instead. It showed up on 34 devices in three days, and was hard to see because the resulting error is indistinguishable from ordinary "wrong state for this event" noise unless you look at the wrapped cause.

transitionFunc returns early when ctx.Err() != nil, but does not clear
f.transition on the way out — only the completed path below it does. Since
f.transition was already published before the context was ever checked, the FSM is
left in transition permanently: every later Event returns InTransitionError, Can()
returns false, and Transition() cannot help either, because it re-runs this same
closure and takes this same early return. Only constructing a new FSM recovers.

A state whose leave callback cancels is spared, because leaveStateCallbacks
returning CanceledError clears the flag. A state with no callbacks at all is not —
so whether a canceled context bricks the FSM depends on whether the state you
happen to be leaving has a callback registered.

Clears it with the same lock the completed path takes, and leaves e.Err untouched,
so Event still returns the context error to the caller as before.

Fixes looplab#115.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@maxekman

Copy link
Copy Markdown
Member

Thanks for the fix, will check it out.

@coveralls

Copy link
Copy Markdown

Coverage Status

coverage: 92.936% (+0.05%) from 92.889% — dunv:fix/clear-transition-on-canceled-context into looplab:main

@maxekman
maxekman merged commit b456069 into looplab:main Aug 27, 2026
1 check passed
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.

why not clear transition when ctx.Err != nil ?

3 participants