Files
Marc Schier a81d981755 dotnet format sweep (whitespace, IDE, RCS) + code-style enforcer agent fix (#3865)
# Description

Pure code-style sweep across the repo — no behavioral changes. Drives
`dotnet format` info-level diagnostics down on a large slice of the
codebase (Stack, Libraries, Tests, Tools).

Six commits, applied bottom-up in safety order so each batch leaves the
build green:

1. **`style: fix whitespace and final-newline formatting (WHITESPACE,
FINALNEWLINE)`**
   197 hits across ~30 files; pure mechanical cleanup.

2. **`style: apply dotnet format style fixes (IDE rules)`**
IDE-rule batch via `dotnet format style --severity info` —
`var`/explicit type, simplify names, collection expressions, expression
bodies, unused usings, etc.

3. **`style: apply dotnet format analyzers fixes (RCS blank-line
formatting)`**
   Roslynator formatting (RCS0xxx) blank-line and punctuation rules.

4. **`style: convert comments to doc comments and inline local vars
(RCS1181, RCS1124)`**
24 files. RCS1181 promotes `// summary`-style comments above
public/internal members to `/// <summary>` doc comments; RCS1124 inlines
locals used exactly once. Generated `Tools/` files were reverted to keep
the source-generator outputs deterministic.

5. **`docs(agent): correct --diagnostics flag form and verify Roslynator
4.x code fixers`**
Updates `.github/agents/opc-ua-codestyle-enforcer.agent.md` with two
corrections discovered while running this sweep:
- `dotnet format --diagnostics "RULE1 RULE2"` (single quoted,
space-separated) silently fixes nothing on `dotnet format` 10.x — it
parses as one unknown ID and exits 0. The correct form is `--diagnostics
RULE1 --diagnostics RULE2 …`, repeated per rule.
- Roslynator 4.15.0 **does** ship code fixers for
RCS1078/1118/1124/1140/1142/1181/1221/1260 (the previous "no auto-fixer"
list was outdated). RCS1140/1142 default to `Hidden` so need `--severity
info` to surface and fire; RCS1078 requires
`roslynator_empty_string_style = field` (already set in
`.editorconfig`). Only NUnit4002/2046/2010 genuinely lack auto-fixers in
this stack.

6. **`style: format new code from master merge (dotnet format sweep)`**
After merging master into the branch (commit `e3ebc8742`), re-ran the
same three-phase format sweep on the 151 .cs files brought in by the
merge, scoped per project via repeated `--include`. 81 files changed;
same rule families as commits 1–4.
- Hand-fixed `Tests/Opc.Ua.MigrationAnalyzer.Tests/AnalyzerHarness.cs`
for `RCS1174` (redundant `async`/`await` removal) because the auto-fixer
didn't apply for that specific case.
   - Reverted files that the formatter would break:
- `Libraries/Opc.Ua.Server/Server/ServerInternalData.cs` — multi-TFM
analysis disagreement injected `<<<<<<<` / `=======` / `>>>>>>>`
markers.
-
`Tools/Opc.Ua.MigrationAnalyzer.Core/{Core/Stack/Client/ChannelBase.cs,
Core/Stack/Types/IUserIdentityTokenHandler.cs,
Client/Session/Session.cs}` — IDE0005 removed `using
Opc.Ua.Security.Certificates` that IS used (the files are 1.5.378
reference fixtures for the analyzer).
- `Tests/Opc.Ua.Gds.Tests/PushTest.cs` — IDE0002 over-simplified
`OpcUa.ObjectTypeIds` to `ObjectTypeIds`, which then resolves to a
conflicting type.

### What this PR does **not** touch

- No behavioral or semantic logic changes.
- No `.editorconfig` rule promotions (rules stay at their current
severities; this is a backlog-burndown pass, not a rule tightening).
- Generated source under `Tools/` is intentionally excluded.
- Higher-risk rule families (`RCS1090`/`CA2007` `ConfigureAwait`,
`RCS1249` null-forgiving cleanup, `IDE0270`/`IDE0059`) are left for
follow-up sweeps because they need per-TFM build verification or
semantic review.

## Related Issues

_No tracking issue — this is a non-functional style sweep and the
agent-doc correction is a follow-up to the same work. Happy to open one
if the maintainers prefer._

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage. _(N/A — style-only, no
behavioral change)_
- [x] I have added all necessary documentation. _(agent doc updated with
the two corrections)_
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings. _(verified clean on `net10.0` and `net48`; only
pre-existing `NETSDK1005`/`NETSDK1207` failures on Tools/AOT projects)_
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. _(build
verified on net10 and net48 per batch; full test suite not yet run
locally — relying on CI)_
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

---------

Co-authored-by: agent <agent@local>
2026-06-10 08:35:44 +02:00

1574 lines
63 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
namespace Opc.Ua.Server.StateMachines
{
/// <summary>
/// Unified fluent builder for Part 16 finite state machines.
/// Combines two complementary modes in a single API:
/// </summary>
/// <typeparam name="TState"></typeparam>
/// <remarks>
/// <list type="bullet">
/// <item><description>
/// <strong>Definition mode</strong> —
/// <see cref="StateMachineBuilder.Create"/> constructs a new
/// <see cref="FluentFiniteStateMachineState"/> in the address space
/// and exposes <see cref="AddState"/> / <see cref="AddTransition"/>
/// / <see cref="OnCause"/> for declarative table construction.
/// </description></item>
/// <item><description>
/// <strong>Lifecycle mode</strong> —
/// <see cref="StateMachineBuilder.For{TState}"/> adopts an
/// already-Created state machine (stack-shipped, generator-emitted,
/// or vendor) and wires
/// <see cref="OnEnterState"/> / <see cref="OnExitState"/> /
/// <see cref="OnTransition"/> / <see cref="OnBeforeTransition"/> /
/// <see cref="WithCause"/> / <see cref="WithTimedTransition"/>
/// behavior on top of the existing tables.
/// </description></item>
/// </list>
/// <para>
/// Both modes share the same lifecycle surface. The first call to
/// any lifecycle method (or the first access to
/// <see cref="StateMachine"/>) freezes the underlying definition
/// holder — subsequent <see cref="AddState"/> / etc. calls throw.
/// </para>
/// <para>
/// The builder is intentionally <em>not</em> <see cref="IDisposable"/>.
/// Lifecycle hooks attach to the state machine's own
/// <c>OnBeforeTransition</c> / <c>OnAfterTransition</c> delegate
/// fields; the dispatcher (with its timers and per-state handler
/// tables) is kept alive by those delegates and garbage-collected
/// when the state machine itself becomes unreachable.
/// </para>
/// </remarks>
public sealed class StateMachineBuilder<TState>
where TState : FiniteStateMachineState
{
private readonly TState m_stateMachine;
private readonly ISystemContext m_context;
private readonly MutableStateMachineDefinition? m_definition;
private readonly StateMachineDispatcher<TState> m_dispatcher;
internal StateMachineBuilder(
TState stateMachine,
ISystemContext context,
MutableStateMachineDefinition? definition,
NodeId deferredCreateNodeId = default,
QualifiedName deferredCreateBrowseName = default)
{
m_stateMachine = stateMachine
?? throw new ArgumentNullException(nameof(stateMachine));
m_context = context
?? throw new ArgumentNullException(nameof(context));
m_definition = definition;
m_deferredCreateNodeId = deferredCreateNodeId;
m_deferredCreateBrowseName = deferredCreateBrowseName;
TimeProvider timeProvider =
((context as ServerSystemContext)?.Server as ITimeProviderProvider)?.TimeProvider
?? TimeProvider.System;
m_dispatcher = new StateMachineDispatcher<TState>(stateMachine, context, timeProvider);
}
private readonly NodeId m_deferredCreateNodeId;
private readonly QualifiedName m_deferredCreateBrowseName;
/// <summary>
/// The underlying state machine. Reading this property freezes
/// the definition holder (if any).
/// </summary>
public TState StateMachine
{
get
{
FreezeDefinition();
return m_stateMachine;
}
}
/// <summary>
/// Adds a state to the machine. Definition-mode only — the
/// underlying state machine must be a
/// <see cref="FluentFiniteStateMachineState"/>.
/// </summary>
/// <param name="id">The numeric state id.</param>
/// <param name="browseName">The browse name.</param>
/// <param name="isInitial">Whether this is the initial state.</param>
/// <exception cref="ArgumentException">
/// <paramref name="browseName"/> is null/empty or
/// <paramref name="id"/> is already declared.</exception>
/// <exception cref="InvalidOperationException">
/// The builder is in lifecycle mode (no definition), the
/// definition has been frozen, or another state was already
/// marked initial.</exception>
public StateMachineBuilder<TState> AddState(
uint id,
string browseName,
bool isInitial = false)
{
MutableStateMachineDefinition def = RequireDefinition();
def.EnsureNotFrozen();
if (string.IsNullOrEmpty(browseName))
{
throw new ArgumentException("Browse name must not be empty.",
nameof(browseName));
}
foreach (StateMachineStateDefinition existing in def.States)
{
if (existing.Id == id)
{
throw new ArgumentException(
$"State id {id} is already declared.", nameof(id));
}
}
def.States.Add(new StateMachineStateDefinition(id, browseName, isInitial));
if (isInitial)
{
if (def.InitialStateId.HasValue && def.InitialStateId.Value != id)
{
throw new InvalidOperationException(
"Only one state may be marked as the initial state.");
}
def.InitialStateId = id;
}
def.Version++;
return this;
}
/// <summary>
/// Adds a transition between two states. Definition-mode only.
/// </summary>
/// <param name="id">The numeric transition id.</param>
/// <param name="browseName">The browse name.</param>
/// <param name="from">The from-state id.</param>
/// <param name="to">The to-state id.</param>
/// <param name="hasEffect">Whether the transition fires a
/// <c>TransitionEventType</c>.</param>
/// <exception cref="ArgumentException">
/// <paramref name="browseName"/> is null/empty or
/// <paramref name="id"/> is already declared.</exception>
public StateMachineBuilder<TState> AddTransition(
uint id,
string browseName,
uint from,
uint to,
bool hasEffect = true)
{
MutableStateMachineDefinition def = RequireDefinition();
def.EnsureNotFrozen();
if (string.IsNullOrEmpty(browseName))
{
throw new ArgumentException("Browse name must not be empty.",
nameof(browseName));
}
foreach (StateMachineTransitionDefinition existing in def.Transitions)
{
if (existing.Id == id)
{
throw new ArgumentException(
$"Transition id {id} is already declared.", nameof(id));
}
}
def.Transitions.Add(new StateMachineTransitionDefinition(
id, browseName, from, to, hasEffect));
def.Version++;
return this;
}
/// <summary>
/// Adds a cause-to-transition mapping. When the method with
/// the given cause id is invoked while the machine is in the
/// given from-state, the named transition fires.
/// Definition-mode only.
/// </summary>
public StateMachineBuilder<TState> OnCause(
uint causeId,
uint from,
uint transition)
{
MutableStateMachineDefinition def = RequireDefinition();
def.EnsureNotFrozen();
def.CauseMappings.Add(new StateMachineCauseMapping(
causeId, from, transition));
def.Version++;
return this;
}
/// <summary>
/// Designates the namespace URI that qualifies the state and
/// transition numeric NodeIds. Defaults to the standard UA
/// namespace. Definition-mode only.
/// </summary>
/// <exception cref="ArgumentException">
/// <paramref name="namespaceUri"/> is null or empty.</exception>
public StateMachineBuilder<TState> UseElementNamespace(string namespaceUri)
{
MutableStateMachineDefinition def = RequireDefinition();
def.EnsureNotFrozen();
if (string.IsNullOrEmpty(namespaceUri))
{
throw new ArgumentException("Namespace URI must not be empty.",
nameof(namespaceUri));
}
def.ElementNamespaceUri = namespaceUri;
def.Version++;
return this;
}
/// <summary>
/// Sets the initial state — applied immediately via
/// <see cref="FiniteStateMachineState.SetState"/>. Does not
/// invoke lifecycle handlers (initial state assignment is not
/// a transition).
/// </summary>
public StateMachineBuilder<TState> WithInitialState(uint stateId)
{
FreezeDefinition();
ValidateDefinitionStateExists(stateId);
m_stateMachine.SetState(m_context, stateId);
return this;
}
/// <summary>
/// Registers a handler invoked when the state machine enters
/// the given state. Order within a single transition is
/// <c>OnExitState(from) → OnTransition(from, to) → OnEnterState(to)</c>.
/// </summary>
public StateMachineBuilder<TState> OnEnterState(
uint stateId,
Action<ISystemContext, TState> handler)
{
if (handler is null)
{
throw new ArgumentNullException(nameof(handler));
}
FreezeDefinition();
m_dispatcher.AddEnterStateHandler(stateId, handler);
return this;
}
/// <summary>
/// Async overload of <see cref="OnEnterState"/>. The handler
/// is invoked fire-and-forget on the thread pool from the sync
/// transition path, so the handler runs on a fully-async path
/// (no <c>GetAwaiter().GetResult()</c> / <c>Wait()</c> /
/// <c>Result</c>) and never blocks the transition. Exceptions
/// are captured and logged.
/// </summary>
#pragma warning disable RCS1047 // 'Async' suffix marks the registered callback as async, not this registration method.
public StateMachineBuilder<TState> OnEnterStateAsync(
uint stateId,
Func<ISystemContext, TState, CancellationToken, System.Threading.Tasks.ValueTask> handler)
#pragma warning restore RCS1047
{
if (handler is null)
{
throw new ArgumentNullException(nameof(handler));
}
FreezeDefinition();
m_dispatcher.AddEnterStateHandlerAsync(stateId, handler);
return this;
}
/// <summary>
/// Registers a handler invoked when the state machine leaves
/// the given state.
/// </summary>
public StateMachineBuilder<TState> OnExitState(
uint stateId,
Action<ISystemContext, TState> handler)
{
if (handler is null)
{
throw new ArgumentNullException(nameof(handler));
}
FreezeDefinition();
m_dispatcher.AddExitStateHandler(stateId, handler);
return this;
}
/// <summary>
/// Async overload of <see cref="OnExitState"/>. See
/// <see cref="OnEnterStateAsync"/> for invocation semantics.
/// </summary>
#pragma warning disable RCS1047 // 'Async' suffix marks the registered callback as async, not this registration method.
public StateMachineBuilder<TState> OnExitStateAsync(
uint stateId,
Func<ISystemContext, TState, CancellationToken, System.Threading.Tasks.ValueTask> handler)
#pragma warning restore RCS1047
{
if (handler is null)
{
throw new ArgumentNullException(nameof(handler));
}
FreezeDefinition();
m_dispatcher.AddExitStateHandlerAsync(stateId, handler);
return this;
}
/// <summary>
/// Registers a generic transition observer invoked after every
/// completed transition. Receives the resolved <c>from</c> and
/// <c>to</c> state ids (extracted from <c>LastState</c> /
/// <c>CurrentState</c>).
/// </summary>
public StateMachineBuilder<TState> OnTransition(
Action<ISystemContext, TState, uint, uint> handler)
{
if (handler is null)
{
throw new ArgumentNullException(nameof(handler));
}
FreezeDefinition();
m_dispatcher.AddTransitionObserver(handler);
return this;
}
/// <summary>
/// Async overload of <see cref="OnTransition"/>. See
/// <see cref="OnEnterStateAsync"/> for invocation semantics.
/// </summary>
#pragma warning disable RCS1047 // 'Async' suffix marks the registered callback as async, not this registration method.
public StateMachineBuilder<TState> OnTransitionAsync(
Func<ISystemContext, TState, uint, uint, CancellationToken, System.Threading.Tasks.ValueTask> handler)
#pragma warning restore RCS1047
{
if (handler is null)
{
throw new ArgumentNullException(nameof(handler));
}
FreezeDefinition();
m_dispatcher.AddTransitionObserverAsync(handler);
return this;
}
/// <summary>
/// Registers a pre-transition guard. Returning a non-Good
/// <see cref="ServiceResult"/> cancels the transition. The
/// builder guards run BEFORE any pre-existing
/// <c>OnBeforeTransition</c> delegate the state machine had
/// when the builder adopted it.
/// </summary>
public StateMachineBuilder<TState> OnBeforeTransition(
Func<ISystemContext, TState, uint, uint, ServiceResult> guard)
{
if (guard is null)
{
throw new ArgumentNullException(nameof(guard));
}
FreezeDefinition();
m_dispatcher.AddBeforeTransitionGuard(guard);
return this;
}
/// <summary>
/// Adds a transition-scoped guard. The predicate fires only
/// when the inbound transition matches
/// <paramref name="transitionId"/>; returning <c>false</c>
/// vetoes the transition with
/// <see cref="StatusCodes.BadUserAccessDenied"/>. Guards
/// registered via the <c>When*</c> family compose with
/// <see cref="OnBeforeTransition"/> in registration order —
/// the first failing guard wins.
/// </summary>
public StateMachineBuilder<TState> WhenTransition(
uint transitionId,
Func<ISystemContext, TState, bool> predicate)
{
return WhenTransition(transitionId, predicate, StatusCodes.BadUserAccessDenied);
}
/// <summary>
/// Overload of <see cref="WhenTransition(uint, Func{ISystemContext, TState, bool})"/>
/// that lets the caller customize the deny status returned
/// when the predicate evaluates to <c>false</c>.
/// </summary>
public StateMachineBuilder<TState> WhenTransition(
uint transitionId,
Func<ISystemContext, TState, bool> predicate,
ServiceResult denyStatus)
{
if (predicate is null)
{
throw new ArgumentNullException(nameof(predicate));
}
FreezeDefinition();
m_dispatcher.AddBeforeTransitionGuard((ctx, sm, tid, cid) =>
{
if (tid != transitionId)
{
return ServiceResult.Good;
}
return predicate(ctx, sm) ? ServiceResult.Good : denyStatus;
});
return this;
}
/// <summary>
/// Adds a cause-scoped guard. The predicate fires only when
/// the inbound cause id matches <paramref name="causeId"/>.
/// Useful for permission checks on specific method-driven
/// transitions (e.g. <c>Acknowledge</c>, <c>Suspend</c>).
/// </summary>
public StateMachineBuilder<TState> WhenCause(
uint causeId,
Func<ISystemContext, TState, bool> predicate)
{
return WhenCause(causeId, predicate, StatusCodes.BadUserAccessDenied);
}
/// <summary>
/// Overload of <see cref="WhenCause(uint, Func{ISystemContext, TState, bool})"/>
/// with a caller-supplied deny status.
/// </summary>
public StateMachineBuilder<TState> WhenCause(
uint causeId,
Func<ISystemContext, TState, bool> predicate,
ServiceResult denyStatus)
{
if (predicate is null)
{
throw new ArgumentNullException(nameof(predicate));
}
FreezeDefinition();
m_dispatcher.AddBeforeTransitionGuard((ctx, sm, tid, cid) =>
{
if (cid != causeId)
{
return ServiceResult.Good;
}
return predicate(ctx, sm) ? ServiceResult.Good : denyStatus;
});
return this;
}
/// <summary>
/// Adds an enter-state-scoped guard. The predicate fires only
/// when the inbound transition would put the machine into
/// <paramref name="toStateId"/>. Definition-mode only — the
/// builder uses its own transition table to resolve the
/// to-state from the transition id.
/// </summary>
/// <exception cref="InvalidOperationException">
/// The builder is in lifecycle mode (no definition).
/// </exception>
public StateMachineBuilder<TState> WhenEnter(
uint toStateId,
Func<ISystemContext, TState, bool> predicate)
{
return WhenEnter(toStateId, predicate, StatusCodes.BadUserAccessDenied);
}
/// <summary>
/// Overload of <see cref="WhenEnter(uint, Func{ISystemContext, TState, bool})"/>
/// with a caller-supplied deny status.
/// </summary>
public StateMachineBuilder<TState> WhenEnter(
uint toStateId,
Func<ISystemContext, TState, bool> predicate,
ServiceResult denyStatus)
{
if (predicate is null)
{
throw new ArgumentNullException(nameof(predicate));
}
if (m_definition == null)
{
throw new InvalidOperationException(
"WhenEnter is only available in definition mode. "
+ "Use WhenTransition with the relevant transition id "
+ "in lifecycle mode.");
}
FreezeDefinition();
// Snapshot the matching transitions at registration time;
// the definition is frozen so this list is stable.
HashSet<uint> matchingTransitions = [];
foreach (StateMachineTransitionDefinition t in m_definition.Transitions)
{
if (t.ToStateId == toStateId)
{
matchingTransitions.Add(t.Id);
}
}
m_dispatcher.AddBeforeTransitionGuard((ctx, sm, tid, cid) =>
{
if (!matchingTransitions.Contains(tid))
{
return ServiceResult.Good;
}
return predicate(ctx, sm) ? ServiceResult.Good : denyStatus;
});
return this;
}
/// <summary>
/// Adds an exit-state-scoped guard. The predicate fires only
/// when the inbound transition would leave
/// <paramref name="fromStateId"/>. Works in both definition
/// and lifecycle modes — the dispatcher captures the current
/// state in <c>DispatchBefore</c> and matches against
/// <paramref name="fromStateId"/>.
/// </summary>
public StateMachineBuilder<TState> WhenExit(
uint fromStateId,
Func<ISystemContext, TState, bool> predicate)
{
return WhenExit(fromStateId, predicate, StatusCodes.BadUserAccessDenied);
}
/// <summary>
/// Overload of <see cref="WhenExit(uint, Func{ISystemContext, TState, bool})"/>
/// with a caller-supplied deny status.
/// </summary>
public StateMachineBuilder<TState> WhenExit(
uint fromStateId,
Func<ISystemContext, TState, bool> predicate,
ServiceResult denyStatus)
{
if (predicate is null)
{
throw new ArgumentNullException(nameof(predicate));
}
FreezeDefinition();
m_dispatcher.AddBeforeTransitionGuard((ctx, sm, tid, cid) =>
{
uint currentFrom = ExtractCurrentStateId(sm);
if (currentFrom != fromStateId)
{
return ServiceResult.Good;
}
return predicate(ctx, sm) ? ServiceResult.Good : denyStatus;
});
return this;
}
private static uint ExtractCurrentStateId(TState sm)
{
NodeId? id = sm.CurrentState?.Id?.Value;
if (id is null || id.Value.IsNull)
{
return 0;
}
return id.Value.TryGetValue(out uint stateId) ? stateId : 0;
}
/// <summary>
/// Attaches a sub-state-machine to the parent state identified
/// by <paramref name="parentStateId"/>. The sub-SM is added
/// as a <c>HasSubStateMachine</c>-referenced child of the
/// parent FSM, configured through the nested
/// <paramref name="configure"/> builder, and managed by the
/// dispatcher lifecycle:
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item><description>
/// When the parent enters <paramref name="parentStateId"/>,
/// the child sub-SM's <see cref="FluentFiniteStateMachineState.IsSuspended"/>
/// is set to <c>false</c> and (unless
/// <paramref name="preserveOnReentry"/> is <c>true</c>) the
/// child is reset to its declared initial state.
/// </description></item>
/// <item><description>
/// When the parent exits <paramref name="parentStateId"/>,
/// the child is suspended — subsequent <c>DoTransition</c> /
/// <c>DoCause</c> calls on the child return
/// <see cref="StatusCodes.BadInvalidState"/> until the parent
/// re-enters the attached state.
/// </description></item>
/// </list>
/// <para>
/// Definition-mode only: the parent must be a
/// <see cref="FluentFiniteStateMachineState"/>. In lifecycle
/// mode (when adopting a stack-shipped or vendor FSM) the
/// sub-SM is already part of the type definition; observe it
/// through the client-side sub-SM accessors instead.
/// </para>
/// </remarks>
/// <param name="parentStateId">The parent state whose entry
/// activates the sub-SM.</param>
/// <param name="browseName">The sub-SM's browse name (becomes
/// the child node's BrowseName).</param>
/// <param name="configure">Nested builder action that defines
/// the sub-SM's states, transitions, and behavior.</param>
/// <param name="preserveOnReentry">When <c>true</c>, the
/// sub-SM retains its last state across parent re-entries;
/// otherwise it resets to its declared initial state.</param>
/// <exception cref="InvalidOperationException">
/// Definition-mode only — the builder must own a
/// <see cref="FluentFiniteStateMachineState"/>.
/// </exception>
public StateMachineBuilder<TState> WithSubStateMachine(
uint parentStateId,
QualifiedName browseName,
Action<StateMachineBuilder<FluentFiniteStateMachineState>> configure,
bool preserveOnReentry = false)
{
if (configure is null)
{
throw new ArgumentNullException(nameof(configure));
}
if (browseName.IsNull)
{
throw new ArgumentException(
"Browse name must not be null.", nameof(browseName));
}
if (m_definition == null)
{
throw new InvalidOperationException(
"WithSubStateMachine is only available in definition "
+ "mode (use StateMachineBuilder.Create). Lifecycle-mode "
+ "FSMs already declare their sub-state-machines as part "
+ "of the type definition.");
}
FreezeDefinition();
// Configure the sub-SM through a nested builder. The
// sub-SM's NodeId is derived from the parent's NodeId so
// it lives under the parent in the address space.
var subHolder = new MutableStateMachineDefinition();
var child =
FluentFiniteStateMachineState.CreateWithHolder(m_stateMachine, subHolder);
NodeId childNodeId = m_stateMachine.NodeId.IsNull
? new NodeId(Guid.NewGuid())
: ComposeChildNodeId(m_stateMachine.NodeId, browseName);
child.Create(
m_context,
childNodeId,
browseName,
new LocalizedText(browseName.Name ?? string.Empty),
true);
var childBuilder = new StateMachineBuilder<FluentFiniteStateMachineState>(
child, m_context, subHolder);
configure(childBuilder);
// Read StateMachine to freeze the child's definition and
// ensure it is fully constructed.
FluentFiniteStateMachineState materializedChild = childBuilder.StateMachine;
// Set the reference type BEFORE adding to the parent so
// AddChild does not default it to HasComponent. Then add.
materializedChild.ReferenceTypeId = ReferenceTypeIds.HasSubStateMachine;
m_stateMachine.AddChild(materializedChild);
// Initial state of the sub-SM is "suspended" unless the
// parent already starts in the attached state.
uint initialChildStateId = subHolder.InitialStateId ?? 0;
bool parentInAttachedState =
ExtractCurrentStateId(m_stateMachine) == parentStateId;
materializedChild.IsSuspended = !parentInAttachedState;
if (parentInAttachedState && initialChildStateId != 0)
{
// Set the child to its initial state immediately —
// the parent's OnEnterState handler isn't fired on
// WithInitialState (it goes through SetState, not
// DoTransition), so we must seed the child here.
materializedChild.SetState(m_context, initialChildStateId);
}
// Wire the lifecycle hooks on the parent.
m_dispatcher.AddEnterStateHandler(parentStateId, (ctx, parent) =>
{
if (!preserveOnReentry && initialChildStateId != 0)
{
materializedChild.IsSuspended = false;
materializedChild.SetState(ctx, initialChildStateId);
}
else
{
materializedChild.IsSuspended = false;
}
});
m_dispatcher.AddExitStateHandler(parentStateId, (ctx, parent) => materializedChild.IsSuspended = true);
return this;
}
private static NodeId ComposeChildNodeId(NodeId parentNodeId, QualifiedName browseName)
{
// Derive a deterministic child NodeId from the parent
// and the child's browse name so multiple
// WithSubStateMachine calls produce stable, distinct ids.
string suffix = browseName.Name ?? "Child";
if (parentNodeId.TryGetValue(out string parentStr))
{
return new NodeId(parentStr + "_" + suffix, parentNodeId.NamespaceIndex);
}
return new NodeId(parentNodeId + "_" + suffix, parentNodeId.NamespaceIndex);
}
/// <summary>
/// Binds an inbound method call on the given method node to
/// the cause-processing pipeline. The cause id is derived from
/// <paramref name="methodNodeId"/>'s numeric identifier (the
/// OPC UA convention). The cause-to-transition mapping must
/// already exist on the state machine — either via
/// <see cref="OnCause"/> (definition mode) or hardcoded in a
/// stack/vendor subclass.
/// </summary>
/// <param name="methodNodeId">The method node whose
/// <c>OnCallMethod2</c> handler will be installed.</param>
/// <exception cref="ArgumentException">
/// <paramref name="methodNodeId"/> is null, has a non-numeric
/// identifier, or could not be resolved to a child method
/// of the state machine.
/// </exception>
public StateMachineBuilder<TState> WithCause(NodeId methodNodeId)
{
FreezeDefinition();
if (methodNodeId.IsNull)
{
throw new ArgumentException(
"Method node id must not be null.",
nameof(methodNodeId));
}
if (!methodNodeId.TryGetValue(out uint causeId))
{
throw new ArgumentException(
"Method node id must have a numeric identifier; " +
$"got '{methodNodeId.IdentifierAsString}' " +
$"(type {methodNodeId.IdType}).",
nameof(methodNodeId));
}
MethodState? method = FindMethodInTree(m_stateMachine, methodNodeId) ??
throw new ArgumentException(
$"Method '{methodNodeId}' not found among the state " +
"machine's children.",
nameof(methodNodeId));
m_dispatcher.InstallCauseMethod(method, causeId);
return this;
}
/// <summary>
/// Registers an automatic transition that fires after the
/// machine has been in <paramref name="fromStateId"/> for at
/// least <paramref name="timeout"/>. The timer is armed on
/// entry into <paramref name="fromStateId"/> and cancelled on
/// exit; multiple entries re-arm a fresh timer.
/// </summary>
/// <param name="fromStateId">The state whose entry arms the
/// timer.</param>
/// <param name="timeout">The duration to wait before firing
/// the transition.</param>
/// <param name="transitionId">The transition to fire when the
/// timer expires — driven via
/// <see cref="FiniteStateMachineState.DoTransition"/>.</param>
/// <param name="causeId">The cause id reported in the audit
/// event (0 if no human-meaningful cause).</param>
/// <exception cref="ArgumentOutOfRangeException">
/// <paramref name="timeout"/> is zero or negative.</exception>
public StateMachineBuilder<TState> WithTimedTransition(
uint fromStateId,
TimeSpan timeout,
uint transitionId,
uint causeId = 0)
{
if (timeout <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(timeout),
"Timeout must be positive.");
}
FreezeDefinition();
ValidateTimedTransition(fromStateId, transitionId);
m_dispatcher.AddTimedTransition(
fromStateId, timeout, transitionId, causeId);
return this;
}
private void ValidateTimedTransition(uint fromStateId, uint transitionId)
{
// Validate only when the builder owns the definition. For
// lifecycle-mode (For()) we can't reach the FSM's protected
// tables — the caller is responsible.
if (m_definition == null)
{
return;
}
bool fromOk = false;
foreach (StateMachineStateDefinition s in m_definition.States)
{
if (s.Id == fromStateId)
{
fromOk = true;
break;
}
}
if (!fromOk)
{
throw new InvalidOperationException(
"Timed transition references unknown from-state " +
$"{fromStateId}.");
}
bool transitionOk = false;
foreach (StateMachineTransitionDefinition t in m_definition.Transitions)
{
if (t.Id == transitionId)
{
if (t.FromStateId != fromStateId)
{
throw new InvalidOperationException(
$"Timed transition {transitionId} declares " +
$"from-state {t.FromStateId} but was " +
$"registered with fromStateId={fromStateId}.");
}
transitionOk = true;
break;
}
}
if (!transitionOk)
{
throw new InvalidOperationException(
"Timed transition references unknown transition " +
$"{transitionId}.");
}
}
/// <summary>
/// Escape hatch — invokes <paramref name="configure"/> with the
/// underlying state machine. Useful for setting properties or
/// invoking generated-type setters that the builder does not
/// surface directly.
/// </summary>
public StateMachineBuilder<TState> ConfigureStateMachine(
Action<TState> configure)
{
if (configure is null)
{
throw new ArgumentNullException(nameof(configure));
}
FreezeDefinition();
configure(m_stateMachine);
return this;
}
private MutableStateMachineDefinition RequireDefinition()
{
if (m_definition == null)
{
throw new InvalidOperationException(
"Definition-mode methods (AddState / AddTransition / " +
"OnCause / UseElementNamespace) are only available " +
"when the builder owns the state-machine definition. " +
"Use StateMachineBuilder.Create(...) to create a " +
"FluentFiniteStateMachineState, or stick to lifecycle " +
"methods (OnEnterState / WithCause / ...) when " +
"wrapping an existing state machine via " +
"StateMachineBuilder.For(...).");
}
return m_definition;
}
private void FreezeDefinition()
{
if (m_definition != null && !m_definition.Frozen)
{
ValidateDefinition(m_definition);
m_definition.Frozen = true;
m_definition.Version++;
// For definition-mode builders the FSM has not yet been
// added to the address space — we defer
// NodeState.Create() until freeze so callers can use
// UseElementNamespace() to override the element
// namespace URI before OnAfterCreate caches the
// namespace index.
if (!m_deferredCreateNodeId.IsNull && !m_deferredCreateBrowseName.IsNull)
{
m_stateMachine.Create(
m_context,
m_deferredCreateNodeId,
m_deferredCreateBrowseName,
new LocalizedText(m_deferredCreateBrowseName.Name),
true);
}
}
}
private void ValidateDefinitionStateExists(uint stateId)
{
if (m_definition == null)
{
return;
}
foreach (StateMachineStateDefinition s in m_definition.States)
{
if (s.Id == stateId)
{
return;
}
}
throw new InvalidOperationException(
$"Initial state {stateId} is not declared. Call AddState " +
"to declare it before WithInitialState.");
}
private static void ValidateDefinition(MutableStateMachineDefinition def)
{
if (def.States.Count == 0)
{
throw new InvalidOperationException(
"At least one state must be declared before the " +
"definition is frozen.");
}
var stateIds = new HashSet<uint>();
foreach (StateMachineStateDefinition s in def.States)
{
stateIds.Add(s.Id);
}
foreach (StateMachineTransitionDefinition t in def.Transitions)
{
if (!stateIds.Contains(t.FromStateId))
{
throw new InvalidOperationException(
$"Transition '{t.BrowseName}' references unknown " +
$"from-state {t.FromStateId}.");
}
if (!stateIds.Contains(t.ToStateId))
{
throw new InvalidOperationException(
$"Transition '{t.BrowseName}' references unknown " +
$"to-state {t.ToStateId}.");
}
}
var transitionIds = new HashSet<uint>();
foreach (StateMachineTransitionDefinition t in def.Transitions)
{
transitionIds.Add(t.Id);
}
foreach (StateMachineCauseMapping c in def.CauseMappings)
{
if (!stateIds.Contains(c.FromStateId))
{
throw new InvalidOperationException(
"Cause mapping references unknown from-state " +
$"{c.FromStateId}.");
}
if (!transitionIds.Contains(c.TransitionId))
{
throw new InvalidOperationException(
"Cause mapping references unknown transition " +
$"{c.TransitionId}.");
}
}
// Detect duplicate cause mappings — only the first match
// would ever fire, so silently shadowed mappings are an
// authoring bug.
var seenCauseKeys = new HashSet<(uint, uint)>();
foreach (StateMachineCauseMapping c in def.CauseMappings)
{
if (!seenCauseKeys.Add((c.CauseId, c.FromStateId)))
{
throw new InvalidOperationException(
"Duplicate cause mapping for causeId=" +
$"{c.CauseId}, fromState={c.FromStateId} — " +
"subsequent mappings would be silently " +
"shadowed by the first.");
}
}
}
private static MethodState? FindMethodInTree(
NodeState root, NodeId methodNodeId)
{
var children = new List<BaseInstanceState>();
root.GetChildren(null!, children);
foreach (BaseInstanceState child in children)
{
if (child is MethodState method &&
method.NodeId == methodNodeId)
{
return method;
}
MethodState? nested = FindMethodInTree(child, methodNodeId);
if (nested != null)
{
return nested;
}
}
return null;
}
}
/// <summary>
/// Static facade for <see cref="StateMachineBuilder{TState}"/>.
/// </summary>
public static class StateMachineBuilder
{
/// <summary>
/// Creates a new <see cref="FluentFiniteStateMachineState"/>
/// in the address space and returns a builder ready for
/// definition-mode chaining
/// (<see cref="StateMachineBuilder{TState}.AddState"/> /
/// <see cref="StateMachineBuilder{TState}.AddTransition"/> /
/// <see cref="StateMachineBuilder{TState}.OnCause"/>).
/// </summary>
/// <param name="parent">The parent node (may be <c>null</c>
/// for stand-alone machines).</param>
/// <param name="context">The system context used for the
/// underlying <see cref="NodeState.Create(ISystemContext, NodeId, QualifiedName, LocalizedText, bool)"/> call.</param>
/// <param name="nodeId">The state machine's NodeId.</param>
/// <param name="browseName">The state machine's browse
/// name.</param>
/// <exception cref="ArgumentException">
/// <paramref name="nodeId"/> or <paramref name="browseName"/>
/// is null.</exception>
public static StateMachineBuilder<FluentFiniteStateMachineState> Create(
NodeState? parent,
ISystemContext context,
NodeId nodeId,
QualifiedName browseName)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
if (nodeId.IsNull)
{
throw new ArgumentException(
"NodeId must not be null.", nameof(nodeId));
}
if (browseName.IsNull)
{
throw new ArgumentException(
"Browse name must not be null.", nameof(browseName));
}
var holder = new MutableStateMachineDefinition();
var sm =
FluentFiniteStateMachineState.CreateWithHolder(parent!, holder);
// Defer sm.Create() until FreezeDefinition() so that
// UseElementNamespace() called between Create() and the
// first lifecycle method takes effect — OnAfterCreate
// caches ElementNamespaceIndex from ElementNamespaceUri.
return new StateMachineBuilder<FluentFiniteStateMachineState>(
sm, context, holder,
deferredCreateNodeId: nodeId,
deferredCreateBrowseName: browseName);
}
/// <summary>
/// Adopts an existing, already-Created
/// <see cref="FiniteStateMachineState"/> instance and returns a
/// builder ready for lifecycle-mode chaining. Definition
/// methods (<see cref="StateMachineBuilder{TState}.AddState"/>
/// etc.) throw <see cref="InvalidOperationException"/> in this
/// mode.
/// </summary>
/// <typeparam name="TState">The concrete
/// <see cref="FiniteStateMachineState"/> subclass of the
/// existing state machine.</typeparam>
/// <param name="stateMachine">The pre-existing state machine
/// (e.g. a <c>ShelvedStateMachineState</c> or a
/// generator-emitted vendor type).</param>
/// <param name="context">The system context used to drive
/// auto-transitions and method dispatch.</param>
public static StateMachineBuilder<TState> For<TState>(
TState stateMachine,
ISystemContext context)
where TState : FiniteStateMachineState
{
if (stateMachine is null)
{
throw new ArgumentNullException(nameof(stateMachine));
}
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
return new StateMachineBuilder<TState>(stateMachine, context, null);
}
}
/// <summary>
/// Internal dispatcher that wires builder-collected handlers onto
/// the state machine's <c>OnBefore/AfterTransition</c> delegates,
/// resolves the <c>from</c>/<c>to</c> numeric state ids from
/// <c>LastState</c>/<c>CurrentState</c>, and owns the per-state
/// timed-transition registry.
/// </summary>
/// <typeparam name="TState">The concrete
/// <see cref="FiniteStateMachineState"/> subclass the dispatcher
/// wraps.</typeparam>
internal sealed class StateMachineDispatcher<TState>
where TState : FiniteStateMachineState
{
private readonly TState m_stateMachine;
private readonly ISystemContext m_context;
private readonly TimeProvider m_timeProvider;
private readonly Dictionary<uint, List<Action<ISystemContext, TState>>> m_enterHandlers = [];
private readonly Dictionary<uint, List<Action<ISystemContext, TState>>> m_exitHandlers = [];
private readonly List<Action<ISystemContext, TState, uint, uint>> m_transitionObservers = [];
private readonly List<Func<ISystemContext, TState, uint, uint, ServiceResult>> m_guards = [];
private readonly Dictionary<uint, TimedTransitionEntry> m_timedTransitions = [];
/// <summary>
/// Async observer counterparts. Scheduled fire-and-forget from
/// the sync transition path (which itself remains sync since
/// FiniteStateMachineState.DoTransition is sync). Each invocation
/// runs on the thread pool with ConfigureAwait(false), so no
/// sync-over-async wait occurs anywhere; exceptions are
/// captured and logged via Debug.WriteLine in line with the
/// existing SafeInvoke pattern.
/// </summary>
private readonly Dictionary<uint,
List<Func<ISystemContext, TState, CancellationToken, System.Threading.Tasks.ValueTask>>>
m_enterHandlersAsync = [];
private readonly Dictionary<uint,
List<Func<ISystemContext, TState, CancellationToken, System.Threading.Tasks.ValueTask>>>
m_exitHandlersAsync = [];
private readonly
List<Func<ISystemContext, TState, uint, uint, CancellationToken, System.Threading.Tasks.ValueTask>>
m_transitionObserversAsync = [];
private readonly StateMachineTransitionHandler? m_originalBefore;
private readonly StateMachineTransitionHandler? m_originalAfter;
private bool m_installed;
/// <summary>
/// Thread-keyed pending-from-state so concurrent transitions
/// do not clobber each other's stash. OPC UA service requests
/// can hit a node manager concurrently; each DoTransition runs
/// synchronously on one thread (DispatchBefore -> state update
/// -> DispatchAfter all on the same thread), so a thread-id
/// keyed slot is sufficient.
/// </summary>
private readonly System.Collections.Concurrent.ConcurrentDictionary<int, uint>
m_pendingFromByThread = new();
public StateMachineDispatcher(TState stateMachine, ISystemContext context)
: this(stateMachine, context, TimeProvider.System)
{
}
public StateMachineDispatcher(
TState stateMachine,
ISystemContext context,
TimeProvider timeProvider)
{
m_stateMachine = stateMachine;
m_context = context;
m_timeProvider = timeProvider ?? TimeProvider.System;
m_originalBefore = stateMachine.OnBeforeTransition;
m_originalAfter = stateMachine.OnAfterTransition;
}
public void AddEnterStateHandler(
uint stateId, Action<ISystemContext, TState> handler)
{
if (!m_enterHandlers.TryGetValue(stateId, out List<Action<ISystemContext, TState>>? list))
{
list = [];
m_enterHandlers[stateId] = list;
}
list.Add(handler);
EnsureInstalled();
}
public void AddExitStateHandler(
uint stateId, Action<ISystemContext, TState> handler)
{
if (!m_exitHandlers.TryGetValue(stateId, out List<Action<ISystemContext, TState>>? list))
{
list = [];
m_exitHandlers[stateId] = list;
}
list.Add(handler);
EnsureInstalled();
}
public void AddTransitionObserver(
Action<ISystemContext, TState, uint, uint> handler)
{
m_transitionObservers.Add(handler);
EnsureInstalled();
}
#pragma warning disable RCS1047 // 'Async' suffix marks the registered callback as async, not this registration method.
public void AddEnterStateHandlerAsync(
uint stateId,
Func<ISystemContext, TState, CancellationToken, System.Threading.Tasks.ValueTask> handler)
#pragma warning restore RCS1047
{
if (!m_enterHandlersAsync.TryGetValue(stateId,
out List<Func<ISystemContext, TState, CancellationToken, System.Threading.Tasks.ValueTask>>? list))
{
list = [];
m_enterHandlersAsync[stateId] = list;
}
list.Add(handler);
EnsureInstalled();
}
#pragma warning disable RCS1047 // 'Async' suffix marks the registered callback as async, not this registration method.
public void AddExitStateHandlerAsync(
uint stateId,
Func<ISystemContext, TState, CancellationToken, System.Threading.Tasks.ValueTask> handler)
#pragma warning restore RCS1047
{
if (!m_exitHandlersAsync.TryGetValue(stateId,
out List<Func<ISystemContext, TState, CancellationToken, System.Threading.Tasks.ValueTask>>? list))
{
list = [];
m_exitHandlersAsync[stateId] = list;
}
list.Add(handler);
EnsureInstalled();
}
#pragma warning disable RCS1047 // 'Async' suffix marks the registered callback as async, not this registration method.
public void AddTransitionObserverAsync(
Func<ISystemContext, TState, uint, uint, CancellationToken, System.Threading.Tasks.ValueTask> handler)
#pragma warning restore RCS1047
{
m_transitionObserversAsync.Add(handler);
EnsureInstalled();
}
public void AddBeforeTransitionGuard(
Func<ISystemContext, TState, uint, uint, ServiceResult> guard)
{
m_guards.Add(guard);
EnsureInstalled();
}
public void InstallCauseMethod(MethodState method, uint causeId)
{
method.OnCallMethod2 = (ctx, m, objectId, inputs, outputs) =>
m_stateMachine.DoCause(ctx, m, causeId, inputs, outputs);
}
public void AddTimedTransition(
uint fromStateId,
TimeSpan timeout,
uint transitionId,
uint causeId)
{
// Cancel any pre-existing entry for the same from-state
// before replacing it, so a re-call of WithTimedTransition
// doesn't leak a stale timer or fire it with stale ids.
CancelTimer(fromStateId);
var entry = new TimedTransitionEntry(timeout, transitionId, causeId);
m_timedTransitions[fromStateId] = entry;
EnsureInstalled();
// If the machine is already in the matching from-state,
// arm the timer now — otherwise the initial-state entry
// (applied via SetState, which doesn't fire OnAfterTransition)
// would never arm a timer.
uint currentState = ExtractStateId(
m_stateMachine.CurrentState?.Id?.Value);
if (currentState == fromStateId)
{
ArmTimer(fromStateId, entry);
}
}
private void EnsureInstalled()
{
if (m_installed)
{
return;
}
m_installed = true;
m_stateMachine.OnBeforeTransition = DispatchBefore;
m_stateMachine.OnAfterTransition = DispatchAfter;
}
private ServiceResult DispatchBefore(
ISystemContext context,
StateMachineState machine,
uint transitionId,
uint causeId,
ArrayOf<Variant> inputArguments,
List<Variant>? outputArguments)
{
// Capture from-state BEFORE the framework updates
// CurrentState. Stored per-thread to support concurrent
// transitions safely (each DoTransition call is fully
// synchronous on one thread).
m_pendingFromByThread[Environment.CurrentManagedThreadId] = ExtractStateId(
m_stateMachine.CurrentState?.Id?.Value);
// Builder guards run before any pre-existing OnBefore.
foreach (Func<ISystemContext, TState, uint, uint, ServiceResult> g in m_guards)
{
ServiceResult r = g(context, m_stateMachine, transitionId, causeId);
if (ServiceResult.IsBad(r))
{
// Clear the per-thread stash on veto — DispatchAfter
// won't run, so leave-no-trace.
m_pendingFromByThread.TryRemove(
Environment.CurrentManagedThreadId, out _);
return r;
}
}
if (m_originalBefore != null)
{
ServiceResult r = m_originalBefore(
context, machine, transitionId, causeId,
inputArguments, outputArguments);
if (ServiceResult.IsBad(r))
{
m_pendingFromByThread.TryRemove(
Environment.CurrentManagedThreadId, out _);
}
return r;
}
return ServiceResult.Good;
}
private ServiceResult DispatchAfter(
ISystemContext context,
StateMachineState machine,
uint transitionId,
uint causeId,
ArrayOf<Variant> inputArguments,
List<Variant>? outputArguments)
{
// Pre-existing OnAfter runs first so its side effects (e.g.
// stack-shipped state-change reporting) complete before the
// builder's observers see the new state.
ServiceResult? originalResult = null;
if (m_originalAfter != null)
{
originalResult = m_originalAfter(
context, machine, transitionId, causeId,
inputArguments, outputArguments);
}
m_pendingFromByThread.TryRemove(
Environment.CurrentManagedThreadId, out uint from);
uint to = ExtractStateId(m_stateMachine.CurrentState?.Id?.Value);
// Exit handlers fire first, then transition observers, then
// enter handlers (standard reactive-FSM lifecycle order).
if (from != 0 && m_exitHandlers.TryGetValue(from, out List<Action<ISystemContext, TState>>? exitList))
{
foreach (Action<ISystemContext, TState> h in exitList)
{
SafeInvoke(() => h(context, m_stateMachine));
}
}
if (from != 0 && m_exitHandlersAsync.TryGetValue(from,
out List<Func<ISystemContext, TState, CancellationToken, System.Threading.Tasks.ValueTask>>? exitListAsync))
{
foreach (Func<ISystemContext, TState, CancellationToken, System.Threading.Tasks.ValueTask> h in exitListAsync)
{
Schedule(h, context, fromAt: from, toAt: 0);
}
}
foreach (Action<ISystemContext, TState, uint, uint> observer in m_transitionObservers)
{
SafeInvoke(() => observer(context, m_stateMachine, from, to));
}
foreach (Func<ISystemContext, TState, uint, uint, CancellationToken, System.Threading.Tasks.ValueTask>
observer in m_transitionObserversAsync)
{
Schedule(observer, context, from, to);
}
if (to != 0 && m_enterHandlers.TryGetValue(to, out List<Action<ISystemContext, TState>>? enterList))
{
foreach (Action<ISystemContext, TState> h in enterList)
{
SafeInvoke(() => h(context, m_stateMachine));
}
}
if (to != 0 && m_enterHandlersAsync.TryGetValue(to,
out List<Func<ISystemContext, TState, CancellationToken, System.Threading.Tasks.ValueTask>>? enterListAsync))
{
foreach (Func<ISystemContext, TState, CancellationToken, System.Threading.Tasks.ValueTask> h in enterListAsync)
{
Schedule(h, context, fromAt: 0, toAt: to);
}
}
// Cancel timer armed on the from state (if any), then arm
// the timer for the to state.
if (from != 0)
{
CancelTimer(from);
}
if (to != 0 && m_timedTransitions.TryGetValue(to, out TimedTransitionEntry? armEntry))
{
ArmTimer(to, armEntry);
}
return originalResult ?? ServiceResult.Good;
}
private void ArmTimer(uint stateId, TimedTransitionEntry entry)
{
var cts = new CancellationTokenSource();
entry.Cts = cts;
entry.Timer = m_timeProvider.CreateTimer(_ =>
{
if (cts.IsCancellationRequested)
{
return;
}
try
{
m_stateMachine.DoTransition(
m_context,
entry.TransitionId,
entry.CauseId,
default,
[]);
}
catch (Exception ex)
{
Debug.WriteLine(
"StateMachineBuilder timed transition for state " +
$"{stateId} threw: {ex}");
}
}, null, entry.Timeout, Timeout.InfiniteTimeSpan);
}
private void CancelTimer(uint stateId)
{
if (m_timedTransitions.TryGetValue(stateId, out TimedTransitionEntry? entry))
{
entry.Cts?.Cancel();
entry.Timer?.Dispose();
entry.Cts = null;
entry.Timer = null;
}
}
private static uint ExtractStateId(NodeId? nodeId)
{
if (!nodeId.HasValue || nodeId.Value.IsNull)
{
return 0;
}
return nodeId.Value.TryGetValue(out uint id) ? id : 0;
}
private static void SafeInvoke(Action action)
{
try
{
action();
}
catch (Exception ex)
{
Debug.WriteLine(
$"StateMachineBuilder lifecycle handler threw: {ex}");
}
}
private void Schedule(
Func<ISystemContext, TState, CancellationToken, System.Threading.Tasks.ValueTask> handler,
ISystemContext context,
uint fromAt,
uint toAt)
{
_ = System.Threading.Tasks.Task.Run(async () =>
{
try
{
await handler(context, m_stateMachine, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
Debug.WriteLine(
"StateMachineBuilder async lifecycle handler " +
$"(from={fromAt}, to={toAt}) threw: {ex}");
}
});
}
private void Schedule(
Func<ISystemContext, TState, uint, uint, CancellationToken, System.Threading.Tasks.ValueTask> handler,
ISystemContext context,
uint from,
uint to)
{
_ = System.Threading.Tasks.Task.Run(async () =>
{
try
{
await handler(context, m_stateMachine, from, to, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
Debug.WriteLine(
"StateMachineBuilder async transition observer " +
$"(from={from}, to={to}) threw: {ex}");
}
});
}
private sealed class TimedTransitionEntry
{
public TimedTransitionEntry(TimeSpan timeout, uint transitionId, uint causeId)
{
Timeout = timeout;
TransitionId = transitionId;
CauseId = causeId;
}
public TimeSpan Timeout { get; }
public uint TransitionId { get; }
public uint CauseId { get; }
public CancellationTokenSource? Cts { get; set; }
public ITimer? Timer { get; set; }
}
}
}