diff --git a/fsm.go b/fsm.go index f3af5f2..d837955 100644 --- a/fsm.go +++ b/fsm.go @@ -350,6 +350,16 @@ func (f *FSM) Event(ctx context.Context, event string, args ...interface{}) erro if e.Err == nil { e.Err = ctx.Err() } + // Clear the transition on the way out, exactly as the completed + // path below does. Leaving it set makes every later Event return + // InTransitionError, and there is no recovery short of a new FSM: + // Transition() re-runs this same closure and takes this same + // early return. A state whose leave callback cancels is spared, + // because leaveStateCallbacks returning CanceledError clears it; + // a state with no callbacks is not. + f.stateMu.Lock() + f.transition = nil + f.stateMu.Unlock() return } diff --git a/fsm_test.go b/fsm_test.go index 6a8dadf..a3ead78 100644 --- a/fsm_test.go +++ b/fsm_test.go @@ -431,6 +431,43 @@ func TestCancelWithError(t *testing.T) { } } +func TestCanceledContextDoesNotLeaveTheFSMInTransition(t *testing.T) { + fsm := NewFSM( + "start", + Events{ + {Name: "run", Src: []string{"start"}, Dst: "end"}, + }, + Callbacks{}, + ) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := fsm.Event(ctx, "run") + if !errors.Is(err, context.Canceled) { + t.Errorf("expected 'context canceled' error, got %v", err) + } + if fsm.Current() != "start" { + t.Errorf("expected state to be 'start', was '%s'", fsm.Current()) + } + + // A canceled event must not leave the FSM in transition. transitionFunc returns + // early when the context is done, and unless it clears f.transition on the way + // out, every later event fails with InTransitionError -- permanently, because + // Transition() re-runs the same closure and takes the same early return, so only + // a newly constructed FSM recovers. + // + // A state whose leave callback cancels is spared, since leaveStateCallbacks + // returning CanceledError does clear the flag. A state with no callbacks, as + // here, is not. + if err := fsm.Event(context.Background(), "run"); err != nil { + t.Errorf("expected the FSM to still accept events, got %v", err) + } + if fsm.Current() != "end" { + t.Errorf("expected state to be 'end', was '%s'", fsm.Current()) + } +} + func TestAsyncTransitionGenericState(t *testing.T) { fsm := NewFSM( "start",