feat: timeout transitions + concurrent state parts (B3+B5)#27
Merged
Conversation
…+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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Lands
ZeroAlloc.StateMachinebacklog items B3 + B5 as a pair:[Transition(... AfterMs = N)]emits a lazily-allocatedSystem.Threading.Timer?per timed edge. Armed in the concurrent CAS-succeeded path on enter, disarmed on exit. Lazy allocation usesInterlocked.CompareExchange+ dispose-of-loser to avoid a construction race. RequiresConcurrent = true. GeneratedIDisposablecleans up timers.[StateMachineGroup]+[StateMachinePart<TState, TTrigger>(Name, InitialState)]declare multiple independent CAS state fields in one class, each with its ownTryFire<Name>/<Name>Current/ per-partOnEnter<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
Known follow-ups (non-blocking)
InitialStateis the source of a timed transition; documented as a caveat intimeout-transitions.md. Constructor-time arm emit deferred.[StateMachineGroup]pipeline; per-part dead-state / single-use-trigger heuristics could land in a separate PR.🤖 Generated with Claude Code