Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions fsm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
37 changes: 37 additions & 0 deletions fsm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading