From 28b5856c304894db4044828f26d398d080a35a27 Mon Sep 17 00:00:00 2001 From: Damian Suess Date: Sat, 10 Jan 2026 15:53:42 -0500 Subject: [PATCH 1/3] Maintain single instance of Context object. New StateMachine.AddContext(..) method. Benchmark times from 705ns to 608ns --- .../BasicStateBenchmarks.cs | 8 +- .../StateTests/BasicStateTests.cs | 76 +++----- .../StateTests/CommandStateTests.cs | 5 +- .../StateTests/CompositeStateTest.cs | 3 +- .../StateTests/ContextTests.cs | 3 +- .../StateTests/CustomStateTests.cs | 20 +- .../StateTests/DiMsTests.cs | 2 +- .../TestData/States/BasicStates.cs | 5 +- source/Lite.StateMachine/Context.cs | 33 +++- source/Lite.StateMachine/IStateMachine.cs | 7 +- .../Lite.StateMachine.csproj | 2 +- source/Lite.StateMachine/StateMachine.cs | 182 ++++++++---------- source/Sample.Basics/States/DemoMachine.cs | 3 +- 13 files changed, 165 insertions(+), 184 deletions(-) diff --git a/source/Lite.StateMachine.BenchmarkTests/BasicStateBenchmarks.cs b/source/Lite.StateMachine.BenchmarkTests/BasicStateBenchmarks.cs index cec6db3..b0a56eb 100644 --- a/source/Lite.StateMachine.BenchmarkTests/BasicStateBenchmarks.cs +++ b/source/Lite.StateMachine.BenchmarkTests/BasicStateBenchmarks.cs @@ -33,26 +33,26 @@ public void BasicStateGlobalSetup() public async Task BasicStatesRunsAsync() { var maxCounter = CyclesBeforeExit; - PropertyBag parameters = new() + _machine.Context.Parameters = new() { { ParameterType.MaxCounter, maxCounter }, { ParameterType.Counter, 0 }, }; - await _machine.RunAsync(BasicStateId.State1, parameters); + await _machine.RunAsync(BasicStateId.State1); } [Benchmark] public void BasicStatesRunsSync() { var maxCounter = CyclesBeforeExit; - PropertyBag parameters = new() + _machine.Context.Parameters = new() { { ParameterType.MaxCounter, maxCounter }, { ParameterType.Counter, 0 }, }; - _machine.RunAsync(BasicStateId.State1, parameters) + _machine.RunAsync(BasicStateId.State1) .GetAwaiter() .GetResult(); } diff --git a/source/Lite.StateMachine.Tests/StateTests/BasicStateTests.cs b/source/Lite.StateMachine.Tests/StateTests/BasicStateTests.cs index e5e05b4..29504b6 100644 --- a/source/Lite.StateMachine.Tests/StateTests/BasicStateTests.cs +++ b/source/Lite.StateMachine.Tests/StateTests/BasicStateTests.cs @@ -3,7 +3,6 @@ using System; using System.Linq; -using System.Threading; using System.Threading.Tasks; using Lite.StateMachine.Tests.TestData; using Lite.StateMachine.Tests.TestData.States; @@ -18,25 +17,26 @@ public class BasicStateTests : TestBase public void Basic_RegisterState_Executes123_SuccessTest() { // Assemble - var ctxProperties = new PropertyBag() - { - { ParameterType.Counter, 0 }, - { ParameterType.TestExecutionOrder, true }, - }; - var machine = new StateMachine(); machine.RegisterState(BasicStateId.State1, BasicStateId.State2); machine.RegisterState(BasicStateId.State2, BasicStateId.State3); machine.RegisterState(BasicStateId.State3); + machine.AddContext(new() + { + { ParameterType.Counter, 0 }, + { ParameterType.TestExecutionOrder, true }, + }); // Act - Non async Start your engine! - var task = machine.RunAsync(BasicStateId.State1, ctxProperties); + var task = machine.RunAsync(BasicStateId.State1); task.GetAwaiter().GetResult(); // Assert Results AssertMachineNotNull(machine); - Assert.AreEqual(0, machine.Context.Parameters.Count); + Assert.IsNotEmpty(machine.Context.Parameters); + Assert.AreEqual(9, machine.Context.ParameterAsInt(ParameterType.Counter)); + Assert.IsTrue(machine.Context.ParameterAsBool(ParameterType.TestExecutionOrder)); // Ensure all states are registered var enums = Enum.GetValues().Cast(); @@ -53,19 +53,19 @@ public void Basic_RegisterState_Executes123_SuccessTest() public async Task Basic_RegisterState_Executes123_SuccessTestAsync() { // Assemble - var ctxProperties = new PropertyBag() - { - { ParameterType.Counter, 0 }, - { ParameterType.TestExecutionOrder, true }, - }; - var machine = new StateMachine(); machine.RegisterState(BasicStateId.State1, BasicStateId.State2); machine.RegisterState(BasicStateId.State2, BasicStateId.State3); machine.RegisterState(BasicStateId.State3); + machine.Context.Parameters = new() + { + { ParameterType.Counter, 0 }, + { ParameterType.TestExecutionOrder, true }, + }; + // Act - Start your engine! - await machine.RunAsync(BasicStateId.State1, ctxProperties); + await machine.RunAsync(BasicStateId.State1); // Assert Results AssertMachineNotNull(machine); @@ -89,8 +89,8 @@ public async Task Basic_RegisterState_Executes132_SuccessTestAsync() machine.RegisterState(BasicStateId.State2); // Act - Start your engine! - var ctxProperties = new PropertyBag() { { ParameterType.TestExecutionOrder, false } }; - await machine.RunAsync(BasicStateId.State1, ctxProperties); + machine.Context.Parameters = new() { { ParameterType.TestExecutionOrder, false } }; + await machine.RunAsync(BasicStateId.State1); // Assert Results AssertMachineNotNull(machine); @@ -121,7 +121,8 @@ public async Task Basic_RegisterState_Fluent_SuccessTestAsync() .RegisterState(BasicStateId.State1, BasicStateId.State2) .RegisterState(BasicStateId.State2, BasicStateId.State3) .RegisterState(BasicStateId.State3) - .RunAsync(BasicStateId.State1, ctxProperties, cancellationToken: TestContext.CancellationToken); + .AddContext(ctxProperties) + .RunAsync(BasicStateId.State1, cancellationToken: TestContext.CancellationToken); // Assert Results AssertMachineNotNull(machine); @@ -182,9 +183,10 @@ public async Task HungState_Proceeds_DefaultStateTimeout_SuccessTestAsync() machine.RegisterState(BasicStateId.State1, BasicStateId.State2); machine.RegisterState(BasicStateId.State2, BasicStateId.State3); machine.RegisterState(BasicStateId.State3); + machine.AddContext(ctxProperties); // Act - Start your engine! - await machine.RunAsync(BasicStateId.State1, ctxProperties); + await machine.RunAsync(BasicStateId.State1); // Assert Results AssertMachineNotNull(machine); @@ -194,38 +196,4 @@ public async Task HungState_Proceeds_DefaultStateTimeout_SuccessTestAsync() Assert.HasCount(enums.Count(), machine.States); Assert.IsTrue(enums.All(k => machine.States.Contains(k))); } - - /// Context is returned at the end. - /// A representing the asynchronous unit test. - [TestMethod] - [Ignore("vNext - Currently StateMachine destroys context after run completes.")] - public async Task RegisterState_ReturnsContext_SuccessTestAsync() - { - // Assemble - var ctxProperties = new PropertyBag() - { - { ParameterType.Counter, 0 }, - { ParameterType.TestExecutionOrder, true }, - }; - - var machine = new StateMachine(); - machine.RegisterState(BasicStateId.State1, BasicStateId.State2); - machine.RegisterState(BasicStateId.State2, BasicStateId.State3); - machine.RegisterState(BasicStateId.State3); - - // Act - Start your engine! - var task = machine.RunAsync(BasicStateId.State1, ctxProperties); - await task; // Non async method: task.GetAwaiter().GetResult(); - - // Assert Results - AssertMachineNotNull(machine); - - var ctxFinalParams = machine.Context.Parameters; - Assert.IsNotNull(ctxFinalParams); - Assert.AreNotEqual(0, ctxFinalParams.Count); - - // NOTE: This should be 9 because each state has 3 hooks that increment the counter - // TODO (2025-12-22 DS): Fix last state not calling OnExit. - ////Assert.AreEqual(9, ctxFinalParams[ParameterType.Counter]); - } } diff --git a/source/Lite.StateMachine.Tests/StateTests/CommandStateTests.cs b/source/Lite.StateMachine.Tests/StateTests/CommandStateTests.cs index ebd9f53..8363e69 100644 --- a/source/Lite.StateMachine.Tests/StateTests/CommandStateTests.cs +++ b/source/Lite.StateMachine.Tests/StateTests/CommandStateTests.cs @@ -46,6 +46,7 @@ public async Task BasicState_Override_Executes_SuccessAsync() }; machine + .AddContext(ctxProperties) .RegisterState(StateId.State1, StateId.State2) .RegisterComposite(StateId.State2, initialChildStateId: StateId.State2_Sub1, onSuccess: StateId.State3) .RegisterSubState(StateId.State2_Sub1, parentStateId: StateId.State2, onSuccess: StateId.State2_Sub2) @@ -81,7 +82,7 @@ public async Task BasicState_Override_Executes_SuccessAsync() }); // Act - Start your engine! - await machine.RunAsync(StateId.State1, ctxProperties, null, TestContext.CancellationToken); + await machine.RunAsync(StateId.State1, TestContext.CancellationToken); // Assert Results AssertMachineNotNull(machine); @@ -131,7 +132,7 @@ public async Task CancelsInfiniteStateMachineTestAsync() events.Publish(new CancelResponse()); }); - var result = await machine.RunAsync(StateId.State1, null, null, cts.Token); + var result = await machine.RunAsync(StateId.State1, cts.Token); // Assert Assert.IsNotNull(result); diff --git a/source/Lite.StateMachine.Tests/StateTests/CompositeStateTest.cs b/source/Lite.StateMachine.Tests/StateTests/CompositeStateTest.cs index 2545b92..34540f1 100644 --- a/source/Lite.StateMachine.Tests/StateTests/CompositeStateTest.cs +++ b/source/Lite.StateMachine.Tests/StateTests/CompositeStateTest.cs @@ -274,9 +274,10 @@ public async Task Level3_PreviousStateId_SuccessTestAsync() var ctxProperties = new PropertyBag() { { ParameterType.TestExecutionOrder, true } }; var machine = GenerateStateMachineL3(new StateMachine(factory)); + machine.AddContext(ctxProperties); // Act - await machine.RunAsync(CompositeL3.State1, ctxProperties, null, TestContext.CancellationToken); + await machine.RunAsync(CompositeL3.State1, TestContext.CancellationToken); // Assert AssertMachineNotNull(machine); diff --git a/source/Lite.StateMachine.Tests/StateTests/ContextTests.cs b/source/Lite.StateMachine.Tests/StateTests/ContextTests.cs index cfe142c..161a411 100644 --- a/source/Lite.StateMachine.Tests/StateTests/ContextTests.cs +++ b/source/Lite.StateMachine.Tests/StateTests/ContextTests.cs @@ -40,9 +40,10 @@ public void Basic_RegisterState_Executes123_SuccessTest() machine.RegisterState(CtxStateId.State1, CtxStateId.State2); machine.RegisterState(CtxStateId.State2, CtxStateId.State3); machine.RegisterState(CtxStateId.State3); + machine.AddContext(ctxProperties); // Act - Non async Start your engine! - var task = machine.RunAsync(CtxStateId.State1, ctxProperties); + var task = machine.RunAsync(CtxStateId.State1); task.GetAwaiter().GetResult(); // Assert Results diff --git a/source/Lite.StateMachine.Tests/StateTests/CustomStateTests.cs b/source/Lite.StateMachine.Tests/StateTests/CustomStateTests.cs index 58fac61..2c295b1 100644 --- a/source/Lite.StateMachine.Tests/StateTests/CustomStateTests.cs +++ b/source/Lite.StateMachine.Tests/StateTests/CustomStateTests.cs @@ -29,20 +29,19 @@ public async Task BasicState_Override_Executes_SuccessAsync(bool skipState3) var msgService = services.GetRequiredService(); Func factory = t => ActivatorUtilities.CreateInstance(services, t); - var ctxProperties = new PropertyBag() - { - { ParameterType.Counter, 0 }, - { ParameterType.TestExitEarly, skipState3 }, - }; - var machine = new StateMachine(factory, null, isContextPersistent: true); machine.RegisterState(CustomStateId.State1, CustomStateId.State2_Dummy); machine.RegisterState(CustomStateId.State2_Dummy, CustomStateId.State3); machine.RegisterState(CustomStateId.State2_Success, CustomStateId.State3); machine.RegisterState(CustomStateId.State3); + machine.AddContext(new() + { + { ParameterType.Counter, 0 }, + { ParameterType.TestExitEarly, skipState3 }, + }); // Act - Start your engine! - await machine.RunAsync(CustomStateId.State1, ctxProperties, cancellationToken: TestContext.CancellationToken); + await machine.RunAsync(CustomStateId.State1, cancellationToken: TestContext.CancellationToken); // Assert Results AssertMachineNotNull(machine); @@ -72,6 +71,7 @@ public async Task BasicState_Overrides_ThrowUnregisteredException_Async() }; var machine = new StateMachine(factory, null, isContextPersistent: true); + machine.AddContext(ctxProperties); machine.RegisterState(CustomStateId.State1, CustomStateId.State2_Dummy); machine.RegisterState(CustomStateId.State2_Dummy, CustomStateId.State3); machine.RegisterState(CustomStateId.State2_Success, CustomStateId.State3); @@ -79,7 +79,7 @@ public async Task BasicState_Overrides_ThrowUnregisteredException_Async() // Act - Start your engine! await Assert.ThrowsExactlyAsync(() - => machine.RunAsync(CustomStateId.State1, ctxProperties, null, TestContext.CancellationToken)); + => machine.RunAsync(CustomStateId.State1, TestContext.CancellationToken)); // Assert Results AssertMachineNotNull(machine); @@ -110,7 +110,7 @@ public async Task Composite_Override_Executes_SuccessAsync(bool skipSubState2) }; var machine = new StateMachine(factory, null, isContextPersistent: true); - + machine.AddContext(ctxProperties); machine.RegisterState(CustomStateId.State1, CustomStateId.State2_Dummy); machine.RegisterState(CustomStateId.State2_Dummy, CustomStateId.State3); machine.RegisterComposite(CustomStateId.State2_Success, CustomStateId.State2_Sub1, CustomStateId.State3); @@ -120,7 +120,7 @@ public async Task Composite_Override_Executes_SuccessAsync(bool skipSubState2) machine.RegisterState(CustomStateId.State3); // Act - Start your engine! - await machine.RunAsync(CustomStateId.State1, ctxProperties, cancellationToken: TestContext.CancellationToken); + await machine.RunAsync(CustomStateId.State1, cancellationToken: TestContext.CancellationToken); // Assert Results AssertMachineNotNull(machine); diff --git a/source/Lite.StateMachine.Tests/StateTests/DiMsTests.cs b/source/Lite.StateMachine.Tests/StateTests/DiMsTests.cs index 48bc1c0..3dbec08 100644 --- a/source/Lite.StateMachine.Tests/StateTests/DiMsTests.cs +++ b/source/Lite.StateMachine.Tests/StateTests/DiMsTests.cs @@ -271,7 +271,7 @@ public async Task RegisterState_MsDi_EventAggregatorOnly_SuccessTestAsync() }); // Act - Run the state machine and send messages - await machine.RunAsync(CompositeMsgStateId.Entry, null, null, CancellationToken.None); + await machine.RunAsync(CompositeMsgStateId.Entry, CancellationToken.None); Console.WriteLine("MS.DI workflow finished."); diff --git a/source/Lite.StateMachine.Tests/TestData/States/BasicStates.cs b/source/Lite.StateMachine.Tests/TestData/States/BasicStates.cs index 144b337..12b1fbf 100644 --- a/source/Lite.StateMachine.Tests/TestData/States/BasicStates.cs +++ b/source/Lite.StateMachine.Tests/TestData/States/BasicStates.cs @@ -31,12 +31,15 @@ public async Task OnEnter(Context context) context.Parameters[ParameterType.Counter] = context.ParameterAsInt(ParameterType.Counter) + 1; context.NextState(Result.Success); Console.WriteLine($"[BasicState1][OnEnter] {context.Parameters[ParameterType.Counter]} => OK"); + Console.WriteLine($"[BasicState1][OnEnter].OnSuccess '{context.NextStates.OnSuccess}'"); + Console.WriteLine($"[BasicState1][OnEnter].OnError '{context.NextStates.OnError}'"); + Console.WriteLine($"[BasicState1][OnEnter].OnFailure '{context.NextStates.OnFailure}'"); } public Task OnExit(Context context) { context.Parameters[ParameterType.Counter] = context.ParameterAsInt(ParameterType.Counter) + 1; - Console.WriteLine($"[BasicState1][OnExit] {context.Parameters[ParameterType.Counter]}"); + Console.WriteLine($"[BasicState1][OnExit] Params[Counter]: {context.Parameters[ParameterType.Counter]}"); return Task.CompletedTask; } } diff --git a/source/Lite.StateMachine/Context.cs b/source/Lite.StateMachine/Context.cs index fb4b80b..b4b4edf 100644 --- a/source/Lite.StateMachine/Context.cs +++ b/source/Lite.StateMachine/Context.cs @@ -19,16 +19,17 @@ public sealed class Context #pragma warning restore SA1401 // Fields should be private - private readonly TaskCompletionSource _tcs; + ////private readonly TaskCompletionSource _tcs; + private TaskCompletionSource _tcs; internal Context( - TStateId current, + TStateId currentStateId, StateMap nextStates, TaskCompletionSource tcs, IEventAggregator? eventAggregator = null, Result? lastChildResult = null) { - CurrentStateId = current; + CurrentStateId = currentStateId; NextStates = nextStates; _tcs = tcs; EventAggregator = eventAggregator; @@ -36,16 +37,16 @@ internal Context( } /// Gets the current State's Id. - public TStateId CurrentStateId { get; } + public TStateId CurrentStateId { get; private set; } /// Gets or sets an arbitrary collection of errors to pass along to the next state. public PropertyBag Errors { get; set; } = []; /// Gets the Event aggregator for Command states (optional). - public IEventAggregator? EventAggregator { get; } + public IEventAggregator? EventAggregator { get; private set; } /// Gets result emitted by the last child state (for composite parents only). - public Result? LastChildResult { get; } + public Result? LastChildResult { get; private set; } /// Gets or sets an arbitrary parameter provided by caller to the current action. public PropertyBag Parameters { get; set; } = []; @@ -53,6 +54,26 @@ internal Context( /// Gets the previous state's enum value. public TStateId? PreviousStateId { get; internal set; } + /// Not for user consumption. Configures Context for state transitions. + /// Task Completion Source. + /// Current state that we're in. + /// State which sent us here. + public void Configure(TaskCompletionSource tcs, TStateId currentStateId, TStateId? previousStateId) + { + _tcs = tcs; + CurrentStateId = currentStateId; + PreviousStateId = previousStateId; + } + + /// Not for user consumption. Configures Context for composite state transitions. + /// Task Completion Source. + /// State which sent us here. + public void Configure(TaskCompletionSource tcs, Result? lastChildResult) + { + _tcs = tcs; + LastChildResult = lastChildResult; + } + /// Signal the machine to move forward (only once per state entry). /// Result to pass to the next state. /// Consider renaming to `StateResult` or `Result` for clarity. diff --git a/source/Lite.StateMachine/IStateMachine.cs b/source/Lite.StateMachine/IStateMachine.cs index 7815150..d8d000d 100644 --- a/source/Lite.StateMachine/IStateMachine.cs +++ b/source/Lite.StateMachine/IStateMachine.cs @@ -105,10 +105,11 @@ StateMachine RegisterSubState(TStateId stateId, TStateId /// Starts the machine at the initial state. /// Initial startup state. - /// Parameter stack . - /// Error stack . /// Cancellation Token. /// Async task of The current instance, enabling method chaining. /// Thrown if the specified state identifier has not been registered. - Task> RunAsync(TStateId initialState, PropertyBag? parameterStack = null, PropertyBag? errors = null, CancellationToken cancellationToken = default); + Task> RunAsync(TStateId initialState, CancellationToken cancellationToken = default); + /////// Parameter stack . + /////// Error stack . + ////Task> RunAsync(TStateId initialState, PropertyBag? parameterStack = null, PropertyBag? errors = null, CancellationToken cancellationToken = default); } diff --git a/source/Lite.StateMachine/Lite.StateMachine.csproj b/source/Lite.StateMachine/Lite.StateMachine.csproj index 7d858a4..68b2b19 100644 --- a/source/Lite.StateMachine/Lite.StateMachine.csproj +++ b/source/Lite.StateMachine/Lite.StateMachine.csproj @@ -11,7 +11,7 @@ 2.3.0 $(AssemblyVersion) $(AssemblyVersion) - -alpha1 + -alpha2 $(VersionPrefix)$(VersionSuffix) True diff --git a/source/Lite.StateMachine/StateMachine.cs b/source/Lite.StateMachine/StateMachine.cs index a16be39..b89985c 100644 --- a/source/Lite.StateMachine/StateMachine.cs +++ b/source/Lite.StateMachine/StateMachine.cs @@ -29,9 +29,6 @@ public sealed partial class StateMachine : IStateMachine /// States registered with system. private readonly Dictionary> _states = []; - ////private IDisposable? _subscription; - ////private CancellationTokenSource? _timeoutCts; - /// /// Initializes a new instance of the class. /// Dependency Injection is optional: @@ -53,20 +50,26 @@ public StateMachine( _eventAggregator = eventAggregator; IsContextPersistent = isContextPersistent; + Context = new Context( + currentStateId: default, + nextStates: new StateMap { OnSuccess = null, OnError = null, OnFailure = null }, + tcs: default!, + eventAggregator: _eventAggregator); + // NOTE-1 (2025-12-25): // * Create Precheck Sanitization: // * Verify initial states are set for core and all sub-states. // * Verify any transition exceptions (i.e. DisjointedNextSubStateException) // - //// OLD-4d3, 4bx: + //// OLD-4d3, 4bx 'IServiceResolver' container helper: //// public StateMachine(IServiceResolver? services = null, IEventAggregator? eventAggregator = null, ILogger>? logs = null) //// _services = services; //// _logger = logs; } /// - public Context Context { get; private set; } = new Context(default, default, default!, null); - ////public Context Context { get; private set; } = default!; + ////public Context Context { get; private set; } = new Context(default, default, default!, null); + public Context Context { get; private set; } = default!; /// public int DefaultCommandTimeoutMs { get; set; } = 3000; @@ -80,6 +83,27 @@ public StateMachine( /// public List States => [.. _states.Keys]; + /// Preload properties and errors to the context. + /// Parameter properties to safely add/update. + /// Error properties to safely add/update. + /// Instance of this class. + public StateMachine AddContext(PropertyBag? parameters = null, PropertyBag? errors = null) + { + if (parameters is not null) + { + foreach (var item in parameters) + Context.Parameters.SafeAdd(item.Key, item.Value); + } + + if (errors is not null) + { + foreach (var item in errors) + Context.Parameters.SafeAdd(item.Key, item.Value); + } + + return this; + } + /// public StateMachine RegisterComposite( TStateId stateId, @@ -200,8 +224,6 @@ public StateMachine RegisterSubState( /// public async Task> RunAsync( TStateId initialStateId, - PropertyBag? parameterStack = null, - PropertyBag? errorStack = null, CancellationToken cancellationToken = default) { if (!_states.ContainsKey(initialStateId)) @@ -210,31 +232,24 @@ public async Task> RunAsync( TStateId? prevStateId = null; var currentStateId = initialStateId; - // TBD - ////Context = new Context(currentStateId, default, default!, null); + // Make sure we're always initialized + Context ??= new Context( + currentStateId: default, + nextStates: new StateMap { OnSuccess = null, OnError = null, OnFailure = null }, + tcs: default!, + eventAggregator: _eventAggregator); while (!cancellationToken.IsCancellationRequested) { var reg = GetRegistration(currentStateId); reg.PreviousStateId = prevStateId; - // TODO (2025-12-28 DS): Configure context and pass it along - ////var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - ////var ctx = new Context(reg.StateId, tcs, _eventAggregator) - ////{ - //// Parameters = parameterStack ?? [], - //// Errors = errorStack ?? [], - ////}; - - ////Context.Parameters = parameterStack ?? []; - ////Context.Errors = errorStack ?? []; - - parameterStack ??= []; - errorStack ??= []; + // Ensure we're always initialized + Context.Parameters = Context.Parameters ?? []; + Context.Errors = Context.Errors ?? []; // Run any state (composite or leaf) recursively. - var result = await RunAnyStateRecursiveAsync(reg, parameterStack, errorStack, cancellationToken).ConfigureAwait(false); - ////var result = await RunAnyStateRecursiveAsync(reg, cancellationToken).ConfigureAwait(false); + var result = await RunAnyStateRecursiveAsync(reg, cancellationToken).ConfigureAwait(false); if (result is null) break; @@ -295,38 +310,24 @@ private StateRegistration GetRegistration(TStateId stateId) private async Task RunAnyStateRecursiveAsync( StateRegistration reg, - PropertyBag? parameters, - PropertyBag? errors, CancellationToken ct) { - // Ensure we always operate on non-null, shared bags - ////parameters ??= []; - ////errors ??= []; - // Run Normal or Command State if (!reg.IsCompositeParent) - ////return await RunLeafAsync(reg, ct).ConfigureAwait(false); - return await RunLeafAsync(reg, parameters, errors, ct).ConfigureAwait(false); + return await RunLeafAsync(reg, ct).ConfigureAwait(false); // Composite States var instance = GetOrCreateInstance(reg); - StateMap nextStates = new() - { - OnSuccess = reg.OnSuccess, - OnError = reg.OnError, - OnFailure = reg.OnFailure, - }; - - var parentEnterTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var parentEnterCtx = new Context(reg.StateId, nextStates, parentEnterTcs, _eventAggregator) - { - Parameters = parameters, - Errors = errors, - PreviousStateId = reg.PreviousStateId, - }; + Context.Configure( + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously), + reg.StateId, + reg.PreviousStateId); + Context.NextStates.OnSuccess = reg.OnSuccess; + Context.NextStates.OnError = reg.OnError; + Context.NextStates.OnFailure = reg.OnFailure; - await instance.OnEntering(parentEnterCtx).ConfigureAwait(false); + await instance.OnEntering(Context).ConfigureAwait(false); // [IsContextPersistent] // Take snapshot of original Context keys AFTER OnEntering so we can give the state a chance @@ -334,15 +335,10 @@ private StateRegistration GetRegistration(TStateId stateId) // // Any new Context keys added via OnEnter are considered "for children consumption only". // After our OnExit, they'll be (optionally) removed. - var originalParamKeys = new HashSet(parameters.Keys); - var originalErrorKeys = new HashSet(errors.Keys); - - await instance.OnEnter(parentEnterCtx).ConfigureAwait(false); + var originalParamKeys = new HashSet(Context.Parameters.Keys); + var originalErrorKeys = new HashSet(Context.Errors.Keys); - // Check for next transition overrides - ////reg.OnSuccess = parentEnterCtx.NextStates.OnSuccess; - ////reg.OnError = parentEnterCtx.NextStates.OnError; - ////reg.OnFailure = parentEnterCtx.NextStates.OnFailure; + await instance.OnEnter(Context).ConfigureAwait(false); // TODO (2025-12-28 DS): Consider StateMachine config param to just move along or throw exception if (reg.InitialChildId is null) @@ -364,18 +360,16 @@ private StateRegistration GetRegistration(TStateId stateId) if (!Equals(childReg.ParentId, reg.StateId)) throw new OrphanSubStateException($"Child state '{childId}' must belong to composite '{reg.StateId}'."); - // Could just call "RunAnyStateRecursiveAsync" but lets not waste cycles Result? childResult; if (childReg.IsCompositeParent) - childResult = await RunAnyStateRecursiveAsync(childReg, parameters, errors, ct).ConfigureAwait(false); + childResult = await RunAnyStateRecursiveAsync(childReg, ct).ConfigureAwait(false); else - childResult = await RunLeafAsync(childReg, parameters, errors, ct).ConfigureAwait(false); + childResult = await RunLeafAsync(childReg, ct).ConfigureAwait(false); // Cancelled or timed out inside child state if (childResult is null) return null; - // TODO (#76): Extract the Context.OnSuccess/Error/Failure override (if any) lastChildResult = childResult; var nextChildId = StateMachine.ResolveNext(childReg, childResult.Value); @@ -396,13 +390,15 @@ private StateRegistration GetRegistration(TStateId stateId) // Parent's OnExit decides Ok/Error/Failure; Inform parent of last child's result via Context // TODO (2025-12-28 DS): Pass one Context object. Just clear "lastChildResult" after the OnExit. var parentExitTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var parentExitCtx = new Context(reg.StateId, nextStates, parentExitTcs, _eventAggregator, lastChildResult) - { - Parameters = parameters ?? [], - Errors = errors ?? [], - }; - await instance.OnExit(parentExitCtx).ConfigureAwait(false); + Context.Configure(parentExitTcs, lastChildResult); + + // vNext (2025-12-28 DS): Use `OnState` handler to handle children completion instead of OnExit + ////await instance.OnState(Context).ConfigureAwait(false); + ////var parentDecision = await WaitForNextOrCancelAsync(parentExitTcs.Task, ct).ConfigureAwait(false); + + // TODO (2025-01-10 DS): Double check the Context's CurrentStateId, NextStates.OnSuccess/Error/Failure match our parent state. Not leftover child garbage + await instance.OnExit(Context).ConfigureAwait(false); // To avoid composite state's OnExit, use the DefaultStateTimeoutMs to auto-cancel wait. var parentDecision = await WaitForNextOrCancelAsync(parentExitTcs.Task, ct).ConfigureAwait(false); @@ -410,16 +406,16 @@ private StateRegistration GetRegistration(TStateId stateId) // Optionally cleanup context added by the children; giving the parent a peek at their mess. if (!IsContextPersistent) { - if (parameters is not null) + if (Context.Parameters is not null) { - foreach (var k in parameters.Keys) - if (!originalParamKeys.Contains(k)) parameters.Remove(k); + foreach (var k in Context.Parameters.Keys) + if (!originalParamKeys.Contains(k)) Context.Parameters.Remove(k); } - if (errors is not null) + if (Context.Errors is not null) { - foreach (var k in errors.Keys) - if (!originalErrorKeys.Contains(k)) errors.Remove(k); + foreach (var k in Context.Errors.Keys) + if (!originalErrorKeys.Contains(k)) Context.Errors.Remove(k); } } @@ -430,40 +426,29 @@ private StateRegistration GetRegistration(TStateId stateId) return parentDecision; } - // Rename: RunSingleStateAsync(...) + // Rename (2015-12-28 DS): RunSingleStateAsync(...) private async Task RunLeafAsync( StateRegistration reg, - PropertyBag? parameterStack, - PropertyBag? errorStack, CancellationToken cancellationToken) { IState instance = GetOrCreateInstance(reg); - // Next state transitions - StateMap nextStates = new() - { - OnSuccess = reg.OnSuccess, - OnError = reg.OnError, - OnFailure = reg.OnFailure, - }; - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var ctx = new Context(reg.StateId, nextStates, tcs, _eventAggregator) - { - Parameters = parameterStack ?? [], - Errors = errorStack ?? [], - PreviousStateId = reg.PreviousStateId, - }; + Context.Configure(tcs, reg.StateId, reg.PreviousStateId); + Context.NextStates.OnSuccess = reg.OnSuccess; + Context.NextStates.OnError = reg.OnError; + Context.NextStates.OnFailure = reg.OnFailure; IDisposable? subscription = null; CancellationTokenSource? timeoutCts = null; + // TODO (2026-01-10 DS): Is is possible for OnMessage to happen before OnEntering/OnEnter? if (instance is ICommandState cmd) { if (_eventAggregator is not null) { // Subscribed message types or `Array.Empty()` for none - //// vNext: IReadOnlyCollection types2 = [.. cmd.SubscribedMessageTypes ?? [], .. reg.SubscribedMessageTypes ?? []]; + //// vNext (#89): IReadOnlyCollection types2 = [.. cmd.SubscribedMessageTypes ?? [], .. reg.SubscribedMessageTypes ?? []]; var types = cmd.SubscribedMessageTypes ?? []; subscription = _eventAggregator.Subscribe(async (msgObj) => @@ -473,7 +458,7 @@ private StateRegistration GetRegistration(TStateId stateId) #pragma warning disable SA1501 // Statement should not be on a single line // Swallow to avoid breaking publication loop - try { await cmd.OnMessage(ctx, msgObj).ConfigureAwait(false); } catch { } + try { await cmd.OnMessage(Context, msgObj).ConfigureAwait(false); } catch { } #pragma warning restore SA1501 // Statement should not be on a single line }, [.. types]); //// [.. types] == types.ToArray() @@ -488,7 +473,7 @@ private StateRegistration GetRegistration(TStateId stateId) { await Task.Delay(timeoutMs, timeoutCts.Token).ConfigureAwait(false); if (!tcs.Task.IsCompleted && !timeoutCts.IsCancellationRequested) - await cmd.OnTimeout(ctx).ConfigureAwait(false); + await cmd.OnTimeout(Context).ConfigureAwait(false); } catch (TaskCanceledException) { @@ -502,21 +487,20 @@ private StateRegistration GetRegistration(TStateId stateId) try { - await instance.OnEntering(ctx).ConfigureAwait(false); - await instance.OnEnter(ctx).ConfigureAwait(false); + await instance.OnEntering(Context).ConfigureAwait(false); + await instance.OnEnter(Context).ConfigureAwait(false); var result = await WaitForNextOrCancelAsync(tcs.Task, cancellationToken).ConfigureAwait(false); // TODO (2025-12-28 DS): Potential DefaultStateTimeoutMs. Even leaving OnEnter without NextState(Result.OK), should consider calling `OnExit` to allow states to cleanup. - // TODO (2026-01-04 DS): Consider handling "OnTRANSITION" overrides. Passing a single Context would solve this as it's passed by ref. if (result is null) return null; - await instance.OnExit(ctx).ConfigureAwait(false); + await instance.OnExit(Context).ConfigureAwait(false); - reg.OnSuccess = ctx.NextStates.OnSuccess; - reg.OnError = ctx.NextStates.OnError; - reg.OnFailure = ctx.NextStates.OnFailure; + reg.OnSuccess = Context.NextStates.OnSuccess; + reg.OnError = Context.NextStates.OnError; + reg.OnFailure = Context.NextStates.OnFailure; return result.Value; } diff --git a/source/Sample.Basics/States/DemoMachine.cs b/source/Sample.Basics/States/DemoMachine.cs index cdaa5cf..925bfe0 100644 --- a/source/Sample.Basics/States/DemoMachine.cs +++ b/source/Sample.Basics/States/DemoMachine.cs @@ -26,11 +26,12 @@ public static async Task RunAsync(bool logOutput = true) }; var machine = new StateMachine(); + machine.AddContext(ctxProperties); machine.RegisterState(BasicStateId.State1, BasicStateId.State2); machine.RegisterState(BasicStateId.State2, BasicStateId.State3); machine.RegisterState(BasicStateId.State3); // Act - Start your engine! - await machine.RunAsync(BasicStateId.State1, ctxProperties); + await machine.RunAsync(BasicStateId.State1); } } From 04c04ea0d53c9eb4ff74c4ba064e48a9aef611ee Mon Sep 17 00:00:00 2001 From: Damian Suess Date: Sat, 10 Jan 2026 16:03:02 -0500 Subject: [PATCH 2/3] Readme cleanup --- readme-nuget.md | 14 +++++++++----- readme.md | 14 +++++++++----- .../StateTests/ContextTests.cs | 4 +--- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/readme-nuget.md b/readme-nuget.md index 7a90181..fadcbea 100644 --- a/readme-nuget.md +++ b/readme-nuget.md @@ -8,9 +8,9 @@ The Lite State Machine is designed for vertical scaling. Meaning, it can be used || |-| -| Copyright 2021-2025 Xeno Innovations, Inc. (_dba, Suess Labs_) | +| Copyright 2021-2026 Xeno Innovations, Inc. (_DBA:, Suess Labs_) | | Created by: Damian Suess | -| Date: 2021-06-07 | +| Date: 2021-06-07 (_inception 2016_) | ## Package Releases @@ -92,10 +92,9 @@ var uml = machine.ExportUml(includeSubmachines: true); ```cs using Lite.StateMachine; -var ctxProperties = new PropertyBag() { { "CounterKey", 0 } }; - // Note the use of generics '' to strongly-type the state machine var machine = new StateMachine() + .AddContext(new() { { ParameterType.Counter, 999 } }); .RegisterState(CompositeL1StateId.State1, CompositeL1StateId.State2) .RegisterComposite( @@ -126,6 +125,9 @@ public class Composite_State1() : BaseState { public override Task OnEnter(Context context) { + var cnt = context.ParameterAsInt(ParameterType.Counter); + var blank = context.ParameterAsBool(ParameterType.DummyBool); + context.NextState(Result.Ok); return Task.CompletedTask; } @@ -152,7 +154,9 @@ public class Composite_State2_Sub1() : BaseState { public override Task OnEnter(Context context) { - context.Parameters.Add("ParameterSubStateEntered", SUCCESS); + // Safely add/update key-value in Context + context.Parameters.SafeAdd("StringBasedParamKey", SUCCESS); + context.NextState(Result.Ok); return Task.CompletedTask; } diff --git a/readme.md b/readme.md index 59f50b8..5aaa6b4 100644 --- a/readme.md +++ b/readme.md @@ -10,9 +10,9 @@ The Lite State Machine is designed for vertical scaling. Meaning, it can be used || |-| -| Copyright 2021-2025 Xeno Innovations, Inc. (_dba, Suess Labs_) | +| Copyright 2021-2026 Xeno Innovations, Inc. (_DBA: Suess Labs_) | | Created by: Damian Suess | -| Date: 2021-06-07 | +| Date: 2021-06-07 (_inception 2016_) | ## Package Releases @@ -94,10 +94,9 @@ var uml = machine.ExportUml(includeSubmachines: true); ```cs using Lite.StateMachine; -var ctxProperties = new PropertyBag() { { "CounterKey", 0 } }; - // Note the use of generics '' to strongly-type the state machine var machine = new StateMachine() + .AddContext(new() { { ParameterType.Counter, 999 } }); .RegisterState(CompositeL1StateId.State1, CompositeL1StateId.State2) .RegisterComposite( @@ -128,6 +127,9 @@ public class Composite_State1() : BaseState { public override Task OnEnter(Context context) { + var cnt = context.ParameterAsInt(ParameterType.Counter); + var blank = context.ParameterAsBool(ParameterType.DummyBool); + context.NextState(Result.Ok); return Task.CompletedTask; } @@ -154,7 +156,9 @@ public class Composite_State2_Sub1() : BaseState { public override Task OnEnter(Context context) { - context.Parameters.Add("ParameterSubStateEntered", SUCCESS); + // Safely add/update key-value in Context + context.Parameters.SafeAdd("StringBasedParamKey", SUCCESS); + context.NextState(Result.Ok); return Task.CompletedTask; } diff --git a/source/Lite.StateMachine.Tests/StateTests/ContextTests.cs b/source/Lite.StateMachine.Tests/StateTests/ContextTests.cs index 161a411..8ff95fc 100644 --- a/source/Lite.StateMachine.Tests/StateTests/ContextTests.cs +++ b/source/Lite.StateMachine.Tests/StateTests/ContextTests.cs @@ -34,13 +34,11 @@ private enum ParameterType public void Basic_RegisterState_Executes123_SuccessTest() { // Assemble - var ctxProperties = new PropertyBag() { { "KeyString_ValueInt", 99 } }; - var machine = new StateMachine(); machine.RegisterState(CtxStateId.State1, CtxStateId.State2); machine.RegisterState(CtxStateId.State2, CtxStateId.State3); machine.RegisterState(CtxStateId.State3); - machine.AddContext(ctxProperties); + machine.AddContext(new() { { "KeyString_ValueInt", 99 } }); // Act - Non async Start your engine! var task = machine.RunAsync(CtxStateId.State1); From 077145fe63acd6c9400eeeddfa642c35585e0858 Mon Sep 17 00:00:00 2001 From: Damian Suess Date: Sat, 10 Jan 2026 21:26:10 -0500 Subject: [PATCH 3/3] NEW: Context `TStateId? LastChildStateId`. Ensure that Context's CurrentStateId, LastStateId, LastChildStateId, and LastChildResult are properly carried forward, and LastChild* is NULL'd after the Composite parent state's OnExit is done. --- .../TestData/States/CommandL3States.cs | 25 +++++++++++++++++++ source/Lite.StateMachine/Context.cs | 15 ++++++++--- source/Lite.StateMachine/StateMachine.cs | 12 ++++----- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/source/Lite.StateMachine.Tests/TestData/States/CommandL3States.cs b/source/Lite.StateMachine.Tests/TestData/States/CommandL3States.cs index 2e9ef33..cf87709 100644 --- a/source/Lite.StateMachine.Tests/TestData/States/CommandL3States.cs +++ b/source/Lite.StateMachine.Tests/TestData/States/CommandL3States.cs @@ -151,6 +151,9 @@ public override Task OnEnter(Context context) { // Demonstrate temporary parameter that will be discarded after State2_Sub2's OnExit context.Parameters.Add($"{context.CurrentStateId}!TEMP", Guid.NewGuid()); + + Log.LogInformation($"[OnEnter] CurrentStateId: {context.CurrentStateId} PreviousStateId: {context.PreviousStateId}"); + Assert.AreEqual(StateId.State2_Sub2, context.CurrentStateId); return base.OnEnter(context); } @@ -158,6 +161,12 @@ public override Task OnExit(Context context) { // Expected Count: 7 MessageService.Counter3 = context.Parameters.Count; + + Log.LogInformation("[OnExit] CurrentStateId: {c} PreviousStateId: {p}", context.CurrentStateId, context.PreviousStateId); + Assert.AreEqual(StateId.State2_Sub2, context.CurrentStateId); + Assert.AreEqual(StateId.State2_Sub1, context.PreviousStateId); + Assert.AreEqual(StateId.State2_Sub2_Sub3, context.LastChildStateId); + Assert.AreEqual(Result.Success, context.LastChildResult); return base.OnExit(context); } } @@ -189,6 +198,9 @@ public override Task OnEnter(Context context) // 1) We're sending the same OpenCommand to prove that State1's OnMessage isn't called a 2nd time. // 2) CloseResponse doesn't reached our OnMessage because we left already. context.EventAggregator?.Publish(new UnlockCommand { Counter = 200 }); + + Log.LogInformation($"[OnEnter] CurrentStateId: {context.CurrentStateId} PreviousStateId: {context.PreviousStateId}"); + Assert.AreEqual(StateId.State2_Sub2_Sub2, context.CurrentStateId); return base.OnEnter(context); } @@ -197,6 +209,9 @@ public override Task OnMessage(Context context, object message) MessageService.Counter4++; context.NextState(Result.Success); + + Log.LogInformation($"[OnMessage] CurrentStateId: {context.CurrentStateId} PreviousStateId: {context.PreviousStateId}"); + Assert.AreEqual(StateId.State2_Sub2_Sub2, context.CurrentStateId); return base.OnMessage(context, message); } @@ -222,6 +237,13 @@ public override Task OnEnter(Context context) { context.Parameters.Add(context.CurrentStateId.ToString(), Guid.NewGuid()); MessageService.AddMessage($"[Keys-{context.CurrentStateId}]: {string.Join(",", context.Parameters.Keys)}"); + + // NOTE: We the state following the composite doesn't know about "LastChildXXX". + Log.LogInformation($"[OnEnter] CurrentStateId: {context.CurrentStateId} PreviousStateId: {context.PreviousStateId}"); + Assert.AreEqual(StateId.State2_Sub3, context.CurrentStateId); + Assert.AreEqual(StateId.State2_Sub2, context.PreviousStateId); + Assert.IsNull(context.LastChildStateId); + Assert.IsNull(context.LastChildResult); return base.OnEnter(context); } } @@ -234,6 +256,9 @@ public override Task OnEnter(Context context) { context.Parameters.Add(context.CurrentStateId.ToString(), Guid.NewGuid()); MessageService.AddMessage($"[Keys-{context.CurrentStateId}]: {string.Join(",", context.Parameters.Keys)}"); + + Log.LogInformation($"[OnEnter] CurrentStateId: {context.CurrentStateId} PreviousStateId: {context.PreviousStateId}"); + Assert.AreEqual(StateId.State3, context.CurrentStateId); return base.OnEnter(context); } } diff --git a/source/Lite.StateMachine/Context.cs b/source/Lite.StateMachine/Context.cs index b4b4edf..d707895 100644 --- a/source/Lite.StateMachine/Context.cs +++ b/source/Lite.StateMachine/Context.cs @@ -48,6 +48,9 @@ internal Context( /// Gets result emitted by the last child state (for composite parents only). public Result? LastChildResult { get; private set; } + /// Gets the last child (for composite parents only). + public TStateId? LastChildStateId { get; private set; } + /// Gets or sets an arbitrary parameter provided by caller to the current action. public PropertyBag Parameters { get; set; } = []; @@ -65,12 +68,18 @@ public void Configure(TaskCompletionSource tcs, TStateId currentStateId, PreviousStateId = previousStateId; } - /// Not for user consumption. Configures Context for composite state transitions. + /// Not for user consumption. Configures Composite Context state transitions. /// Task Completion Source. - /// State which sent us here. - public void Configure(TaskCompletionSource tcs, Result? lastChildResult) + /// Current state that we're in. + /// State which sent us here. + /// Last child state's . + /// Last child state's which sent us here. + public void Configure(TaskCompletionSource tcs, TStateId currentStateId, TStateId? previousStateId, TStateId? lastChildStateId, Result? lastChildResult) { _tcs = tcs; + CurrentStateId = currentStateId; + PreviousStateId = previousStateId; + LastChildStateId = lastChildStateId; LastChildResult = lastChildResult; } diff --git a/source/Lite.StateMachine/StateMachine.cs b/source/Lite.StateMachine/StateMachine.cs index b89985c..dfda020 100644 --- a/source/Lite.StateMachine/StateMachine.cs +++ b/source/Lite.StateMachine/StateMachine.cs @@ -349,6 +349,7 @@ private StateRegistration GetRegistration(TStateId stateId) // Set the initial substate's PreviousStateId to NULL, as we already know the parent. TStateId? childPrevStateId = null; + TStateId? lastChildStateId = null; // Composite Loop while (!ct.IsCancellationRequested) @@ -385,24 +386,24 @@ private StateRegistration GetRegistration(TStateId stateId) // Proceed to the next substate childPrevStateId = childId; childId = nextChildId.Value; + lastChildStateId = nextChildId.Value; } // Parent's OnExit decides Ok/Error/Failure; Inform parent of last child's result via Context // TODO (2025-12-28 DS): Pass one Context object. Just clear "lastChildResult" after the OnExit. var parentExitTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - Context.Configure(parentExitTcs, lastChildResult); + Context.Configure(parentExitTcs, reg.StateId, reg.PreviousStateId, lastChildStateId, lastChildResult); // vNext (2025-12-28 DS): Use `OnState` handler to handle children completion instead of OnExit ////await instance.OnState(Context).ConfigureAwait(false); ////var parentDecision = await WaitForNextOrCancelAsync(parentExitTcs.Task, ct).ConfigureAwait(false); - // TODO (2025-01-10 DS): Double check the Context's CurrentStateId, NextStates.OnSuccess/Error/Failure match our parent state. Not leftover child garbage await instance.OnExit(Context).ConfigureAwait(false); - - // To avoid composite state's OnExit, use the DefaultStateTimeoutMs to auto-cancel wait. var parentDecision = await WaitForNextOrCancelAsync(parentExitTcs.Task, ct).ConfigureAwait(false); + // Clear out the garbage pail kids + Context.Configure(parentExitTcs, reg.StateId, reg.PreviousStateId, lastChildStateId: null, lastChildResult: null); + // Optionally cleanup context added by the children; giving the parent a peek at their mess. if (!IsContextPersistent) { @@ -442,7 +443,6 @@ private StateRegistration GetRegistration(TStateId stateId) IDisposable? subscription = null; CancellationTokenSource? timeoutCts = null; - // TODO (2026-01-10 DS): Is is possible for OnMessage to happen before OnEntering/OnEnter? if (instance is ICommandState cmd) { if (_eventAggregator is not null)