Skip to content

feat: timeout transitions + concurrent state parts (B3+B5)#27

Merged
MarcelRoozekrans merged 22 commits into
mainfrom
feat/timeout-and-concurrent-parts
May 23, 2026
Merged

feat: timeout transitions + concurrent state parts (B3+B5)#27
MarcelRoozekrans merged 22 commits into
mainfrom
feat/timeout-and-concurrent-parts

Conversation

@MarcelRoozekrans
Copy link
Copy Markdown
Contributor

Summary

Lands ZeroAlloc.StateMachine backlog items B3 + B5 as a pair:

  • B3 (timeout transitions): [Transition(... AfterMs = N)] emits a lazily-allocated System.Threading.Timer? per timed edge. Armed in the concurrent CAS-succeeded path on enter, disarmed on exit. Lazy allocation uses Interlocked.CompareExchange + dispose-of-loser to avoid a construction race. Requires Concurrent = true. Generated IDisposable cleans up timers.
  • B5 (concurrent state parts): [StateMachineGroup] + [StateMachinePart<TState, TTrigger>(Name, InitialState)] declare multiple independent CAS state fields in one class, each with its own TryFire<Name> / <Name>Current / per-part OnEnter<Name><State> hooks.

Eight new diagnostics (ZSM0012-ZSM0019) cover declaration mistakes.

Design doc: docs/plans/2026-05-22-timeout-and-concurrent-parts-design.md.
Plan: docs/plans/2026-05-22-timeout-and-concurrent-parts.md.

Test plan

  • Generator snapshot tests for timed edges (single + multiple)
  • Generator snapshot tests for groups (no timer + with timer)
  • Diagnostic tests for ZSM0012-ZSM0019
  • Runtime tests for timer arm/disarm/dispose
  • Runtime tests for per-part independence + timed edge inside a part
  • All existing v1.3 tests still pass (32/32 generator + 26/26 runtime)

Known follow-ups (non-blocking)

  • Timers do not arm at construction if InitialState is the source of a timed transition; documented as a caveat in timeout-transitions.md. Constructor-time arm emit deferred.
  • ZSM0002 / ZSM0003 reachability analyzers are not wired into the [StateMachineGroup] pipeline; per-part dead-state / single-use-trigger heuristics could land in a separate PR.

🤖 Generated with Claude Code

MarcelRoozekrans and others added 22 commits May 22, 2026 21:49
…+B5)

Brainstormed B3 (timeout transitions) and B5 (concurrent state parts)
together since their natural declaration sites overlap via the new
[Transition].Part discriminator. Locks the design before implementation:
AfterMs requires concurrent, one Timer field per timed edge with lazy
allocation, generated IDisposable, [StateMachineGroup] mutually
exclusive with [StateMachine], new diagnostics ZSM0012-ZSM0019.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rts (B3+B5)

19-task TDD plan walking through:
  - Attribute additions (Tasks 1-2)
  - Diagnostic descriptors ZSM0012-ZSM0019 (Task 3)
  - Generator model + parsing (Tasks 4-5, 9-10)
  - Diagnostic detection (Tasks 6, 8, 11)
  - Writer emit for timers + IDisposable (Task 7)
  - Writer emit for per-part CAS dispatch (Task 12)
  - Snapshot, diagnostic, and runtime tests (Tasks 13-17)
  - Docs + PR (Tasks 18-19)

Each task lists exact files, code, build/test commands, and a commit
message. Follows the same shape as the prior composite-states plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new optional properties on TransitionAttribute<TState, TTrigger>:

  - AfterMs (int): when > 0, generator emits a Timer that auto-fires
    On after the configured ms. Default 0 (no timer).
  - Part (string?): scopes the transition to a named [StateMachinePart]
    when the enclosing class is a [StateMachineGroup]. Default null.

Both default to "off" so existing v1.3 [Transition] declarations are
strictly additive. Generator wiring lands in subsequent commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…(B5 runtime types)

