diff --git a/README.md b/README.md index e28ec732..5762f513 100644 --- a/README.md +++ b/README.md @@ -212,16 +212,70 @@ Stateless supports 2 types of state machine events: #### State transition ```csharp +// Synchronously stateMachine.OnTransitioned((transition) => { }); + +// Asynchronously +stateMachine.OnTransitionedAsync((transition) => { return Task.FromResult(0); }); ``` This event will be invoked every time the state machine changes state. #### State machine transition completed ```csharp +// Synchronously stateMachine.OnTransitionCompleted((transition) => { }); + +// Asynchronously +stateMachine.OnTransitionCompletedAsync((transition) => { return Task.FromResult(0); }); ``` This event will be invoked at the very end of the trigger handling, after the last entry action has been executed. +--- + +In addition to this, Stateless also provides you with the ability to unregister from state machine events in 3 ways. + * State transition unregister (sync/async) + * State machine transition completed (sync/async) + * State machine unregister from all (sync and async) + + +#### State machine transition unregister (synchronous) +```csharp +// Keep a reference to the synchronous callback action we want to unregister later. +Action transitionCallbackAction = (transition) => { }; +stateMachine.OnTransitionedUnregister(transitionCallbackAction); +``` +This method will unregister the specified action callback from the transition event. + +#### State machine transition unregister (asynchronous) +```csharp +// Keep a reference to the asynchronous callback function we want to unregister later. +Func transitionAsyncCallback => (transition) => { return Task.FromResult(0); }; +stateMachine.OnTransitionedAsyncUnregister(transitionAsyncCallback); +```` +This method will unregister the specified async function callback from the transition event. + +#### State machine transition completed unregister (synchronous) +```csharp +// Keep a reference to the synchronous callback action we want to unregister later. +Action transitionCompletedCallbackAction = (transition) => { }); +stateMachine.OnTransitionCompletedUnregister(transitionCompletedCallbackAction); +``` +This method will unregister the specified action callback from the transition completed event. + +#### State machine transition completed unregister (asynchronous) +```csharp +// Keep a reference to to the asynchronous callback function we want to unregister later. +Func transitionCompletedAsyncCallback => (transition) => { return Task.FromResult(0); }); +stateMachine.OnTransitionCompletedAsyncUnregister(transitionCompletedAsyncCallback); +``` +This method will unregister the specified async function callback from the transition completed event. + +#### Unregister all registered callbacks (sync and async) +```csharp +stateMachine.UnregisterAllCallbacks(); +``` +This method will unregister all synchronous and asynchronously registered callbacks from the state machine. + ### Export to DOT graph It can be useful to visualize state machines on runtime. With this approach the code is the authoritative source and state diagrams are by-products which are always up to date. diff --git a/example/OnOffExample/Program.cs b/example/OnOffExample/Program.cs index c887fff0..c40a5c2b 100644 --- a/example/OnOffExample/Program.cs +++ b/example/OnOffExample/Program.cs @@ -1,5 +1,6 @@ using System; using Stateless; +using Stateless.Graph; namespace OnOffExample { @@ -9,6 +10,11 @@ namespace OnOffExample /// class Program { + static void TransitionAnnounce(StateMachine.Transition transition) + { + Console.WriteLine($"State Machine Transitioning from '{transition.Source}' to '{transition.Destination}'"); + } + static void Main(string[] args) { const string on = "On"; @@ -28,9 +34,31 @@ static void Main(string[] args) { Console.WriteLine("Switch is in state: " + onOffSwitch.State); var pressed = Console.ReadKey(true).KeyChar; - + // Check if user wants to exit - if (pressed != space) break; + if (pressed != space) + { + // Before exiting this is how you can safely register and unregister from transition events. + // Keys 'r' or 'R' = Register for transition events with double subscription prevention built-in. + // Keys 'u' or 'U' = Unregister from transition events to prevent memory leaks in long running applications. + switch (pressed) + { + case 'r': + case 'R': + onOffSwitch.OnTransitioned(TransitionAnnounce); + Console.WriteLine("Now subscribed to transition events.."); + continue; + + case 'u': + case 'U': + onOffSwitch.OnTransitionedUnregister(TransitionAnnounce); + Console.WriteLine("Successfully unsubscribed from transition events.."); + continue; + } + + Console.WriteLine("Exiting program"); + break; + } // Use the Fire method with the trigger as payload to supply the state machine with an event. // The state machine will react according to its configuration. diff --git a/src/Stateless/OnTransitionedEvent.cs b/src/Stateless/OnTransitionedEvent.cs index 4ede1525..95ca46e9 100644 --- a/src/Stateless/OnTransitionedEvent.cs +++ b/src/Stateless/OnTransitionedEvent.cs @@ -6,11 +6,11 @@ namespace Stateless { public partial class StateMachine { - class OnTransitionedEvent + internal class OnTransitionedEvent { event Action _onTransitioned; readonly List> _onTransitionedAsync = new List>(); - + public void Invoke(Transition transition) { if (_onTransitionedAsync.Count != 0) @@ -33,13 +33,41 @@ public async Task InvokeAsync(Transition transition, bool retainSynchronizationC public void Register(Action action) { + _onTransitioned -= action; _onTransitioned += action; } public void Register(Func action) { + _onTransitionedAsync.Remove(action); _onTransitionedAsync.Add(action); } + + public void Unregister(Action action) + { + if (_onTransitioned != null) + { + _onTransitioned -= action; + } + } + + public void Unregister(Func action) + { + _onTransitionedAsync.Remove(action); + } + + public void UnregisterAll() + { + if (_onTransitioned != null) + { + foreach (Delegate eventHandler in _onTransitioned.GetInvocationList()) + { + _onTransitioned -= (Action)eventHandler; + } + } + + _onTransitionedAsync.Clear(); + } } } } diff --git a/src/Stateless/StateMachine.Async.cs b/src/Stateless/StateMachine.Async.cs index b7944822..3081a5dc 100644 --- a/src/Stateless/StateMachine.Async.cs +++ b/src/Stateless/StateMachine.Async.cs @@ -449,6 +449,30 @@ public void OnTransitionCompletedAsync(Func onTransitionAction if (onTransitionAction == null) throw new ArgumentNullException(nameof(onTransitionAction)); _onTransitionCompletedEvent.Register(onTransitionAction); } + + /// + /// Unregisters a previously registered callback to prevent further events from + /// being raised when the state machine transitions from one state into another. + /// + /// + /// + public void OnTransitionedAsyncUnregister(Func onTransitionAction) + { + if (onTransitionAction == null) throw new ArgumentNullException(nameof(onTransitionAction)); + _onTransitionedEvent.Unregister(onTransitionAction); + } + + /// + /// Unregisters a previously registered callback to prevent further events from + /// being raised when the state machine has completed its state transition. + /// + /// + /// + public void OnTransitionCompletedAsyncUnregister(Func onTransitionAction) + { + if (onTransitionAction == null) throw new ArgumentNullException(nameof(onTransitionAction)); + _onTransitionCompletedEvent.Unregister(onTransitionAction); + } } } diff --git a/src/Stateless/StateMachine.cs b/src/Stateless/StateMachine.cs index 3a0f9b3e..999297e2 100644 --- a/src/Stateless/StateMachine.cs +++ b/src/Stateless/StateMachine.cs @@ -821,5 +821,39 @@ public void OnTransitionCompleted(Action onTransitionAction) if (onTransitionAction == null) throw new ArgumentNullException(nameof(onTransitionAction)); _onTransitionCompletedEvent.Register(onTransitionAction); } + + /// + /// Unregisters a previously registered callback to prevent further events from + /// being raised when the state machine transitions from one state into another. + /// + /// + /// + public void OnTransitionedUnregister(Action onTransitionAction) + { + if (onTransitionAction == null) throw new ArgumentNullException(nameof(onTransitionAction)); + _onTransitionedEvent.Unregister(onTransitionAction); + } + + /// + /// Unregisters a previously registered callback to prevent further events from + /// being raised when the state machine has completed its state transition. + /// + /// + /// + public void OnTransitionCompletedUnregister(Action onTransitionAction) + { + if (onTransitionAction == null) throw new ArgumentNullException(nameof(onTransitionAction)); + _onTransitionCompletedEvent.Unregister(onTransitionAction); + } + + /// + /// Unregisters all callbacks currently registered with the state machine for + /// both "transitioned" and "transition completed" events. + /// + public void UnregisterAllCallbacks() + { + _onTransitionedEvent.UnregisterAll(); + _onTransitionCompletedEvent.UnregisterAll(); + } } -} +} \ No newline at end of file diff --git a/test/Stateless.Tests/OnTransitionedEventTests.cs b/test/Stateless.Tests/OnTransitionedEventTests.cs new file mode 100644 index 00000000..63c5a58f --- /dev/null +++ b/test/Stateless.Tests/OnTransitionedEventTests.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Xunit; +using static Stateless.StateMachine; + +namespace Stateless.Tests +{ + public class OnTransitionedEventTests : IDisposable + { + private readonly OnTransitionedEvent subject; + + private event Action onTransitioned; + private List> onTransitionedAsync; + + private Action registeredAction; + private Func registeredAsyncAction; + + private int transitionCounter = 0; + + public OnTransitionedEventTests() + { + subject = new OnTransitionedEvent(); + + registeredAction = (Transition _) => + { + Interlocked.Increment(ref transitionCounter); + }; + + registeredAsyncAction = (Transition _) => + { + Interlocked.Increment(ref transitionCounter); + return Task.FromResult(0); + }; + } + + public void Dispose() + { + subject.UnregisterAll(); + } + + private void InstantiateTransitionReferences() + { + FieldInfo[] subjectFields = subject.GetType().GetFields(BindingFlags.Instance | BindingFlags.NonPublic); + + onTransitioned = subjectFields[0].GetValue(subject) as Action; + onTransitionedAsync = subjectFields[1].GetValue(subject) as List>; + } + + [Fact] + public void Register_ShouldOnlyRegisterActionOnce_WhenCalled() + { + subject.Register(registeredAction); + subject.Register(registeredAction); + + InstantiateTransitionReferences(); + + Assert.Single(onTransitioned.GetInvocationList() ?? Array.Empty()); + } + + [Fact] + public void Register_ShouldRegisterDistinctActions_WhenCalled() + { + subject.Register(registeredAction); + subject.Register((Transition _) => { }); + + InstantiateTransitionReferences(); + + Assert.Equal(2, onTransitioned.GetInvocationList().Length); + } + + [Fact] + public void Register_ShouldOnlyRegisterFuncOnce_WhenCalled() + { + subject.Register(registeredAsyncAction); + subject.Register(registeredAsyncAction); + + InstantiateTransitionReferences(); + + Assert.Single(onTransitionedAsync); + } + + [Fact] + public void Register_ShouldRegisterDistinctFuncActions_WhenCalled() + { + subject.Register(registeredAsyncAction); + subject.Register((Transition _) => Task.FromResult(0)); + + InstantiateTransitionReferences(); + + Assert.Equal(2, onTransitionedAsync.Count); + } + + [Fact] + public void Unregister_ShouldNotInvokeActionTwice_WhenCalled() + { + subject.Register(registeredAction); + subject.Invoke(new Transition(null, null, null)); + + Assert.Equal(1, transitionCounter); + + subject.Unregister(registeredAction); + subject.Invoke(new Transition(null, null, null)); + + Assert.Equal(1, transitionCounter); + } + + [Fact] + public async Task Unregister_ShouldNotInvokeAsyncActionTwice_WhenCalled() + { + subject.Register(registeredAsyncAction); + await subject.InvokeAsync(new Transition(null, null, null), true); + + Assert.Equal(1, transitionCounter); + + subject.Unregister(registeredAsyncAction); + await subject.InvokeAsync(new Transition(null, null, null), true); + + Assert.Equal(1, transitionCounter); + } + + [Fact] + public void UnregisterAll_ShouldRemoveSyncAndAsyncActions_WhenCalled() + { + subject.Register(registeredAction); + subject.Register(registeredAsyncAction); + + InstantiateTransitionReferences(); + + Assert.Equal(1, onTransitioned.GetInvocationList().Length); + Assert.Single(onTransitionedAsync); + + subject.UnregisterAll(); + + InstantiateTransitionReferences(); + + Assert.Null(onTransitioned); + Assert.Empty(onTransitionedAsync); + } + + [Fact] + public void Invoke_ShouldCallRegisteredAction_WhenCalled() + { + subject.Register(registeredAction); + subject.Invoke(new Transition(null, null, null)); + subject.Invoke(new Transition(null, null, null)); + + Assert.Equal(2, transitionCounter); + } + + [Fact] + public void Invoke_ShouldThrowInvalidOperationException_WhenAsyncActionRegistered() + { + subject.Register(registeredAction); + subject.Register(registeredAsyncAction); + + Assert.Throws(() => subject.Invoke(new Transition(null, null, null))); + } + + [Fact] + public async Task InvokeAsync_ShouldCallSyncAction_WhenRegistered() + { + subject.Register(registeredAction); + subject.Register(registeredAsyncAction); + + await subject.InvokeAsync(new Transition(null, null, null), true); + + Assert.Equal(2, transitionCounter); + } + + [Fact] + public async Task InvokeAsync_ShouldNotCallSyncAction_WhenNotRegistered() + { + subject.Register(registeredAsyncAction); + + await subject.InvokeAsync(new Transition(null, null, null), true); + + Assert.Equal(1, transitionCounter); + } + } +}