ActiveStateMachine 1.9.0
dotnet add package ActiveStateMachine --version 1.9.0
NuGet\Install-Package ActiveStateMachine -Version 1.9.0
<PackageReference Include="ActiveStateMachine" Version="1.9.0" />
<PackageVersion Include="ActiveStateMachine" Version="1.9.0" />
<PackageReference Include="ActiveStateMachine" />
paket add ActiveStateMachine --version 1.9.0
#r "nuget: ActiveStateMachine, 1.9.0"
#:package ActiveStateMachine@1.9.0
#addin nuget:?package=ActiveStateMachine&version=1.9.0
#tool nuget:?package=ActiveStateMachine&version=1.9.0
ActiveStateMachine
A modern .NET Roslyn source generator that turns a plain partial class into a fully thread-safe,
lock-free Active Object around a
Stateless state machine.
You describe what your states, triggers and public methods are. The generator writes all of the concurrency plumbing for you at compile time — the mailbox, the worker, the message types, the constructor and disposal — with zero reflection and zero runtime dependencies beyond Stateless itself.
Two flavours are available, selected by the attribute you apply:
[ActiveObjectAsync]— the modern implementation: aSystem.Threading.Channels.Channelmailbox drained by a workerTask, trigger methods that returnTask, andIAsyncDisposable.[ActiveObjectSync]— the classic implementation: aBlockingCollection<T>drained by a dedicated backgroundThread, trigger methods that returnvoidand block the caller until the message has been processed, andIDisposable. Noasync/await, noTask-returning API, no Channels.
Table of contents
- Why
- Features
- How it works
- Getting started
- Usage
- The Phone example
- What the generator emits
- API reference
- Parameterized triggers
- Concurrency & lifetime semantics
- Diagnostics
- Project layout
- Build, run & test
- Requirements
- Limitations & roadmap
- Contributing
- License
- Acknowledgements
Why
Stateless is an excellent, lightweight state
machine library — but a StateMachine<TState, TTrigger> is not thread-safe. If two threads fire
triggers concurrently you get races and corrupted state. The classic fix is the Active Object
pattern: put the machine behind a mailbox and let a single dedicated worker apply every transition
sequentially, so callers never touch the machine directly.
Writing that mailbox by hand is repetitive and easy to get wrong: a Channel<T>, a base message
record with a TaskCompletionSource, one message type per trigger, a worker loop that dispatches and
routes exceptions back to the awaiter, TriggerWithParameters caching, graceful shutdown… every
Active Object looks the same.
ActiveStateMachine generates all of it. Your source stays declarative:
[ActiveObjectAsync(typeof(PhoneState), typeof(PhoneTrigger))]
public partial class PhoneActiveObject : IAsyncDisposable
{
[StateTrigger(PhoneTrigger.CallDialed)]
public partial Task DialAsync(string number);
partial void ConfigureStateMachine() { /* wire up Stateless here */ }
}
…and the compiler fills in the rest. Swap [ActiveObjectAsync] for [ActiveObjectSync] (and
Task→void, IAsyncDisposable→IDisposable) to get the blocking thread-based variant instead.
Features
- ⚙️ Incremental source generator (
IIncrementalGenerator) — fast, cached, IDE-friendly. - 🧵 Lock-free thread safety — a single worker serializes every transition, so the state machine is only ever touched from one thread. No locks.
- 🔀 Two flavours, same API shape — pick modern async (
Channel+Task) or classic blocking (Thread+BlockingCollection) per class, just by changing the attribute. - ⏱️ Completion-aware calls — async triggers return a
Taskthat completes when the transition has actually been applied; sync triggers block until then. Either way, exceptions surface to the caller, not the worker. - 🎯 Parameterized triggers — 0–3 parameters map automatically to Stateless
TriggerWithParameters<…>, with the trigger objects cached for you. - 🧹 Correct disposal —
IAsyncDisposable(async) orIDisposable(sync) that drains in-flight messages and stops the worker cleanly. - 🚦 Compile-time diagnostics — misuse (non-
partial, wrong return type, too many parameters…) becomes a build error, not a runtime surprise. - 📦 Analyzer-only package — the marker attributes are injected into your compilation by the
generator, so there is no attributes assembly to reference; the only dependency is
Stateless. - 🪶 No reflection, no runtime magic — everything is plain C# you can read in the generated file.
How it works
At build time the generator first injects the [ActiveObjectAsync], [ActiveObjectSync] and
[StateTrigger] marker attributes into your compilation (via RegisterPostInitializationOutput), so
you never reference an attributes assembly. It then discovers each marked class, reads the
state/trigger enums and every [StateTrigger] method, and emits a {ClassName}.g.cs partial that
completes the class. At runtime, a call flows through the mailbox to the single worker (the async
variant shown here):
sequenceDiagram
participant Caller as Caller (any thread)
participant Method as DialAsync() (generated)
participant Queue as Channel mailbox
participant Worker as Worker Task (single reader)
participant SM as Stateless machine
Caller->>Method: await DialAsync("555-0199")
Method->>Queue: TryWrite(DialAsyncMessage)
Method-->>Caller: Task (pending)
Worker->>Queue: ReadAllAsync()
Queue-->>Worker: DialAsyncMessage
Worker->>SM: _machine.Fire(TriggerDialAsync, "555-0199")
SM-->>Worker: transition applied
Worker-->>Caller: Tcs.SetResult() → Task completes
Because the mailbox is created with SingleReader = true and only one worker Task ever reads it,
the state machine is only ever touched from one thread — no locks required.
The sync variant is the same idea without the async machinery — a dedicated Thread drains a
BlockingCollection, and the caller blocks on a TaskCompletionSource (used purely as the
wait/exception-marshalling primitive) until its message is processed:
sequenceDiagram
participant Caller as Caller (any thread)
participant Method as Dial() (generated, void)
participant Queue as BlockingCollection
participant Worker as Worker Thread
participant SM as Stateless machine
Caller->>Method: Dial("555-0199")
Method->>Queue: Add(DialMessage)
Method->>Method: Tcs.Task.GetAwaiter().GetResult() (blocks)
Worker->>Queue: GetConsumingEnumerable()
Queue-->>Worker: DialMessage
Worker->>SM: _machine.Fire(TriggerDial, "555-0199")
SM-->>Worker: transition applied
Worker-->>Caller: Tcs.SetResult() → Dial() returns
Getting started
Install the package. It is analyzer-only — the marker attributes are injected straight into your
compilation by the generator, so there is no attributes assembly to reference. The single reference
also flows the one runtime dependency, Stateless:
dotnet add package ActiveStateMachine
or in your .csproj:
<ItemGroup>
<PackageReference Include="ActiveStateMachine" Version="1.2.0" />
</ItemGroup>
Your consuming project should target .NET 5 or later (the async generated code uses
System.Threading.Channels and the non-generic TaskCompletionSource; the sync code uses
BlockingCollection<T> and TaskCompletionSource<bool>). The examples target net8.0.
Building from source instead? Reference the generator as an analyzer and add
Statelessyourself. There is no attributes project to reference — the generator supplies the attributes:<ProjectReference Include="path/to/src/ActiveStateMachine.Generators/ActiveStateMachine.Generators.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" /> <PackageReference Include="Stateless" Version="5.15.0" />
Maintainers: see PUBLISHING.md for how the package is built and pushed to nuget.org.
Usage
Writing an Active Object is three steps. This walkthrough uses the async flavour; the sync differences follow.
1. Declare the enums and mark the class. Apply [ActiveObjectAsync(stateType, triggerType)] to a
partial class.
using ActiveStateMachine.Attributes;
public enum DoorState { Open, Closed, Locked }
public enum DoorTrigger { OpenDoor, CloseDoor, Lock, Unlock }
[ActiveObjectAsync(typeof(DoorState), typeof(DoorTrigger))]
public partial class Door : IAsyncDisposable
{
}
2. Declare the public trigger API. Each public entry point is a partial method returning
Task, decorated with [StateTrigger(…)]. Zero parameters → a plain trigger; one parameter →
a TriggerWithParameters<T>.
[StateTrigger(DoorTrigger.OpenDoor)]
public partial Task OpenAsync();
[StateTrigger(DoorTrigger.Lock)]
public partial Task LockAsync(string pinCode); // parameterized trigger
3. Configure the machine. Implement partial void ConfigureStateMachine(). Inside it you have
access to the generated _machine field and, for each parameterized trigger, a cached
Trigger{MethodName} field (e.g. TriggerLockAsync) you can pass to OnEntryFrom:
partial void ConfigureStateMachine()
{
_machine.Configure(DoorState.Open)
.Permit(DoorTrigger.CloseDoor, DoorState.Closed);
_machine.Configure(DoorState.Closed)
.Permit(DoorTrigger.OpenDoor, DoorState.Open)
.Permit(DoorTrigger.Lock, DoorState.Locked);
_machine.Configure(DoorState.Locked)
.OnEntryFrom(TriggerLockAsync, pin => Console.WriteLine($"Locked with PIN {pin}"))
.Permit(DoorTrigger.Unlock, DoorState.Closed);
}
That's it — the generator supplies the constructor, the mailbox, the worker, the bodies of
OpenAsync/LockAsync, and DisposeAsync. Consume it like any async object:
await using var door = new Door(DoorState.Open);
await door.CloseAsync();
await door.LockAsync("1234");
Note on the constructor: the generator emits
public {ClassName}({StateType} initialState, string? name = null), so you pass the starting state (and optionally a name — see Naming) when you construct the object. Do not declare your own constructor with the same signature.
Naming
Every Active Object has a name. You can set a default on the attribute, and/or override it per instance in the constructor:
[ActiveObjectAsync(typeof(DoorState), typeof(DoorTrigger), Name = "Door")] // default for the type
public partial class Door : IAsyncDisposable { /* … */ }
var a = new Door(DoorState.Open); // name = "Door" (from the attribute)
var b = new Door(DoorState.Open, "front-door"); // name = "front-door" (constructor wins)
var c = new Door(DoorState.Open); // with no attribute Name, defaults to "Door" (the class name)
Resolution order: constructor argument → attribute Name → class name. The current name
is exposed as a public string Name property. It is also surfaced where it helps most while
debugging:
- Sync (
[ActiveObjectSync]) — it becomes the workerThread.Name, so each Active Object's thread is identifiable in the debugger's Threads window and in call stacks. - Async (
[ActiveObjectAsync]) — it is published to apublic static AsyncLocal<string?>CurrentActiveObjectNameon the generated class, set on the worker task. BecauseAsyncLocalflows with the execution context, any code run by the Active Object (Stateless entry/exit actions, awaited continuations) can readMyActiveObject.CurrentActiveObjectName.Valueto discover which instance it is executing inside.
The sync flavour
For the classic blocking variant, change three things: use [ActiveObjectSync], make the trigger
methods partial void, and implement IDisposable instead of IAsyncDisposable. The parameterized
trigger field drops the Async suffix to match the method name (TriggerLock):
[ActiveObjectSync(typeof(DoorState), typeof(DoorTrigger))]
public partial class Door : IDisposable
{
[StateTrigger(DoorTrigger.OpenDoor)]
public partial void Open();
[StateTrigger(DoorTrigger.Lock)]
public partial void Lock(string pinCode); // -> OnEntryFrom(TriggerLock, …)
partial void ConfigureStateMachine() { /* identical Stateless configuration */ }
}
// Calls are synchronous and block until the transition has been applied:
using var door = new Door(DoorState.Open);
door.Close();
door.Lock("1234");
The idle tick
A sync Active Object can also drive a trigger itself whenever its mailbox goes quiet, by setting
IdleTimeoutMilliseconds on that trigger:
[StateTrigger(DoorTrigger.SelfCheck, IdleTimeoutMilliseconds = 200)]
private partial void SelfCheck();
The worker then waits for a message with that timeout instead of blocking indefinitely, and fires the trigger each time the wait expires. Two consequences are worth understanding:
- It costs no timer, task or thread. The worker's existing mailbox wait is the clock, so the work runs on the worker like every other transition — serialized with your commands, never concurrent with them.
- It is idle-driven, not a fixed cadence. The tick fires when nothing has arrived for the interval, so inbound traffic suppresses it entirely. That is what makes it suited to a periodic self-check such as polling a remote peer for liveness: while messages are already flowing there is nothing to check, and the traffic is itself the evidence.
The trigger method stays callable, which is the easy way to test the tick's effect without waiting for
one. A tick has no caller, so an exception thrown while processing it has no Task to fault; it is
handed to an optional hook instead, and the worker keeps running either way:
partial void OnIdleTickFailed(Exception exception) => _logger.Warn(exception, "self-check failed");
Constraints, each a build error: the method must be parameterless
(ASM007), only one trigger per class may set it (ASM008), and it is
supported on [ActiveObjectSync] only (ASM009) — the async worker would need a
linked cancellation per iteration and does not implement it yet. A class that does not opt in emits
exactly the same code as before.
The Phone example
The repository ships the canonical Stateless "telephone" example, re-expressed as an Active Object twice — once per flavour, producing identical console output:
samples/ActiveStateMachine.Example.Async— the async version shown below.samples/ActiveStateMachine.Example.Sync— the blocking thread-based version ([ActiveObjectSync],partial voidtriggers,IDisposable, noawait).
The full async source is PhoneActiveObject.cs:
using ActiveStateMachine.Attributes;
namespace ActiveStateMachine.Example.Async;
public enum PhoneState { OffHook, Ringing, Connected, OnHold }
public enum PhoneTrigger { CallDialed, HungUp, CallConnected, PlacedOnHold, TakenOffHold, LeftMessage }
[ActiveObjectAsync(typeof(PhoneState), typeof(PhoneTrigger))]
public partial class PhoneActiveObject : IAsyncDisposable
{
/// <summary>Current state. Only safe to read between awaited calls.</summary>
public PhoneState State => _machine.State;
[StateTrigger(PhoneTrigger.CallDialed)]
public partial Task DialAsync(string number); // parameterized
[StateTrigger(PhoneTrigger.HungUp)]
public partial Task HangUpAsync();
[StateTrigger(PhoneTrigger.CallConnected)]
public partial Task ConnectCallAsync();
[StateTrigger(PhoneTrigger.PlacedOnHold)]
public partial Task PutOnHoldAsync();
[StateTrigger(PhoneTrigger.TakenOffHold)]
public partial Task TakeOffHoldAsync();
partial void ConfigureStateMachine()
{
_machine.Configure(PhoneState.OffHook)
.Permit(PhoneTrigger.CallDialed, PhoneState.Ringing);
_machine.Configure(PhoneState.Ringing)
.OnEntryFrom(TriggerDialAsync, number => Console.WriteLine($"[Phone] Ringing... Dialing number: {number}"))
.Permit(PhoneTrigger.HungUp, PhoneState.OffHook)
.Permit(PhoneTrigger.CallConnected, PhoneState.Connected);
_machine.Configure(PhoneState.Connected)
.OnEntry(() => Console.WriteLine("[Phone] Call Connected!"))
.OnExit(() => Console.WriteLine("[Phone] Call Ended!"))
.Permit(PhoneTrigger.LeftMessage, PhoneState.OffHook)
.Permit(PhoneTrigger.HungUp, PhoneState.OffHook)
.Permit(PhoneTrigger.PlacedOnHold, PhoneState.OnHold);
_machine.Configure(PhoneState.OnHold)
.SubstateOf(PhoneState.Connected)
.OnEntry(() => Console.WriteLine("[Phone] Call Placed On Hold."))
.OnExit(() => Console.WriteLine("[Phone] Call Resumed."))
.Permit(PhoneTrigger.TakenOffHold, PhoneState.Connected)
.Permit(PhoneTrigger.HungUp, PhoneState.OffHook);
_machine.OnUnhandledTrigger((state, trigger) =>
Console.WriteLine($"[Warning] Invalid trigger '{trigger}' in state '{state}' ignored."));
_machine.OnTransitioned(t =>
Console.WriteLine($"[State Change] {t.Source} -> {t.Destination}"));
}
}
Driving it (Program.cs):
await using var phone = new PhoneActiveObject(PhoneState.OffHook);
await phone.DialAsync("555-0199");
await phone.ConnectCallAsync();
await phone.PutOnHoldAsync();
await phone.TakeOffHoldAsync();
await phone.DialAsync("123-4567"); // invalid while Connected — ignored, worker survives
await phone.HangUpAsync();
The sync driver is the same script with the
awaits removed and Thread.Sleep in place of Task.Delay — e.g. phone.Dial("555-0199");. Both
produce:
Starting Modern .NET Active Object Telephone Simulation...
[State Change] OffHook -> Ringing
[Phone] Ringing... Dialing number: 555-0199
[State Change] Ringing -> Connected
[Phone] Call Connected!
[State Change] Connected -> OnHold
[Phone] Call Placed On Hold.
[Phone] Call Resumed.
[State Change] OnHold -> Connected
[Warning] Invalid trigger 'CallDialed' in state 'Connected' ignored.
[Phone] Call Ended!
[State Change] Connected -> OffHook
Simulation Complete.
What the generator emits
Alongside the marker attributes (emitted once per compilation as
ActiveStateMachine.Attributes.g.cs), the generator produces one {ClassName}.g.cs per Active
Object. For the async class above it emits PhoneActiveObject.g.cs, completing the partial class.
Abbreviated (the sync flavour is the same shape with a BlockingCollection + Thread instead of
a Channel + Task, void trigger bodies that block on Tcs.Task.GetAwaiter().GetResult(), and a
synchronous Dispose()):
// <auto-generated/>
partial class PhoneActiveObject
{
private readonly StateMachine<PhoneState, PhoneTrigger> _machine;
private readonly Channel<PhoneActiveObjectMessage> _messageQueue;
private readonly Task _workerTask;
private readonly CancellationTokenSource _cts;
// Cached parameterized trigger (one field per parameterized method):
private readonly StateMachine<PhoneState, PhoneTrigger>.TriggerWithParameters<string> TriggerDialAsync;
// Base message: carries a TaskCompletionSource and knows how to apply itself.
private abstract record PhoneActiveObjectMessage
{
public TaskCompletionSource Tcs { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task Completion => Tcs.Task;
public abstract void Execute(PhoneActiveObject ao);
}
// One concrete message per trigger method:
private sealed record DialAsyncMessage(string number) : PhoneActiveObjectMessage
{
public override void Execute(PhoneActiveObject ao) => ao._machine.Fire(ao.TriggerDialAsync, number);
}
private sealed record HangUpAsyncMessage() : PhoneActiveObjectMessage
{
public override void Execute(PhoneActiveObject ao) => ao._machine.Fire(PhoneTrigger.HungUp);
}
// … ConnectCallAsyncMessage, PutOnHoldAsyncMessage, TakeOffHoldAsyncMessage …
public PhoneActiveObject(PhoneState initialState)
{
_machine = new StateMachine<PhoneState, PhoneTrigger>(initialState);
TriggerDialAsync = _machine.SetTriggerParameters<string>(PhoneTrigger.CallDialed);
ConfigureStateMachine(); // your configuration
_messageQueue = Channel.CreateUnbounded<PhoneActiveObjectMessage>(
new UnboundedChannelOptions { SingleReader = true });
_cts = new CancellationTokenSource();
_workerTask = Task.Run(ProcessMailboxAsync); // the single worker
}
private async Task ProcessMailboxAsync()
{
try
{
await foreach (var msg in _messageQueue.Reader.ReadAllAsync(_cts.Token))
{
try { msg.Execute(this); msg.Tcs.SetResult(); }
catch (Exception ex) { msg.Tcs.SetException(ex); } // faults route to the caller
}
}
catch (OperationCanceledException) { }
}
public partial Task DialAsync(string number)
{
var __msg = new DialAsyncMessage(number);
return _messageQueue.Writer.TryWrite(__msg)
? __msg.Completion
: Task.FromException(new InvalidOperationException("Active Object message queue is closed."));
}
// … bodies for the other trigger methods …
public async ValueTask DisposeAsync()
{
_messageQueue.Writer.TryComplete(); // no more messages accepted
try { await _workerTask; } // drain everything already queued
catch (OperationCanceledException) { }
_cts.Cancel();
_cts.Dispose();
GC.SuppressFinalize(this);
}
}
Want to see the real thing? Build with
-p:EmitCompilerGeneratedFiles=trueand look underobj/**/generated/…/PhoneActiveObject.g.cs.
API reference
[ActiveObjectAsync(Type stateType, Type triggerType)] / [ActiveObjectSync(Type stateType, Type triggerType)]
Applied to a partial class. Declares the state and trigger enum types for the machine, and selects
the implementation flavour. A class uses exactly one of the two.
[ActiveObjectAsync] |
[ActiveObjectSync] |
|
|---|---|---|
| Mailbox | System.Threading.Channels.Channel |
System.Collections.Concurrent.BlockingCollection<T> |
| Worker | a Task (Task.Run) |
a dedicated background Thread |
| Trigger method return | Task |
void (blocks until processed) |
| Disposal | IAsyncDisposable (DisposeAsync) |
IDisposable (Dispose) |
Uses async/await |
yes | no |
| Name is surfaced as | static AsyncLocal<string?> CurrentActiveObjectName |
the worker Thread.Name |
Both take the same constructor arguments and expose the same properties:
| Member | Description |
|---|---|
ctor initialState |
The starting state passed to the StateMachine. |
ctor name (optional) |
Per-instance name; overrides the attribute's Name. Defaults to the attribute Name, then the class name. See Naming. |
StateType (attribute) |
The enum used for states. |
TriggerType (attribute) |
The enum used for triggers. |
Name (attribute, optional) |
Default name for the type. |
Name (instance property) |
The resolved name of this instance. |
[StateTrigger(object trigger)]
Applied to a partial trigger method (partial Task … on an async class, partial void … on a sync
class). trigger is the enum value itself, e.g. PhoneTrigger.CallDialed, so a renamed member is
caught at compile time and updated by refactoring tools. The parameter is typed object only because
C# does not permit System.Enum as an attribute parameter type (CS0181); the generator requires an
enum value and reports ASM005 for anything else.
| Method shape (async / sync) | Maps to |
|---|---|
partial Task Foo() / partial void Foo() |
_machine.Fire(trigger) |
… Foo(T a) |
_machine.Fire(TriggerFoo, a) |
… Foo(T1 a, T2 b) |
_machine.Fire(TriggerFoo, a, b) |
… Foo(T1 a, T2 b, T3 c) |
_machine.Fire(TriggerFoo, a, b, c) |
Named arguments:
| Property | Default | Effect |
|---|---|---|
Wait |
true |
When false the method only enqueues and returns immediately; the caller neither blocks nor observes processing exceptions. Use it for triggers fired from another Active Object's worker thread, where blocking risks a cross-object deadlock. |
IdleTimeoutMilliseconds |
0 |
When positive, the worker also fires this trigger itself after that long with an empty mailbox — see The idle tick. Sync only. |
Generated members available to your code
Inside ConfigureStateMachine() (and any other member of the class) you can use:
_machine— theStateMachine<TState, TTrigger>to configure.Trigger{MethodName}— the cachedTriggerWithParameters<…>for each parameterized method (e.g.TriggerDialAsyncorTriggerDial), ready to pass toOnEntryFrom.Name— the resolved instance name (public string).CurrentActiveObjectName— (async only) thepublic static AsyncLocal<string?>carrying this Active Object's name on its worker context. See Naming.
Parameterized triggers
Stateless carries trigger arguments through TriggerWithParameters<…>. The generator creates and
caches one such object per parameterized trigger method and threads your method arguments straight
through to Fire. Up to three parameters are supported (Stateless's native maximum). A method
with more than three parameters is a compile error (ASM003).
[StateTrigger(OvenTrigger.SetProgram)]
public partial Task SetProgramAsync(int minutes, int celsius); // -> TriggerWithParameters<int, int>
Concurrency & lifetime semantics
Both flavours share the same guarantees; they differ only in how a caller observes completion
(awaiting a Task vs. blocking) and in the disposal interface.
- Serialization. Every trigger is applied by a single worker — one
Taskreading aSingleReaderchannel (async), or one dedicatedThreaddraining aBlockingCollection(sync). The state machine is never accessed concurrently — no locks, no races. - Completion. A trigger is "done" after the transition has been applied (including Stateless
entry/exit actions). The async method's
Taskcompletes at that point; the sync method returns at that point. Either way you get happens-before ordering. - Ordering. Messages are processed in the order they were enqueued. Firing an async burst without awaiting each call still applies them sequentially.
- Exceptions. If a transition throws (e.g. an invalid trigger with no
OnUnhandledTriggerhandler), the exception is routed to that call — a faultedTask(async) or thrown from the blocking call (sync, unwrapped viaGetAwaiter().GetResult()). The worker keeps running; later calls are unaffected. - Disposal.
DisposeAsync()/Dispose()stops accepting new messages, drains everything already queued, and stops the worker. Calls made after disposal fail withInvalidOperationException("Active Object message queue is closed.")(a faultedTaskfor async, a thrown exception for sync). - Reading state. The examples expose
public PhoneState State => _machine.State. Reading state is only guaranteed consistent between calls; reading it while a transition is in flight is a data race. Prefer completing a trigger, then reading.
Diagnostics
The generator reports build errors for misuse so problems surface at compile time:
| ID | Severity | Condition |
|---|---|---|
ASM001 |
Error | An Active Object attribute applied to a class that is not partial. |
ASM002 |
Error | [StateTrigger] on an [ActiveObjectAsync] class does not return System.Threading.Tasks.Task. |
ASM003 |
Error | [StateTrigger] method has more than 3 parameters. |
ASM004 |
Error | [StateTrigger] method is not partial. |
ASM005 |
Error | [StateTrigger] supplies no trigger value. |
ASM006 |
Error | [StateTrigger] on an [ActiveObjectSync] class does not return void. |
ASM007 |
Error | [StateTrigger] sets IdleTimeoutMilliseconds on a method with parameters. |
ASM008 |
Error | More than one [StateTrigger] in a class sets IdleTimeoutMilliseconds. |
ASM009 |
Error | [StateTrigger] sets IdleTimeoutMilliseconds on an [ActiveObjectAsync] class. |
Project layout
ActiveStateMachine.sln
├─ src/
│ └─ ActiveStateMachine.Generators/ netstandard2.0 — the IIncrementalGenerator + attributes (the whole package)
├─ samples/
│ ├─ ActiveStateMachine.Example.Async/ net8.0 — the Phone demo, async flavour ([ActiveObjectAsync])
│ └─ ActiveStateMachine.Example.Sync/ net8.0 — the Phone demo, sync flavour ([ActiveObjectSync])
└─ tests/
└─ ActiveStateMachine.Tests/ net8.0 — xUnit behavioral + generator-diagnostic tests
Inside the generator project:
| File | Responsibility |
|---|---|
ActiveObjectGenerator.cs |
The IIncrementalGenerator: post-init attributes, plus one pipeline per flavour (discover, validate, model). |
EmbeddedAttributes.cs |
The [ActiveObjectAsync]/[ActiveObjectSync]/[StateTrigger] source injected into each consuming compilation. |
AsyncEmitter.cs |
Renders the async {ClassName}.g.cs (Channel + Task + IAsyncDisposable). |
SyncEmitter.cs |
Renders the sync {ClassName}.g.cs (BlockingCollection + Thread + IDisposable). |
Model.cs |
Immutable, equatable model records used for incremental caching. |
Diagnostics.cs / DiagnosticInfo.cs |
Diagnostic descriptors and cache-safe reporting. |
EquatableArray.cs |
Structural-equality array wrapper for correct pipeline caching. |
Build, run & test
# Build everything
dotnet build ActiveStateMachine.sln
# Run the Phone demos
dotnet run --project samples/ActiveStateMachine.Example.Async
dotnet run --project samples/ActiveStateMachine.Example.Sync
# Run the test suite
dotnet test
The test project (tests/ActiveStateMachine.Tests) covers both
sides of the generator:
- Behavioral tests drive both generated
PhoneActiveObjects — happy-path transitions, parameterized triggers, serial ordering under concurrent load, graceful handling of invalid triggers, exceptions propagating to the caller, faulting/throwing after disposal, and a high-volume stress loop. - Diagnostic tests run the generator in-memory with
CSharpGeneratorDriverand assert that eachASMxxxrule fires (and that valid async and sync input produces clean output).
Requirements
| Version | |
|---|---|
| .NET SDK (to build) | 8.0+ |
| Consuming project TFM | .NET 5.0+ (for System.Threading.Channels and non-generic TaskCompletionSource) |
| Stateless | 5.15.0 (flowed automatically as the package's only dependency) |
| Generator TFM | netstandard2.0 |
| Roslyn | Microsoft.CodeAnalysis.CSharp 4.8.0 |
Limitations & roadmap
Current, intentional V1 scope:
- The generated constructor is fixed to
({StateType} initialState, string? name = null); a consuming class cannot declare its own constructor with that signature. - Trigger methods return
Task(async flavour) orvoid(sync flavour) — noTask<T>/ValueTaskresults yet. - The target class must be top-level (nested classes are not yet handled).
- Up to 3 trigger parameters (Stateless's native limit).
- The idle tick is
[ActiveObjectSync]only.
Ideas on the roadmap:
- Package as a single NuGet package. See PUBLISHING.md.
- Auto-emit the marker attributes into the consuming compilation for an analyzer-only package
(only
Statelessremains as a dependency). - An idle tick driven by the worker's own mailbox wait, with no extra timer or thread.
- The idle tick for
[ActiveObjectAsync](needs a linked cancellation per iteration). - Publish signed / source-linked builds via CI.
- Optional factory / parameterless-constructor patterns and a configurable initial state.
-
Task<T>results for triggers that produce a value. - Nested and generic host classes.
Contributing
Issues and pull requests are welcome. A good PR:
- Builds clean (
dotnet build— no new warnings). - Adds or updates tests under
tests/ActiveStateMachine.Tests(behavioral and/or diagnostic). - Keeps the generated code readable — if you change the emitter, include a regenerated sample in the PR description.
License
Released under the MIT License — see LICENSE.
Acknowledgements
- Stateless — the state machine library this builds upon, and the source of the telephone example.
- The .NET Roslyn team for
IIncrementalGeneratorandSystem.Threading.Channels.
Learn more about Target Frameworks and .NET Standard.
-
.NETStandard 2.0
- Stateless (>= 5.15.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
1.2.0: Active Objects can be named via the attribute (Name = "…") or the constructor. The sync worker Thread is named accordingly; the async worker publishes the name via a static AsyncLocal CurrentActiveObjectName. Analyzer-only package; ASM001-ASM006 diagnostics.