Skip to content

Commit 200de10

Browse files
committed
fix: honor context cancellation at Fire run-to-completion boundaries
Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
1 parent 86704cd commit 200de10

4 files changed

Lines changed: 293 additions & 3 deletions

File tree

state/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@ ready to freeze on sign-off. The `analysis`, `evolution`, `conformance`, and
2121

2222
### Fixed
2323

24+
- `Fire` now honors context cancellation at microstep boundaries. It checks the
25+
context before the triggering transition and between every run-to-completion
26+
microstep — the same granularity `WaitFor` polls at — but never mid-microstep, so
27+
a single in-flight cascade always settles before the next boundary check. A context
28+
that is canceled or past its deadline at a boundary aborts the macrostep and
29+
surfaces `context.Canceled` / `context.DeadlineExceeded` on `FireResult.Err`
30+
(matchable with `errors.Is`). The abort routes through the same transactional
31+
rollback as a failed `Fire`, so a canceled `Fire` is a clean no-op: the instance is
32+
left at its pre-Fire configuration and context, and `FireResult.Effects` is nil.
33+
Previously a canceled or expired context still ran the full macrostep.
2434
- A failed `Fire` is now fully transactional on the instance's internal state: when
2535
an action or assign errors or panics partway through a macrostep, the active
2636
configuration, current leaf, context, and recorded history all roll back to their

state/doc.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,19 @@
174174
// event and eventless step, plus per-region markers) as it happened. FireResult's
175175
// Effects slice carries the same effects, in the same order, as data.
176176
//
177+
// Fire honors context cancellation at microstep boundaries. It checks ctx before
178+
// the triggering transition runs and again between every run-to-completion
179+
// microstep — the same "polls between steps" granularity WaitFor uses — but never
180+
// mid-microstep: a single in-flight microstep (its exit/transition/entry cascade
181+
// and the actions within it) always runs to completion before the next boundary
182+
// check. A context that is canceled or past its deadline at any boundary aborts the
183+
// macrostep and surfaces the cancellation cause (context.Canceled or
184+
// context.DeadlineExceeded) on FireResult.Err, where errors.Is can match it. The
185+
// abort routes through the same transactional rollback as a failed Fire, so a
186+
// canceled Fire is a clean no-op on persisted state: the instance is left at its
187+
// pre-Fire configuration and context, FireResult.NewState reports the original
188+
// state, and FireResult.Effects is nil — no partially settled microstep leaks out.
189+
//
177190
// The ordering is structural, not incidental. Every emission, fold, and cascade
178191
// walk iterates declaration-ordered slices — states, transitions, regions,
179192
// children, refs — never a Go map. The kernel's maps (node and state indices, the

state/fire.go

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,19 @@ func (i *Instance[S, E, C]) fireCore(ctx context.Context, event E) FireResult[S]
116116
// snapshot is simply discarded on success.
117117
txn := i.beginTxn()
118118