Two new public attribute types the generator (next commits) will pick
up to wire multiple concurrent state machines into one class:

  - StateMachineGroupAttribute — class-level marker; mutually exclusive
    with [StateMachine].
  - StateMachinePartAttribute<TState, TTrigger>(Name, InitialState) —
    declares one named CAS state field + TryFire<Name> within the group.

Generator wiring lands in subsequent commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eight new error-severity diagnostics covering declaration mistakes for
[Transition(AfterMs = ...)], [StateMachineGroup], and [StateMachinePart]:

  ZSM0012: AfterMs declared without Concurrent = true
  ZSM0013: AfterMs <= 0
  ZSM0014: [StateMachine] and [StateMachineGroup] on the same class
  ZSM0015: two [StateMachinePart]s share a Name
  ZSM0016: [Transition].Part references an unknown part
  ZSM0017: [StateMachineGroup] declares zero parts
  ZSM0018: [CompositeState] inside a [StateMachineGroup]
  ZSM0019: user-declared Dispose conflicts with generated emit

Descriptors only — detection + report lands in subsequent commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends TransitionModel with AfterMs (int, default 0) and Part (string?,
default null), and updates CollectTransition to read both named args.

Behaviour change: none yet - fields are model-only. Writer + diagnostic
wiring lands in subsequent commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ecords

Immutable records that describe a [StateMachineGroup] class and its
parts. Parse + writer wiring lands in subsequent commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AnalyzeTimedTransitions walks the transition list and reports:
  ZSM0012 when AfterMs > 0 on a non-concurrent class
  ZSM0013 when AfterMs is negative

Tests for both diagnostics land with the snapshot/diagnostic suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…(B3)

Per-edge System.Threading.Timer? fields lazily allocated on first
arm, reused via Timer.Change. Armed in the concurrent CAS-succeeded
path on entering From; disarmed on leaving From.

Emits public void Dispose() implementing IDisposable on every
[StateMachine] class with at least one timed edge. Existing classes
without timers see byte-identical output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When timed transitions are present, the user-declared Dispose method
must be either absent or match public void Dispose() exactly.
Anything else collides with the generator's emit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A second ForAttributeWithMetadataName subscription wires
[StateMachineGroup]-marked classes into the generator. ParseGroup +
StateMachineGroupWriter are stubs that emit an empty partial class
shell; per-part emit lands in subsequent commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CollectGroupParts performs two passes:
  1. gather part declarations with their TState + TTrigger;
  2. bucket [Transition] declarations by their Part discriminator
     into each part's transition list.

Split into CollectPartDeclarations + BucketTransitionsByPart helpers
to satisfy MA0051 (60-line method limit).

AnalyzeGroupDiagnostics is a stub; detection lands in Task 11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five new diagnostics fire from AnalyzeGroupDiagnostics:
  ZSM0014: [StateMachine] + [StateMachineGroup] on the same class
  ZSM0015: two parts share Name
  ZSM0016: [Transition].Part references an unknown part (or null)
  ZSM0017: group declares zero parts
  ZSM0018: [CompositeState] inside a group

Detection only - writer emit for parts lands in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
For each [StateMachinePart] the writer emits:
  - private volatile long _state_<Name>
  - public <TState> <Name>Current
  - public bool TryFire<Name>(<TTrigger>) with CAS loop
  - private OnEnter<Name>/OnExit<Name> dispatchers
  - partial void OnEnter<Name><State> / OnExit<Name><State> stubs

Per-part timed edges arm/disarm inside the part's CAS-succeeded path
using Interlocked.CompareExchange to avoid the lazy-construction race
identified in Task 7.

A single public void Dispose() (when any part has a timed edge)
disposes every timer field across all parts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…(B3)

Two snapshot tests covering:
  - SingleTimedEdge: one timed transition into a sink state
  - MultipleTimedEdges: two timed edges that share neither From nor On

Snapshots assert field naming, arm/disarm placement inside the
concurrent CAS-succeeded path, and the generated public void Dispose().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two snapshot tests covering:
  - TwoParts: two independent CAS parts with disjoint TState/TTrigger
  - TwoPartsOneTimedEdge: same plus a timed edge inside one part

