Skip to main content

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

  • Current is a simple property backed by a plain field — one read, no lock.
  • TryFire is a switch expression over (Current, trigger). For dense enum ranges the JIT emits a jump table.
  • Fire calls OnExit before writing _state, then OnEnter after. 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-concurrentConcurrent
State field typeTStatelong
Current readdirect fieldVolatile.Read
Transitiondirect field writeCAS loop
GuardsSupportedNot generated (ZSM0003)
Hook timingbefore/after field writeafter successful CAS

Inspecting the generated output

Visual Studio / Rider: Expand the project's AnalyzersZeroAlloc.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

IDTrigger
ZSM0001A state appears as From but nothing leads to it and it is not InitialState
ZSM0002A state appears as To (or is InitialState) but has no outgoing transitions and is not marked [Terminal]
ZSM0003A trigger appears in exactly one transition while other triggers appear multiple times (possible typo)
ZSM0004Concurrent = true on a partial struct