119-
res := i.fireOnce(ctx, event)
120-
if res.Err == nil {
121-
res = i.runToCompletion(ctx, res)
119+
// Honor cancellation at the macrostep boundary before any sub-step runs: a Fire
120+
// driven with an already-canceled or already-expired context is a clean no-op.
121+
// The cancellation cause is surfaced raw on res.Err and flows through the same
122+
// transactional-rollback branch as a failed Fire below, so the instance is left
123+
// at its pre-Fire configuration with no leaked effects.
124+
res := FireResult[S]{NewState: i.current, Trace: i.seedTrace(fmt.Sprint(event))}
125+
if err := ctx.Err(); err != nil {
126+
res.Err = err
127+
} else {
128+
res = i.fireOnce(ctx, event)
129+
if res.Err == nil {
130+
res = i.runToCompletion(ctx, res)
131+
}
122132
}
123133
if res.Err != nil {
124134
i.rollback(txn)
@@ -207,6 +217,18 @@ func (i *Instance[S, E, C]) rollback(txn fireTxn[S, E, C]) {
207217
func (i *Instance[S, E, C]) runToCompletion(ctx context.Context, res FireResult[S]) FireResult[S] {
208218
steps := 0
209219
for {
220+
// Honor cancellation at the microstep boundary, the same "poll between steps"
221+
// granularity WaitFor uses: a context canceled or expired between microsteps
222+
// stops the run-to-completion loop and surfaces the cancellation cause. fireCore
223+
// routes a non-nil res.Err through its transactional rollback, so the partially
224+
// settled macrostep is unwound to its pre-Fire configuration with no leaked
225+
// effects. An in-flight microstep is never torn apart — the check only fires at
226+
// a boundary, between completed microsteps.
227+
if err := ctx.Err(); err != nil {
228+
res.Err = err
229+
return res
230+
}
231+
210232
// Internal (raised) events take priority and are processed FIFO.
211233
if len(i.raised) > 0 {
212234
ev := i.raised[0]

state/fire_cancel_test.go

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
package state_test
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
"time"
8+
9+
"github.com/stablekernel/crucible/state"
10+
)
11+
12+
// The cancellation suite drives a machine whose single macrostep settles across
13+
// several microsteps, so a context canceled at (or before) a microstep boundary
14+
// must abort the run-to-completion loop and roll the instance back to its pre-Fire
15+
// configuration — leaving a clean no-op on persisted state.
16+
17+
type cancelState int
18+
19+
const (
20+
cancelStart cancelState = iota
21+
cancelMid
22+
cancelStep2
23+
cancelStep3
24+
cancelFinal
25+
)
26+
27+
type cancelEvent int
28+
29+
const (
30+
cancelGo cancelEvent = iota
31+
cancelKick
32+
)
33+
34+
// cancelCtx records each effect tag the macrostep emits, so a test can confirm a
35+
// rolled-back Fire left no committed context change and a normal Fire folded the
36+
// full chain.
37+
type cancelCtx struct {
38+
Log []string
39+
}
40+
41+
// tagCancelEffect returns an action that emits its tag as a string effect.
42+
func tagCancelEffect(tag string) func(state.ActionCtx[cancelCtx]) (state.Effect, error) {
43+
return func(state.ActionCtx[cancelCtx]) (state.Effect, error) {
44+
return tag, nil
45+
}
46+
}
47+
48+
// buildCancelMachine forges a machine with a multi-microstep macrostep:
49+
//
50+
// Start --Go (raises Kick)--> Mid --always--> Step2 --always--> Step3 --always--> Final
51+
//
52+
// The Go transition raises an internal Kick (drained as the first RTC microstep)
53+
// and lands in Mid, whose eventless chain then walks Step2 -> Step3 -> Final. One
54+
// Fire(Go) therefore settles across multiple microsteps, giving cancellation a
55+
// boundary to take effect at.
56+
func buildCancelMachine() *state.Machine[cancelState, cancelEvent, cancelCtx] {
57+
return state.Forge[cancelState, cancelEvent, cancelCtx]("cancel").
58+
Action("atStart", tagCancelEffect("atStart")).
59+
Action("atMid", tagCancelEffect("atMid")).
60+
Action("atStep2", tagCancelEffect("atStep2")).
61+
Action("atStep3", tagCancelEffect("atStep3")).
62+
Action("atFinal", tagCancelEffect("atFinal")).
63+
State(cancelStart).
64+
Transition(cancelStart).On(cancelGo).GoTo(cancelMid).
65+
Do("atStart", state.P{}).
66+
Raise(cancelKick).
67+
State(cancelMid).
68+
OnEntry("atMid", state.P{}).
69+
Always().GoTo(cancelStep2).
70+
State(cancelStep2).
71+
OnEntry("atStep2", state.P{}).
72+
Always().GoTo(cancelStep3).
73+
State(cancelStep3).
74+
OnEntry("atStep3", state.P{}).
75+
Always().GoTo(cancelFinal).
76+
State(cancelFinal).
77+
OnEntry("atFinal", state.P{}).
78+
Initial(cancelStart).
79+
Quench()
80+
}
81+
82+
// newCancelInstance casts a fresh instance of the cancel machine pinned at Start
83+
// with full trace, the shape the cancellation tests assert against.
84+
func newCancelInstance(t *testing.T) *state.Instance[cancelState, cancelEvent, cancelCtx] {
85+
t.Helper()
86+
m := buildCancelMachine()
87+
return m.Cast(cancelCtx{},
88+
state.WithInitialState[cancelState](cancelStart),
89+
state.WithFullTrace[cancelState](),
90+
)
91+
}
92+
93+
// TestFire_AlreadyCancelledContext_RollsBack asserts that firing with a context
94+
// that is already canceled is a clean no-op: it surfaces the cancellation cause,
95+
// leaves the instance at its pre-Fire configuration, and emits no effects.
96+
func TestFire_AlreadyCancelledContext_RollsBack(t *testing.T) {
97+
tests := []struct {
98+
name string
99+
ctxFn func() (context.Context, context.CancelFunc)
100+
wantErr error
101+
}{
102+
{
103+
name: "canceled",
104+
ctxFn: func() (context.Context, context.CancelFunc) {
105+
ctx, cancel := context.WithCancel(context.Background())
106+
cancel()
107+
return ctx, cancel
108+
},
109+
wantErr: context.Canceled,
110+
},
111+
{
112+
name: "deadline_exceeded",
113+
ctxFn: func() (context.Context, context.CancelFunc) {
114+
// A deadline already in the past makes ctx.Err report DeadlineExceeded
115+
// immediately, so the first boundary check aborts the macrostep.
116+
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Hour))
117+
return ctx, cancel
118+
},
119+
wantErr: context.DeadlineExceeded,
120+
},
121+
}
122+
123+
for _, tc := range tests {
124+
t.Run(tc.name, func(t *testing.T) {
125+
inst := newCancelInstance(t)
126+
ctx, cancel := tc.ctxFn()
127+
defer cancel()
128+
129+
res := inst.Fire(ctx, cancelGo)
130+
131+
if !errors.Is(res.Err, tc.wantErr) {
132+
t.Fatalf("Fire err = %v, want errors.Is(..., %v)", res.Err, tc.wantErr)
133+
}
134+
if res.Effects != nil {
135+
t.Fatalf("canceled Fire emitted effects %v, want nil", res.Effects)
136+
}
137+
if res.NewState != cancelStart {
138+
t.Fatalf("canceled Fire NewState = %v, want %v (rolled back)", res.NewState, cancelStart)
139+
}
140+
if got := inst.Configuration(); len(got) != 1 || got[0] != cancelStart {
141+
t.Fatalf("canceled Fire left configuration %v, want [%v]", got, cancelStart)
142+
}
143+
if entity := inst.Entity(); len(entity.Log) != 0 {
144+
t.Fatalf("canceled Fire folded context %v, want empty", entity.Log)
145+
}
146+
})
147+
}
148+
}
149+
150+
// TestFire_CancelDuringMacrostep_RollsBack cancels the context in the middle of an
151+
// action the macrostep runs, so cancellation is observable only at the NEXT
152+
// microstep boundary — not mid-action. The whole macrostep must still roll back:
153+
// the in-flight microstep completes, then the boundary check aborts and unwinds to
154+
// Start with no effects, proving cancellation never tears a single microstep apart.
155+
func TestFire_CancelDuringMacrostep_RollsBack(t *testing.T) {
156+
ctx, cancel := context.WithCancel(context.Background())
157+
defer cancel()
158+
159+
// The first transition's action cancels the context as a side effect. Because the
160+
// boundary check sits between microsteps, the triggering microstep still finishes
161+
// in full (it has begun and is never interrupted); the run-to-completion loop then
162+
// observes the cancellation at its next boundary and aborts, rolling the macrostep
163+
// back to Start.
164+
m := state.Forge[cancelState, cancelEvent, cancelCtx]("cancel-midflight").
165+
Action("cancelNow", func(state.ActionCtx[cancelCtx]) (state.Effect, error) {
166+
cancel()
167+
return "cancelNow", nil
168+
}).
169+
Action("atMid", tagCancelEffect("atMid")).
170+
Action("atStep2", tagCancelEffect("atStep2")).
171+
State(cancelStart).
172+
Transition(cancelStart).On(cancelGo).GoTo(cancelMid).
173+
Do("cancelNow", state.P{}).
174+
Raise(cancelKick).
175+
State(cancelMid).
176+
OnEntry("atMid", state.P{}).
177+
Always().GoTo(cancelStep2).
178+
State(cancelStep2).
179+
OnEntry("atStep2", state.P{}).
180+
Initial(cancelStart).
181+
Quench()
182+
183+
inst := m.Cast(cancelCtx{},
184+
state.WithInitialState[cancelState](cancelStart),
185+
state.WithFullTrace[cancelState](),
186+
)
187+
188+
res := inst.Fire(ctx, cancelGo)
189+
190+
if !errors.Is(res.Err, context.Canceled) {
191+
t.Fatalf("Fire err = %v, want errors.Is(..., context.Canceled)", res.Err)
192+
}
193+
if res.Effects != nil {
194+
t.Fatalf("canceled Fire emitted effects %v, want nil", res.Effects)
195+
}
196+
if res.NewState != cancelStart {
197+
t.Fatalf("canceled Fire NewState = %v, want %v (rolled back)", res.NewState, cancelStart)
198+
}
199+
if got := inst.Configuration(); len(got) != 1 || got[0] != cancelStart {
200+
t.Fatalf("canceled Fire left configuration %v, want [%v]", got, cancelStart)
201+
}
202+
if entity := inst.Entity(); len(entity.Log) != 0 {
203+
t.Fatalf("canceled Fire folded context %v, want empty", entity.Log)
204+
}
205+
}
206+
207+
// TestFire_NormalContext_Unaffected confirms the happy path is untouched: a
208+
// non-canceled Fire settles the full eventless chain to Final and folds every
209+
// effect in emission order.
210+
func TestFire_NormalContext_Unaffected(t *testing.T) {
211+
inst := newCancelInstance(t)
212+
213+
res := inst.Fire(context.Background(), cancelGo)
214+
215+
if res.Err != nil {
216+
t.Fatalf("normal Fire err = %v, want nil", res.Err)
217+
}
218+
if res.NewState != cancelFinal {
219+
t.Fatalf("normal Fire NewState = %v, want %v", res.NewState, cancelFinal)
220+
}
221+
if got := inst.Configuration(); len(got) != 1 || got[0] != cancelFinal {
222+
t.Fatalf("normal Fire configuration = %v, want [%v]", got, cancelFinal)
223+
}
224+
want := []string{"atStart", "atMid", "atStep2", "atStep3", "atFinal"}
225+
got := effectStrings(res.Effects)
226+
if len(got) != len(want) {
227+
t.Fatalf("normal Fire effects = %v, want %v", got, want)
228+
}
229+
for i := range want {
230+
if got[i] != want[i] {
231+
t.Fatalf("normal Fire effects = %v, want %v", got, want)
232+
}
233+
}
234+
}
235+
236+
// effectStrings projects the string effects of a result in emission order.
237+
func effectStrings(effs []state.Effect) []string {
238+
out := make([]string, 0, len(effs))
239+
for _, e := range effs {
240+
if s, ok := e.(string); ok {
241+
out = append(out, s)
242+
}
243+
}
244+
return out
245+
}

0 commit comments

Comments
 (0)