Source Generator
ZeroAlloc.StateMachine uses a Roslyn IIncrementalGenerator to emit a companion partial file for every annotated type. This page explains exactly what is emitted, why, and how to inspect it.
What triggers generation
The generator activates on any partial class or partial struct decorated with [StateMachine]. It reads the [Transition] and [Terminal] attributes from the same type, validates the graph, and writes one output file per annotated type.
Non-concurrent output
Given this input:
public enum State { Idle, Pending, Done }
public enum Trigger { Submit, Pay }
[StateMachine(InitialState = nameof(State.Idle))]
[Transition<State, Trigger>(From = State.Idle, On = Trigger.Submit, To = State.Pending)]
[Transition<State, Trigger>(From = State.Pending, On = Trigger.Pay, To = State.Done, When = true)]
[Terminal<State>(State = State.Done)]
public partial class OrderMachine { }
The generator emits:
// <auto-generated />
#nullable enable
partial class OrderMachine
{
// Plain field — no synchronisation overhead
private global::State _state = global::State.Idle;
/// <summary>Current state of the machine.</summary>
public global::State Current => _state;
/// <summary>
/// Attempt to fire <paramref name="trigger"/> from the current state.
/// Returns <c>true</c> if the transition occurred; <c>false</c> if no matching
/// transition or a guard rejected it.
/// </summary>
public bool TryFire(global::Trigger trigger)
{
return (Current, trigger) switch
{
(global::State.Idle, global::Trigger.Submit)
=> Fire(global::State.Idle, global::State.Pending, trigger),
(global::State.Pending, global::Trigger.Pay)
when GuardPay(global::State.Pending, global::Trigger.Pay)
=> Fire(global::State.Pending, global::State.Done, trigger),
_ => false
};
}
private bool Fire(global::State from, global::State to, global::Trigger trigger)
{
OnExit(from, trigger);
_state = to;
OnEnter(to, from);
return true;
}
private void OnExit(global::State state, global::Trigger trigger)
{
switch (state)
{
case global::State.Idle: OnExitIdle(trigger); break;
case global::State.Pending: OnExitPending(trigger); break;
}
}
private void OnEnter(global::State state, global::State from)
{
switch (state)
{
case global::State.Pending: OnEnterPending(from); break;
case global::State.Done: OnEnterDone(from); break;
}
}
// ── Partial hooks — implement what you need, leave the rest ─────────────
/// <summary>Guard for the Pending → Done transition on Pay.
/// Return <c>false</c> to block the transition.</summary>
private partial bool GuardPay(global::State from, global::Trigger on);
/// <summary>Called before leaving <c>Idle</c>.</summary>
partial void OnExitIdle(global::Trigger on);
/// <summary>Called before leaving <c>Pending</c>.</summary>
partial void OnExitPending(global::Trigger on);
/// <summary>Called after entering <c>Pending</c>.</summary>
partial void OnEnterPending(global::State from);
/// <summary>Called after entering <c>Done</c>.</summary>
partial void OnEnterDone(global::State from);
}
Key points
Currentis a simple property backed by a plain field — one read, no lock.TryFireis aswitchexpression over(Current, trigger). For dense enum ranges the JIT emits a jump table.FirecallsOnExitbefore writing_state, thenOnEnterafter. Order is always: exit old state → write field → enter new state.- Guard stubs use
private partial bool. The generator emits the defining declaration; you must supply the implementing declaration. If you omit it, the compiler emits CS8795. - Entry/exit stubs use
partial void— unimplemented stubs compile away entirely, zero overhead.
Concurrent output
When Concurrent = true:
[StateMachine(InitialState = nameof(State.Idle), Concurrent = true)]
[Transition<State, Trigger>(From = State.Idle, On = Trigger.Start, To = State.Running)]
[Transition<State, Trigger>(From = State.Running, On = Trigger.Stop, To = State.Idle)]
public partial class WorkerMachine { }
The generator emits:
// <auto-generated />
#nullable enable
partial class WorkerMachine
{
// volatile long — satisfies Interlocked.CompareExchange without boxing
private long _state = (long)global::State.Idle;
/// <summary>Current state (thread-safe read via Volatile).</summary>
public global::State Current
=> (global::State)System.Threading.Volatile.Read(ref _state);
/// <summary>
/// Attempt to fire <paramref name="trigger"/> atomically via CompareExchange.
/// Spins until the CAS succeeds or no matching transition exists.
/// Entry and exit hooks fire after the CAS and are not synchronized.
/// </summary>
public bool TryFire(global::Trigger trigger)
{
while (true)
{
var current = (global::State)System.Threading.Volatile.Read(ref _state);
global::State? next = (current, trigger) switch
{
(global::State.Idle, global::Trigger.Start) => (global::State?)global::State.Running,
(global::State.Running, global::Trigger.Stop) => (global::State?)global::State.Idle,
_ => null
};
if (next is null) return false;
if (System.Threading.Interlocked.CompareExchange(
ref _state, (long)next.Value, (long)current) == (long)current)
{
OnExit(current, trigger);
OnEnter(next.Value, current);
return true;
}
// Lost CAS race — retry with fresh current
}
}
// OnExit / OnEnter dispatchers same as non-concurrent ...
// ── Partial hooks — no guards in concurrent mode (TOCTOU risk) ──────────
partial void OnExitIdle(global::Trigger on);
partial void OnExitRunning(global::Trigger on);
partial void OnEnterRunning(global::State from);
partial void OnEnterIdle(global::State from);
}
Concurrent vs. non-concurrent differences
| Non-concurrent | Concurrent | |
|---|---|---|
| State field type | TState | long |
Current read | direct field | Volatile.Read |
| Transition | direct field write | CAS loop |
| Guards | Supported | Not generated (ZSM0003) |
| Hook timing | before/after field write | after successful CAS |
Inspecting the generated output
Visual Studio / Rider: Expand the project's Analyzers → ZeroAlloc.StateMachine.Generator node in Solution Explorer. Each generated file appears there.
Go to Definition: Place your cursor on TryFire, Current, or any partial stub and press F12 (VS) or Ctrl+B (Rider).
Command line:
dotnet build
# find obj/Debug/{tfm}/generated/ZeroAlloc.StateMachine.Generator/
Diagnostics emitted during generation
| ID | Trigger |
|---|---|
| ZSM0001 | A state appears as From but nothing leads to it and it is not InitialState |
| ZSM0002 | A state appears as To (or is InitialState) but has no outgoing transitions and is not marked [Terminal] |
| ZSM0003 | A trigger appears in exactly one transition while other triggers appear multiple times (possible typo) |
| ZSM0004 | Concurrent = true on a partial struct |