Snapshots assert per-part field/property/method naming, hook routing,
and that a single Dispose disposes timers across parts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
One positive test per new diagnostic asserting that the declaration
trigger reports the expected diagnostic ID.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four scenarios:
  - Timer arms on enter and fires after duration -> state transitions
  - User TryFire before timeout disarms cleanly (re-arms on rearm path)
  - Timer callback after state has moved is a no-op (CAS fails harmlessly)
  - Dispose cancels in-flight timers; no callbacks after Dispose

Deviations from plan:
  - Watchdog fixture is top-level (not nested private) because the
    generator emits into a separate file that cannot see private nested
    types. Matches existing RuntimeTests.cs fixture style.
  - Added [Terminal<WdState>(State = WdState.Dead)] to silence ZSM0002
    (TreatWarningsAsErrors).
  - Added #pragma warning disable MA0048 (file name vs type name) to
    match RuntimeTests.cs convention for multi-type test files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three scenarios:
  - Two parts evolve fully independently under TryFire<Name>
  - TryFire<Name> with an unknown (state, trigger) pair returns false
  - Timed edge inside a part arms/disarms scoped to that part only

Note: spec used a nested private partial class; moved Device and its enums
to top-level public (same deviation as Task 16) so the source generator
can emit on them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new core-concepts pages and eight new diagnostic pages following
the existing template. attributes.md adds entries for AfterMs, Part,
[StateMachineGroup], [StateMachinePart].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The original test armed a 100ms Timeout timer and asserted state == Working
after Task.Delay(50). On busy CI hosts, ThreadPool / Task.Delay scheduling
jitter can easily eat the 50ms buffer between the disarm/rearm point and the
checkpoint, producing intermittent failures where the original timer fires
before the assertion runs.

Inspection of the emitted Watchdog.g.cs confirmed the disarm/rearm sequence
is correct (Change(Infinite,Infinite) on the old timer, then Change(500,Infinite)
on the same Timer instance), so this is a test-timing flake, not a generator bug.

Stabilization:
- Bump AfterMs from 100 to 500 across the Watchdog declaration so the negative-
  case test (assert Working) has a wide stability margin.
- Replace the single 50ms checkpoint with a polling loop of 4 x 50ms checkpoints,
  each asserting state == Working. If disarm/rearm regressed, the original
  ~500ms timer (armed by Start) would fire ~500ms after Start, so any checkpoint
  before that should still observe Working -- giving us a much stronger signal
  than a single checkpoint.
- Proportionally enlarge the wait windows in the sibling tests (250ms -> 1000ms,
  200ms -> 1000ms) so they remain stable under the new 500ms AfterMs.

Generator snapshot tests are unaffected (they use their own self-contained
Watchdog declaration with AfterMs = 5000).

5/5 isolated runs pass; full suite (26/26 runtime + 32/32 generator) passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two non-blocking findings from the whole-branch review:

  1. Document the initial-state-not-auto-armed behavior in
     timeout-transitions.md. Constructor-time arm is deferred
     to a follow-up.
  2. Delete ResolvePartTriggerFqn stub in StateMachineWriter.cs
     (always returned m.TriggerTypeFqn; partPrefix is always null
     at call sites in the single-machine path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@MarcelRoozekrans MarcelRoozekrans merged commit c41b019 into main May 23, 2026
4 checks passed
@MarcelRoozekrans MarcelRoozekrans deleted the feat/timeout-and-concurrent-parts branch May 23, 2026 05:49
MarcelRoozekrans added a commit that referenced this pull request May 24, 2026
The original post-v1 graduation backlog (B1 composite states, B2 shallow
history, B3 timeout transitions, B4 Mermaid diagram, B5 concurrent state
parts) fully shipped across PRs #25, #27, and #29. The backlog file
hadn't been updated to reflect this — now it has.

Each entry now carries a brief 'Shipped:' note pointing at the relevant
attribute / generator file / test file so a reader can navigate from the
backlog item to the implementation. No new backlog items added — future
graduation candidates land here when real-world friction surfaces them.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant