From 84e67b02fb81300ad982f5c7b9e0ef405d520f66 Mon Sep 17 00:00:00 2001 From: Ray Karnes Date: Tue, 10 Nov 2026 12:46:18 -0700 Subject: [PATCH 1/9] feat(state): pass the step's context to guards, actions and reducers A guard, action or reducer had no context, so a host couldn't tell a call it made back into the host from a call on another goroutine. GuardCtx, ActionCtx and AssignCtx now carry Ctx: the context the Fire was given, or context.Background() outside a Fire. The in-process bindings pass their own context through. WithContext sets the context Cast gives the reducers that settle the initial state. ActorSystem.RegisterContext registers a factory that gets the spawning step's context, so it can Cast its child with it. A child's own callbacks already get the context its delivery carries. The change is additive. Signed-off-by: Ray Karnes --- state/CHANGELOG.md | 11 +++ state/actor_snapshot.go | 2 +- state/actor_system.go | 20 +++- state/assign.go | 10 +- state/binding.go | 8 +- state/binding_test.go | 2 +- state/callback_context_test.go | 165 +++++++++++++++++++++++++++++++++ state/fire.go | 40 ++++++-- state/guard.go | 2 +- state/kernel.go | 17 ++++ state/options.go | 14 ++- state/parallel.go | 4 +- state/plan.go | 6 +- 13 files changed, 274 insertions(+), 27 deletions(-) create mode 100644 state/callback_context_test.go diff --git a/state/CHANGELOG.md b/state/CHANGELOG.md index e4894a80..0632e359 100644 --- a/state/CHANGELOG.md +++ b/state/CHANGELOG.md @@ -15,6 +15,17 @@ counts as an additive (minor) versus breaking (major) change. Use the - **`FireEventData`** reports the payload a `WithEventData` option carries, so a host that records a Fire can persist it. It's additive, so the next release is v1.1.0. +- **Callbacks get the step's context.** `GuardCtx`, `ActionCtx` and `AssignCtx` have a new + `Ctx` field: the context the `Fire` was given, so a callback can pass it to any call it + makes back into the host driving the instance. Outside a `Fire`, such as during + `PlanPath`, it's `context.Background()`, never nil. The in-process bindings pass their + `EvalGuard`, `EvalAction` and `EvalAssign` context through. +- **`WithContext`** sets the context `Cast` gives the reducers that settle the initial + state. +- **`ActorSystem.RegisterContext`** registers a `ContextActorBehavior`, an actor factory + that gets the context of the step spawning or restoring the actor, so it can `Cast` + its child `WithContext`. A child's own callbacks already get the context its + delivery carries. ### Changed diff --git a/state/actor_snapshot.go b/state/actor_snapshot.go index 05427178..6c762ac9 100644 --- a/state/actor_snapshot.go +++ b/state/actor_snapshot.go @@ -276,7 +276,7 @@ func (s *ActorSystem[S, E, C]) restoreActor(ctx context.Context, snap actorSnaps if !ok { return nil } - inst, err := behavior(snap.Input) + inst, err := behavior(ctx, snap.Input) if err != nil { return &SnapshotError{Op: "restore", State: snap.ID, Reason: "actor re-spawn failed: " + err.Error(), Cause: err} } diff --git a/state/actor_system.go b/state/actor_system.go index 8d95f287..c4195d37 100644 --- a/state/actor_system.go +++ b/state/actor_system.go @@ -47,6 +47,12 @@ import ( // WithInitialState derived from input. type ActorBehavior func(input map[string]any) (ActorInstance, error) +// ContextActorBehavior is an ActorBehavior that also gets the context of the step +// spawning the actor: the parent's Fire, or the host call that absorbed the spawn. A +// behavior that Casts its child passes it on with WithContext, so the reducers that +// settle the child's initial state see it too. Register one with RegisterContext. +type ContextActorBehavior func(ctx context.Context, input map[string]any) (ActorInstance, error) + // ActorInstance is a running child actor as the ActorSystem sees it, with the // child's own (S, E, C) generic parameters erased. A host obtains one by wrapping // a Cast child *Instance with NewActor; the deterministic test driver and the @@ -130,7 +136,7 @@ type ActorSystem[S comparable, E comparable, C any] struct { parent *Instance[S, E, C] mu sync.Mutex - palette map[string]ActorBehavior + palette map[string]ContextActorBehavior actors map[string]*runningActor[E] bySystem map[string]string // systemId -> id @@ -232,7 +238,7 @@ func (s *ActorSystem[S, E, C]) inspectorRef() Inspector { func NewActorSystem[S comparable, E comparable, C any](parent *Instance[S, E, C]) *ActorSystem[S, E, C] { return &ActorSystem[S, E, C]{ parent: parent, - palette: map[string]ActorBehavior{}, + palette: map[string]ContextActorBehavior{}, actors: map[string]*runningActor[E]{}, bySystem: map[string]string{}, } @@ -243,6 +249,14 @@ func NewActorSystem[S comparable, E comparable, C any](parent *Instance[S, E, C] // analog of Registry.Service: a host registers each child machine it can spawn. // Registering returns the system for chaining. func (s *ActorSystem[S, E, C]) Register(src string, behavior ActorBehavior) *ActorSystem[S, E, C] { + return s.RegisterContext(src, func(_ context.Context, input map[string]any) (ActorInstance, error) { + return behavior(input) + }) +} + +// RegisterContext is Register for a behavior that gets the spawning step's context. +// Registering a src again, with either method, replaces its behavior. +func (s *ActorSystem[S, E, C]) RegisterContext(src string, behavior ContextActorBehavior) *ActorSystem[S, E, C] { s.mu.Lock() defer s.mu.Unlock() s.palette[src] = behavior @@ -323,7 +337,7 @@ func (s *ActorSystem[S, E, C]) spawn(ctx context.Context, e SpawnActor) { s.routeError(ctx, e, &UnboundActorError{Name: e.Src.Name}) return } - inst, err := behavior(e.Input) + inst, err := behavior(ctx, e.Input) if err != nil { s.routeError(ctx, e, err) return diff --git a/state/assign.go b/state/assign.go index fbdcb9b1..2a8a5a35 100644 --- a/state/assign.go +++ b/state/assign.go @@ -32,6 +32,12 @@ type AssignCtx[C any] struct { Entity C Event any Params map[string]any + // Ctx is the context of the Fire running this callback, or of the Cast that + // settles the initial state (see WithContext); context.Background() outside one, + // such as during PlanPath. It is never nil. Pass it to any call the callback makes + // back into the host that drives this instance, so the host can tell the call + // comes from inside a step. + Ctx context.Context } // AssignFn is the sole context writer: a total pure reducer producing the next @@ -80,8 +86,8 @@ func inProcessAssign[C any](fn AssignFn[C]) AssignBinding[C] { // EvalAssign reads the prior context from the request's context view and invokes // the wrapped AssignFn, returning the new context. The view is read-only; the // reducer receives the context by value and its return is the only output. -func (b assignFnBinding[C]) EvalAssign(_ context.Context, req AssignRequest[C]) (AssignResult[C], error) { +func (b assignFnBinding[C]) EvalAssign(ctx context.Context, req AssignRequest[C]) (AssignResult[C], error) { entity, _ := req.Context.Raw().(C) - next := b.fn(AssignCtx[C]{Entity: entity, Event: req.Event, Params: req.Params}) + next := b.fn(AssignCtx[C]{Entity: entity, Event: req.Event, Params: req.Params, Ctx: nonNil(ctx)}) return AssignResult[C]{Context: next}, nil } diff --git a/state/binding.go b/state/binding.go index 983a4cea..22aa4bf1 100644 --- a/state/binding.go +++ b/state/binding.go @@ -96,9 +96,9 @@ func inProcessGuard[C any](fn GuardFn[C]) GuardBinding[C] { // EvalGuard reads the live entity from the request's context view and invokes the // wrapped GuardFn. The view is read-only; the func receives the entity by value. -func (b guardFnBinding[C]) EvalGuard(_ context.Context, req GuardRequest[C]) (GuardResult, error) { +func (b guardFnBinding[C]) EvalGuard(ctx context.Context, req GuardRequest[C]) (GuardResult, error) { entity, _ := req.Context.Raw().(C) - return GuardResult{OK: b.fn(GuardCtx[C]{Entity: entity, Params: req.Params})}, nil + return GuardResult{OK: b.fn(GuardCtx[C]{Entity: entity, Params: req.Params, Ctx: nonNil(ctx)})}, nil } // actionFnBinding is the default in-process ActionBinding: it adapts an ActionFn. @@ -114,9 +114,9 @@ func inProcessAction[C any](fn ActionFn[C]) ActionBinding[C] { // EvalAction invokes the wrapped ActionFn and lifts its single effect into the // ActionResult envelope. Actions emit effects only; context is written through an // assign, not here. -func (b actionFnBinding[C]) EvalAction(_ context.Context, req ActionRequest[C]) (ActionResult, error) { +func (b actionFnBinding[C]) EvalAction(ctx context.Context, req ActionRequest[C]) (ActionResult, error) { entity, _ := req.Context.Raw().(C) - eff, err := b.fn(ActionCtx[C]{Entity: entity, Params: req.Params}) + eff, err := b.fn(ActionCtx[C]{Entity: entity, Params: req.Params, Ctx: nonNil(ctx)}) if err != nil { return ActionResult{}, err } diff --git a/state/binding_test.go b/state/binding_test.go index b1177d58..c211577e 100644 --- a/state/binding_test.go +++ b/state/binding_test.go @@ -177,7 +177,7 @@ func TestRegistry_AssignBindingRecorded(t *testing.T) { // leaves the context unchanged, rather than silently dropping the fold. func TestEvalAssign_UnboundRefFailsClosed(t *testing.T) { m := ForgeFor[bindOrder]("x").State("a").Initial("a").Quench() - next, err := m.evalAssign(Ref{Name: "ghost"}, bindOrder{Amount: 3}, nil) + next, err := m.evalAssign(context.Background(), Ref{Name: "ghost"}, bindOrder{Amount: 3}, nil) if err == nil { t.Fatal("unbound assign ref should fail") } diff --git a/state/callback_context_test.go b/state/callback_context_test.go new file mode 100644 index 00000000..28614319 --- /dev/null +++ b/state/callback_context_test.go @@ -0,0 +1,165 @@ +package state_test + +import ( + "context" + "testing" + + "github.com/stablekernel/crucible/state" +) + +// ctxKey tags the contexts these tests pass, so a callback's Ctx can be traced back to +// the call that supplied it. +type ctxKey struct{} + +// tagged returns a context carrying tag. +func tagged(tag string) context.Context { + return context.WithValue(context.Background(), ctxKey{}, tag) +} + +// tagOf reads the tag a callback's Ctx carries, or "" for none. +func tagOf(ctx context.Context) string { + if ctx == nil { + return "nil" + } + tag, _ := ctx.Value(ctxKey{}).(string) + return tag +} + +// ctxSeen records which tag each kind of callback saw. +type ctxSeen struct { + Guard, Action, Assign, Entry string +} + +// ctxMachine runs a guard, an action and a transition reducer on idle -go-> busy, and +// an entry reducer on entering idle, each recording its Ctx's tag. +func ctxMachine() *state.Machine[string, string, *ctxSeen] { + return state.ForgeFor[*ctxSeen]("ctx"). + Guard("g", func(c state.GuardCtx[*ctxSeen]) bool { + c.Entity.Guard = tagOf(c.Ctx) + return true + }). + Action("a", func(c state.ActionCtx[*ctxSeen]) (state.Effect, error) { + c.Entity.Action = tagOf(c.Ctx) + return nil, nil + }). + Reducer("r", func(c state.AssignCtx[*ctxSeen]) *ctxSeen { + c.Entity.Assign = tagOf(c.Ctx) + return c.Entity + }). + Reducer("entry", func(c state.AssignCtx[*ctxSeen]) *ctxSeen { + c.Entity.Entry = tagOf(c.Ctx) + return c.Entity + }). + State("idle").OnEntryAssign("entry"). + State("busy"). + Initial("idle"). + Transition("idle").On("go").GoTo("busy").When("g").Do("a").Assign("r"). + Transition("busy").On("back").GoTo("idle"). + Quench() +} + +// TestCallbacks_GetTheFiresContext proves a guard, an action and a reducer each get the +// context the Fire was given as their Ctx. +func TestCallbacks_GetTheFiresContext(t *testing.T) { + seen := &ctxSeen{} + inst := ctxMachine().Cast(seen, state.WithInitialState("idle")) + if res := inst.Fire(tagged("fire-1"), "go"); res.Err != nil { + t.Fatalf("Fire: %v", res.Err) + } + if *seen != (ctxSeen{Guard: "fire-1", Action: "fire-1", Assign: "fire-1", Entry: ""}) { + t.Fatalf("callbacks saw %+v, want the guard, action and reducer to see fire-1", *seen) + } + if res := inst.Fire(tagged("fire-2"), "back"); res.Err != nil { + t.Fatalf("Fire(back): %v", res.Err) + } + if seen.Entry != "fire-2" { + t.Fatalf("the entry reducer saw %q, want fire-2", seen.Entry) + } +} + +// TestCallbacks_CastWithContext proves the reducers that settle a cast's initial state +// get WithContext's context, and a plain Cast gives them a non-nil background one. +func TestCallbacks_CastWithContext(t *testing.T) { + seen := &ctxSeen{} + ctxMachine().Cast(seen, state.WithInitialState("idle"), state.WithContext[string](tagged("cast"))) + if seen.Entry != "cast" { + t.Fatalf("the entry reducer saw %q at Cast, want cast", seen.Entry) + } + plain := &ctxSeen{} + ctxMachine().Cast(plain, state.WithInitialState("idle")) + if plain.Entry != "" { // context.Background(): non-nil, no tag + t.Fatalf("the entry reducer saw %q at a plain Cast, want a background context", plain.Entry) + } +} + +// TestCallbacks_PlanPathGetsABackgroundContext proves a guard evaluated outside any +// Fire, by PlanPath, gets a non-nil context. +func TestCallbacks_PlanPathGetsABackgroundContext(t *testing.T) { + m := ctxMachine() + seen := &ctxSeen{Guard: "unset"} + if _, err := m.PlanPath("idle", "busy", seen); err != nil { + t.Fatalf("PlanPath: %v", err) + } + if seen.Guard != "" { + t.Fatalf("PlanPath's guard saw %q, want a background context", seen.Guard) + } +} + +// TestCallbacks_NestedFireRestoresTheOuterContext proves a Fire made from inside a +// callback on the same instance doesn't leave its context behind: the outer step's +// later callbacks still see the outer context. +func TestCallbacks_NestedFireRestoresTheOuterContext(t *testing.T) { + type rec struct{ Assign string } + var inst *state.Instance[string, string, *rec] + m := state.ForgeFor[*rec]("nested"). + Guard("nest", func(c state.GuardCtx[*rec]) bool { + inst.Fire(tagged("inner"), "noop") + return true + }). + Reducer("r", func(c state.AssignCtx[*rec]) *rec { + c.Entity.Assign = tagOf(c.Ctx) + return c.Entity + }). + State("idle"). + State("busy"). + Initial("idle"). + Transition("idle").On("go").GoTo("busy").When("nest").Assign("r"). + Quench() + seen := &rec{} + inst = m.Cast(seen, state.WithInitialState("idle")) + if res := inst.Fire(tagged("outer"), "go"); res.Err != nil { + t.Fatalf("Fire: %v", res.Err) + } + if seen.Assign != "outer" { + t.Fatalf("the reducer after a nested Fire saw %q, want outer", seen.Assign) + } +} + +// TestActorSystem_RegisterContext_GetsTheSpawnContext proves a behavior registered +// with RegisterContext gets the context of the call that absorbed the spawn, and can +// hand it to its child's Cast. +func TestActorSystem_RegisterContext_GetsTheSpawnContext(t *testing.T) { + parent := ctxMachine().Cast(&ctxSeen{}, state.WithInitialState("idle")) + child := &ctxSeen{} + var spawnTag string + sys := state.NewActorSystem(parent). + RegisterContext("kid", func(ctx context.Context, _ map[string]any) (state.ActorInstance, error) { + spawnTag = tagOf(ctx) + inst := ctxMachine().Cast(child, state.WithInitialState("idle"), state.WithContext[string](ctx)) + return state.NewActor(inst, nil), nil + }) + sys.Absorb(tagged("spawn"), []state.Effect{state.SpawnActor{ID: "kid-1", Src: state.Ref{Name: "kid"}}}) + if spawnTag != "spawn" || child.Entry != "spawn" { + t.Fatalf("behavior saw %q and the child's entry reducer %q, want spawn for both", spawnTag, child.Entry) + } + ref, ok := sys.Ref("kid-1") + if !ok { + t.Fatal("the actor didn't spawn") + } + if !sys.Deliver(tagged("deliver"), ref, "go") { + t.Fatal("Deliver returned false") + } + if child.Guard != "deliver" || child.Action != "deliver" || child.Assign != "deliver" { + t.Fatalf("the child's callbacks saw %+v, want deliver", *child) + } +} diff --git a/state/fire.go b/state/fire.go index 457f27fe..866e7672 100644 --- a/state/fire.go +++ b/state/fire.go @@ -31,6 +31,11 @@ func (i *Instance[S, E, C]) Fire(ctx context.Context, event E, opts ...FireOptio // when the macrostep settles, so it never leaks into a later Fire. i.fireData = cfg.eventData i.hasFireData = cfg.hasData + // Guards, actions and reducers see this Fire's context as their Ctx. A Fire nested + // inside another on the same instance restores the outer one when it returns. + outerCtx := i.callCtx + i.callCtx = ctx + defer func() { i.callCtx = outerCtx }() defer func() { i.fireData = nil i.hasFireData = false @@ -519,7 +524,7 @@ func (i *Instance[S, E, C]) selectEventless() (t *Transition[S, E, C], anc, leaf // expr subpackage documents the same asymmetry from its side. func (i *Instance[S, E, C]) guardsPass(t *Transition[S, E, C]) bool { for _, g := range t.Guards { - ok, err := i.machine.evalGuard(g, i.entity) + ok, err := i.machine.evalGuard(i.callbackContext(), g, i.entity) if err != nil || !ok { return false } @@ -659,7 +664,7 @@ func (i *Instance[S, E, C]) fireSpine(ctx context.Context, event E, tr Trace) Fi passed := true for _, g := range t.Guards { tr.recordGuard(g.Name) - ok, gErr := m.evalGuard(g, entity) + ok, gErr := m.evalGuard(i.callbackContext(), g, entity) if gErr != nil { tr.Outcome = OutcomeGuardPanic return FireResult[S]{NewState: from, Trace: tr, Err: gErr} @@ -1077,7 +1082,7 @@ func transName[S comparable](from, to S) string { // failing action's name, and the cause. func (i *Instance[S, E, C]) runActions(refs []Ref, entity C, tr *Trace) (effects []Effect, name string, err error) { for _, a := range refs { - e, aerr := i.machine.evalAction(a, entity) + e, aerr := i.machine.evalAction(i.callbackContext(), a, entity) if aerr != nil { tr.recordEffect(a.Name) return effects, a.Name, aerr @@ -1097,8 +1102,23 @@ func (i *Instance[S, E, C]) runActions(refs []Ref, entity C, tr *Trace) (effects return effects, "", nil } +// callbackContext returns the context a guard, action or reducer gets as its Ctx: the +// running Fire's or settling Cast's, or context.Background() outside one. +func (i *Instance[S, E, C]) callbackContext() context.Context { + return nonNil(i.callCtx) +} + +// nonNil returns ctx, or context.Background() for a nil one, so a callback's Ctx is +// always usable. +func nonNil(ctx context.Context) context.Context { + if ctx == nil { + return context.Background() + } + return ctx +} + // evalGuard resolves and runs a guard ref, recovering panics into GuardPanicError. -func (m *Machine[S, E, C]) evalGuard(g Ref, entity C) (ok bool, err error) { +func (m *Machine[S, E, C]) evalGuard(ctx context.Context, g Ref, entity C) (ok bool, err error) { fn, found := m.guards[g.Name] if !found { // Unbound refs are caught at Quench; defensively treat as a guard panic. @@ -1110,14 +1130,14 @@ func (m *Machine[S, E, C]) evalGuard(g Ref, entity C) (ok bool, err error) { err = &GuardPanicError{GuardName: g.Name, Recovered: r} } }() - return fn(GuardCtx[C]{Entity: entity, Params: g.Params}), nil + return fn(GuardCtx[C]{Entity: entity, Params: g.Params, Ctx: ctx}), nil } // evalAction resolves and runs an action ref, recovering a panicking host action // into a typed ActionPanicError so a faulty action fails the fire deterministically // rather than crashing Fire. Kernel built-in actions (e.g. the Cancel built-in) are // handled directly without consulting the host registry. -func (m *Machine[S, E, C]) evalAction(a Ref, entity C) (eff Effect, err error) { +func (m *Machine[S, E, C]) evalAction(ctx context.Context, a Ref, entity C) (eff Effect, err error) { if isBuiltinAction(a.Name) { return evalBuiltinAction(a) } @@ -1131,7 +1151,7 @@ func (m *Machine[S, E, C]) evalAction(a Ref, entity C) (eff Effect, err error) { err = &ActionPanicError{ActionName: a.Name, Recovered: r} } }() - return fn(ActionCtx[C]{Entity: entity, Params: a.Params}) + return fn(ActionCtx[C]{Entity: entity, Params: a.Params, Ctx: ctx}) } // applyAssigns folds a list of assign refs onto cur in declaration order, each @@ -1141,7 +1161,7 @@ func (m *Machine[S, E, C]) evalAction(a Ref, entity C) (eff Effect, err error) { // error that stops the commit, mirroring a guard panic. func (i *Instance[S, E, C]) applyAssigns(refs []Ref, cur C, eventData any, tr *Trace) (C, string, error) { for _, a := range refs { - next, err := i.machine.evalAssign(a, cur, eventData) + next, err := i.machine.evalAssign(i.callbackContext(), a, cur, eventData) if err != nil { tr.recordAssign(a.Name) return cur, a.Name, err @@ -1155,7 +1175,7 @@ func (i *Instance[S, E, C]) applyAssigns(refs []Ref, cur C, eventData any, tr *T // evalAssign resolves and runs an assign ref, folding the prior context into the // next. A reducer panic is recovered into a typed AssignPanicError so a faulty // reducer fails the commit deterministically rather than corrupting context. -func (m *Machine[S, E, C]) evalAssign(a Ref, cur C, eventData any) (next C, err error) { +func (m *Machine[S, E, C]) evalAssign(ctx context.Context, a Ref, cur C, eventData any) (next C, err error) { fn, found := m.assigns[a.Name] if !found { return cur, &AssignPanicError{AssignName: a.Name, Recovered: "unbound assign at fire time"} @@ -1166,7 +1186,7 @@ func (m *Machine[S, E, C]) evalAssign(a Ref, cur C, eventData any) (next C, err err = &AssignPanicError{AssignName: a.Name, Recovered: r} } }() - return fn(AssignCtx[C]{Entity: cur, Event: eventData, Params: a.Params}), nil + return fn(AssignCtx[C]{Entity: cur, Event: eventData, Params: a.Params, Ctx: ctx}), nil } // projectTransition erases the generic parameters of a Transition into the diff --git a/state/guard.go b/state/guard.go index b95883ed..36160700 100644 --- a/state/guard.go +++ b/state/guard.go @@ -374,7 +374,7 @@ func (i *Instance[S, E, C]) evalGuardExpr(g *GuardNode[S], entity C, tr *Trace) if tr != nil { tr.recordGuard(name) } - ok, err := i.machine.evalGuard(*g.Ref, entity) + ok, err := i.machine.evalGuard(i.callbackContext(), *g.Ref, entity) if err != nil { return guardEval{err: err} } diff --git a/state/kernel.go b/state/kernel.go index 691bc721..00255be2 100644 --- a/state/kernel.go +++ b/state/kernel.go @@ -558,6 +558,12 @@ type BatchResult[S comparable] struct { type GuardCtx[C any] struct { Entity C Params map[string]any + // Ctx is the context of the Fire running this callback, or of the Cast that + // settles the initial state (see WithContext); context.Background() outside one, + // such as during PlanPath. It is never nil. Pass it to any call the callback makes + // back into the host that drives this instance, so the host can tell the call + // comes from inside a step. + Ctx context.Context } // GuardFn is a pure predicate on the entity. @@ -567,6 +573,12 @@ type GuardFn[C any] func(ctx GuardCtx[C]) bool type ActionCtx[C any] struct { Entity C Params map[string]any + // Ctx is the context of the Fire running this callback, or of the Cast that + // settles the initial state (see WithContext); context.Background() outside one, + // such as during PlanPath. It is never nil. Pass it to any call the callback makes + // back into the host that drives this instance, so the host can tell the call + // comes from inside a step. + Ctx context.Context } // ActionFn produces an effect (or error) for a transition. @@ -1688,7 +1700,9 @@ func (m *Machine[S, E, C]) Cast(entity C, opts ...CastOption[S]) *Instance[S, E, // done — so the starting state behaves like every state Fire enters. The // resulting effects are buffered on the instance (InitialEffects/StartEffects); // SCXML requires this entry to run before the first external event. + inst.callCtx = cfg.ctx inst.settleInitial(current) + inst.callCtx = nil return inst } @@ -1746,6 +1760,9 @@ type Instance[S comparable, E comparable, C any] struct { // payload from the absent default (the boxed triggering event). fireData any hasFireData bool + // callCtx is the context of the Fire (or the settling Cast) in progress, handed to + // every guard, action and reducer as its Ctx. It is nil between calls. + callCtx context.Context // clock is the time seam a delayed-transition driver reads to schedule // `after` timers. It is never consulted by the pure Fire step — only by a // Scheduler/host driver wired to this instance — so Fire stays clock-free. diff --git a/state/options.go b/state/options.go index fdd419ae..c5f4bfa6 100644 --- a/state/options.go +++ b/state/options.go @@ -1,6 +1,9 @@ package state -import "log/slog" +import ( + "context" + "log/slog" +) // This file declares the functional-option types for every public constructor // and operation, per the kernel's functional-options convention. Each is an @@ -174,6 +177,15 @@ type castConfig[S comparable] struct { traceFull bool // set by WithFullTrace histLimit int // set by WithHistory (positive value enables ring buffer) histUnbounded bool // set by WithUnboundedHistory + ctx context.Context +} + +// WithContext sets the context Cast hands to the reducers that settle the initial +// state, as their AssignCtx.Ctx. A host that casts an instance inside its own step, +// such as an actor factory, passes that step's context. When omitted, it is +// context.Background(). +func WithContext[S comparable](ctx context.Context) CastOption[S] { + return func(c *castConfig[S]) { c.ctx = ctx } } // WithClock injects the time seam an instance's delayed-transition driver uses. diff --git a/state/parallel.go b/state/parallel.go index 7330eee9..f0ff788c 100644 --- a/state/parallel.go +++ b/state/parallel.go @@ -193,7 +193,7 @@ func (i *Instance[S, E, C]) fireRegion( pass := true for _, g := range t.Guards { tr.recordGuard(g.Name) - okg, gErr := m.evalGuard(g, entity) + okg, gErr := m.evalGuard(i.callbackContext(), g, entity) if gErr != nil { return true, nil, gErr } @@ -609,7 +609,7 @@ func (i *Instance[S, E, C]) fireFromState(ctx context.Context, start S, event E, pass := true for _, g := range t.Guards { tr.recordGuard(g.Name) - okg, gErr := m.evalGuard(g, entity) + okg, gErr := m.evalGuard(i.callbackContext(), g, entity) if gErr != nil { tr.Outcome = OutcomeGuardPanic return FireResult[S]{NewState: from, Trace: tr, Err: gErr} diff --git a/state/plan.go b/state/plan.go index 35f1f39b..46035e96 100644 --- a/state/plan.go +++ b/state/plan.go @@ -1,5 +1,7 @@ package state +import "context" + // PlanPath returns the shortest event sequence that drives an instance from the // `from` state to the `to` state, found by breadth-first search over the static // transition graph. Guards are honored against the supplied entity, so the @@ -68,7 +70,7 @@ func (m *Machine[S, E, C]) PlanPath(from, to S, entity C, opts ...PlanOption) ([ // not traversable), keeping PlanPath pure and non-panicking. func (m *Machine[S, E, C]) guardsPass(t *Transition[S, E, C], entity C) bool { for _, g := range t.Guards { - ok, err := m.evalGuard(g, entity) + ok, err := m.evalGuard(context.Background(), g, entity) if err != nil || !ok { return false } @@ -91,7 +93,7 @@ func (m *Machine[S, E, C]) planGuardExpr(g *GuardNode[S], entity C) bool { } switch g.Op { case GuardLeaf: - ok, err := m.evalGuard(*g.Ref, entity) + ok, err := m.evalGuard(context.Background(), *g.Ref, entity) return err == nil && ok case GuardStateIn: // Configuration-dependent: assume satisfiable for planning. From 252ca4b2f3928045a3f8c05d778ea01dc675bd05 Mon Sep 17 00:00:00 2001 From: Ray Karnes Date: Tue, 10 Nov 2026 12:58:12 -0700 Subject: [PATCH 2/9] ci: skip CI and CodeQL on draft PRs A draft PR now runs only the gate, which fails with a note that CI runs once the PR is marked ready. Marking it ready (ready_for_review) runs the full CI and CodeQL, as does every later push to a ready PR. The gate fails rather than skips on a draft: a skipped required check counts as passing for that commit, so a PR could otherwise be marked ready and merged before its CI had run. Signed-off-by: Ray Karnes --- .github/workflows/ci.yml | 13 +++++++++++++ .github/workflows/codeql.yml | 9 ++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index baa938c1..38847e61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,12 @@ name: CI +# A draft PR runs only the gate, which fails until the PR is marked ready, so work +# under review spends no CI time. Marking it ready (ready_for_review) runs the full +# CI, as does every later push to a ready PR. on: pull_request: branches: [main] + types: [opened, synchronize, reopened, ready_for_review] permissions: contents: read @@ -23,6 +27,7 @@ jobs: # lint/test/vuln/coverage/bench jobs entirely; the `gate` job below still runs # and is the single required status check, so such PRs stay mergeable. changes: + if: ${{ !github.event.pull_request.draft }} runs-on: ubuntu-latest outputs: code: ${{ steps.detect.outputs.code }} @@ -597,6 +602,14 @@ jobs: needs: [changes, lint, tests, sink, source, vuln, coverage, docs] runs-on: ubuntu-latest steps: + # On a draft every other job is skipped, and a skipped required check would + # count as passing for this commit. Fail instead, so a PR can't be marked ready + # and merged before its CI has run. + - name: Fail on a draft + if: ${{ github.event.pull_request.draft }} + run: | + echo "::error::Draft PR: CI runs when it's marked ready for review." + exit 1 - name: Fail if a required job did not pass if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }} run: | diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0a1255ae..4f9f6e68 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,13 +1,15 @@ name: CodeQL -# Static analysis (SAST) over the Go sources. Runs on every PR and on pushes to -# the default branch, plus a weekly scheduled scan so newly published query -# packs catch existing code. Results land in the code-scanning dashboard. +# Static analysis (SAST) over the Go sources. Runs on every ready PR (not a draft; +# marking a draft ready runs it) and on pushes to the default branch, plus a weekly +# scheduled scan so newly published query packs catch existing code. Results land +# in the code-scanning dashboard. on: push: branches: [ main ] pull_request: branches: [ main ] + types: [opened, synchronize, reopened, ready_for_review] schedule: - cron: '24 6 * * 1' @@ -22,6 +24,7 @@ concurrency: jobs: analyze: name: Analyze (Go) + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: ubuntu-latest permissions: # Required to upload CodeQL results to the code-scanning dashboard. From 1cf38618c609437cc968b86f866f990fb2c0ab15 Mon Sep 17 00:00:00 2001 From: Ray Karnes Date: Tue, 10 Nov 2026 13:01:03 -0700 Subject: [PATCH 3/9] fix(state): give bound guards and middleware's context to callbacks Review found that a guard registered with BindGuard was adapted with a background context, dropping the Fire's context the kernel now passes. Its EvalGuard now gets the Fire's context. Callbacks also see the context as the middleware chain passes it down, not only the one Fire was called with. Docs: WithContext reaches the guards and actions that settle the initial state as well as its reducers, and Cast settles to completion even if that context has ended. The ActorSystem doc says the parent Instance still needs one goroutine at a time. The site docs mention Ctx. Signed-off-by: Ray Karnes --- .../docs/authoring/actions-and-effects.md | 2 +- docs/src/content/docs/authoring/assigns.md | 2 +- state/CHANGELOG.md | 10 +++-- state/actor_system.go | 7 ++-- state/assign.go | 2 +- state/binding.go | 5 ++- state/callback_context_internal_test.go | 38 +++++++++++++++++++ state/callback_context_test.go | 32 +++++++++++++++- state/fire.go | 11 +++++- state/kernel.go | 4 +- state/options.go | 5 ++- 11 files changed, 98 insertions(+), 20 deletions(-) create mode 100644 state/callback_context_internal_test.go diff --git a/docs/src/content/docs/authoring/actions-and-effects.md b/docs/src/content/docs/authoring/actions-and-effects.md index 8feaaf24..b9e8435f 100644 --- a/docs/src/content/docs/authoring/actions-and-effects.md +++ b/docs/src/content/docs/authoring/actions-and-effects.md @@ -27,7 +27,7 @@ func notifyCustomer(ctx state.ActionCtx[Order]) (state.Effect, error) { } ``` -The crucial discipline: **the kernel performs no IO**. An action returns an `Effect` (plain data) and the kernel collects every effect into the `FireResult`. Your host loop reads that slice and dispatches the real work. This keeps `Fire` deterministic and pure: the same context and event always yield the same effects, which is what makes the engine testable, replayable, and safe to run anywhere. +The crucial discipline: **the kernel performs no IO**. An action returns an `Effect` (plain data) and the kernel collects every effect into the `FireResult`. Your host loop reads that slice and dispatches the real work. `ctx.Ctx` is the context of the `Fire` running the action. Pass it on if an action, guard or reducer ever has to call back into the host that drives the instance, so the host can tell the call comes from inside a step. This keeps `Fire` deterministic and pure: the same context and event always yield the same effects, which is what makes the engine testable, replayable, and safe to run anywhere. ```mermaid stateDiagram-v2 diff --git a/docs/src/content/docs/authoring/assigns.md b/docs/src/content/docs/authoring/assigns.md index 0a0a1035..c536da6e 100644 --- a/docs/src/content/docs/authoring/assigns.md +++ b/docs/src/content/docs/authoring/assigns.md @@ -34,7 +34,7 @@ Transition(Authorizing).On(Authorized). Assign("recordHold") ``` -The `AssignCtx` carries the prior `Entity`, the triggering `Event`, and the ref's static `Params`, so a reducer can fold event payloads into context (for example, folding a saga's refunded amount in via the `onDone` event). +The `AssignCtx` carries the prior `Entity`, the triggering `Event`, and the ref's static `Params`, so a reducer can fold event payloads into context (for example, folding a saga's refunded amount in via the `onDone` event). It also carries `Ctx`, the context of the `Fire` running the reducer, as guards and actions do in `GuardCtx` and `ActionCtx`. ```mermaid stateDiagram-v2 diff --git a/state/CHANGELOG.md b/state/CHANGELOG.md index 0632e359..cc44a452 100644 --- a/state/CHANGELOG.md +++ b/state/CHANGELOG.md @@ -18,10 +18,12 @@ counts as an additive (minor) versus breaking (major) change. Use the - **Callbacks get the step's context.** `GuardCtx`, `ActionCtx` and `AssignCtx` have a new `Ctx` field: the context the `Fire` was given, so a callback can pass it to any call it makes back into the host driving the instance. Outside a `Fire`, such as during - `PlanPath`, it's `context.Background()`, never nil. The in-process bindings pass their - `EvalGuard`, `EvalAction` and `EvalAssign` context through. -- **`WithContext`** sets the context `Cast` gives the reducers that settle the initial - state. + `PlanPath`, it's `context.Background()`, never nil. Callbacks see the context as + the middleware chain passes it down. The in-process bindings pass their `EvalGuard`, + `EvalAction` and `EvalAssign` context through, and a binding registered with + `BindGuard` gets the Fire's context instead of a background one. +- **`WithContext`** sets the context `Cast` gives the guards, actions and reducers that + settle the initial state. - **`ActorSystem.RegisterContext`** registers a `ContextActorBehavior`, an actor factory that gets the context of the step spawning or restoring the actor, so it can `Cast` its child `WithContext`. A child's own callbacks already get the context its diff --git a/state/actor_system.go b/state/actor_system.go index c4195d37..dd44e342 100644 --- a/state/actor_system.go +++ b/state/actor_system.go @@ -49,7 +49,7 @@ type ActorBehavior func(input map[string]any) (ActorInstance, error) // ContextActorBehavior is an ActorBehavior that also gets the context of the step // spawning the actor: the parent's Fire, or the host call that absorbed the spawn. A -// behavior that Casts its child passes it on with WithContext, so the reducers that +// behavior that Casts its child passes it on with WithContext, so the callbacks that // settle the child's initial state see it too. Register one with RegisterContext. type ContextActorBehavior func(ctx context.Context, input map[string]any) (ActorInstance, error) @@ -122,8 +122,9 @@ const parentActorID = "\x00parent" // ActorSystem is the reusable host-driver that turns the kernel's SpawnActor / // StopActor effects into running child-machine actors, owns each actor's mailbox, // routes delivered events into mailboxes, steps actors via Fire, and re-fires the -// parent's onDone / onError when a child completes or fails. It is -// concurrency-safe. Construct one per parent instance with NewActorSystem, then +// parent's onDone / onError when a child completes or fails. Its own state is +// concurrency-safe, but settling a child Fires the parent Instance, which is not: +// drive the system and the parent from one goroutine at a time. Construct one per parent instance with NewActorSystem, then // Register the child-machine behaviors that resolve SpawnActor Src refs; drive it // by passing each Fire's effects (and the parent's StartEffects) to Absorb, and // step actors with Deliver / Step. diff --git a/state/assign.go b/state/assign.go index 2a8a5a35..aaa6777c 100644 --- a/state/assign.go +++ b/state/assign.go @@ -34,7 +34,7 @@ type AssignCtx[C any] struct { Params map[string]any // Ctx is the context of the Fire running this callback, or of the Cast that // settles the initial state (see WithContext); context.Background() outside one, - // such as during PlanPath. It is never nil. Pass it to any call the callback makes + // such as a guard PlanPath evaluates. It is never nil. Pass it to any call the callback makes // back into the host that drives this instance, so the host can tell the call // comes from inside a step. Ctx context.Context diff --git a/state/binding.go b/state/binding.go index 22aa4bf1..b485cfae 100644 --- a/state/binding.go +++ b/state/binding.go @@ -176,13 +176,14 @@ func (r *Registry[C]) bindGuard(name string, b GuardBinding[C]) { // (which reads r.guards) finds it; the binding is also recorded on the parallel // binding seam so a future out-of-process transport can swap it under the same name. // -// EvalGuard is called with a background context and the in-process context view; +// EvalGuard is called with the context of the Fire evaluating the guard (the +// GuardCtx.Ctx the kernel hands the adapter) and the in-process context view; // an error it returns is treated as a false verdict, matching how a Go guard that // cannot decide yields false rather than transitioning. An optional Describe option // adds palette metadata exactly as Guard does. func (r *Registry[C]) BindGuard(name string, b GuardBinding[C], opts ...DescribeOption) *Registry[C] { r.guards[name] = func(gctx GuardCtx[C]) bool { - res, err := b.EvalGuard(context.Background(), GuardRequest[C]{ + res, err := b.EvalGuard(nonNil(gctx.Ctx), GuardRequest[C]{ Name: name, Params: gctx.Params, Context: newInProcessView(gctx.Entity), diff --git a/state/callback_context_internal_test.go b/state/callback_context_internal_test.go new file mode 100644 index 00000000..6d08b9ad --- /dev/null +++ b/state/callback_context_internal_test.go @@ -0,0 +1,38 @@ +package state + +import ( + "context" + "testing" +) + +// ctxTagKey tags the context a test passes to Fire. +type ctxTagKey struct{} + +// tagGuard is a GuardBinding that records the tag on the context EvalGuard gets. +type tagGuard struct{ seen *string } + +func (g tagGuard) EvalGuard(ctx context.Context, _ GuardRequest[bindOrder]) (GuardResult, error) { + *g.seen, _ = ctx.Value(ctxTagKey{}).(string) + return GuardResult{OK: true}, nil +} + +// TestBindGuard_GetsTheFiresContext proves a guard registered with BindGuard gets the +// Fire's context through EvalGuard, not a background one. +func TestBindGuard_GetsTheFiresContext(t *testing.T) { + var seen string + b := ForgeFor[bindOrder]("bound") + b.reg.BindGuard("bound", tagGuard{seen: &seen}) + m := b. + State("from"). + Transition("from").On("go").GoTo("to").When("bound"). + State("to"). + Initial("from"). + Quench() + ctx := context.WithValue(context.Background(), ctxTagKey{}, "fire") + if res := m.Cast(bindOrder{}, WithInitialState("from")).Fire(ctx, "go"); res.Err != nil { + t.Fatalf("Fire: %v", res.Err) + } + if seen != "fire" { + t.Fatalf("the bound guard's EvalGuard saw %q, want fire", seen) + } +} diff --git a/state/callback_context_test.go b/state/callback_context_test.go index 28614319..193bbca5 100644 --- a/state/callback_context_test.go +++ b/state/callback_context_test.go @@ -85,9 +85,9 @@ func TestCallbacks_CastWithContext(t *testing.T) { if seen.Entry != "cast" { t.Fatalf("the entry reducer saw %q at Cast, want cast", seen.Entry) } - plain := &ctxSeen{} + plain := &ctxSeen{Entry: "unset"} ctxMachine().Cast(plain, state.WithInitialState("idle")) - if plain.Entry != "" { // context.Background(): non-nil, no tag + if plain.Entry != "" { // ran, with context.Background(): non-nil, no tag t.Fatalf("the entry reducer saw %q at a plain Cast, want a background context", plain.Entry) } } @@ -163,3 +163,31 @@ func TestActorSystem_RegisterContext_GetsTheSpawnContext(t *testing.T) { t.Fatalf("the child's callbacks saw %+v, want deliver", *child) } } + +// TestCallbacks_SeeTheContextMiddlewarePasses proves callbacks see the context the +// middleware chain hands down, not only the one Fire was called with. +func TestCallbacks_SeeTheContextMiddlewarePasses(t *testing.T) { + retag := func(next state.FireFunc[string, string, *ctxSeen]) state.FireFunc[string, string, *ctxSeen] { + return func(ctx context.Context, ev string) state.FireResult[string] { + return next(context.WithValue(ctx, ctxKey{}, "middleware"), ev) + } + } + seen := &ctxSeen{} + m := state.ForgeFor[*ctxSeen]("mw"). + Guard("g", func(c state.GuardCtx[*ctxSeen]) bool { + c.Entity.Guard = tagOf(c.Ctx) + return true + }). + State("idle"). + State("busy"). + Initial("idle"). + Transition("idle").On("go").GoTo("busy").When("g"). + Use(retag). + Quench() + if res := m.Cast(seen, state.WithInitialState("idle")).Fire(tagged("fire"), "go"); res.Err != nil { + t.Fatalf("Fire: %v", res.Err) + } + if seen.Guard != "middleware" { + t.Fatalf("the guard saw %q, want the middleware's context", seen.Guard) + } +} diff --git a/state/fire.go b/state/fire.go index 866e7672..a1fc6b54 100644 --- a/state/fire.go +++ b/state/fire.go @@ -31,8 +31,10 @@ func (i *Instance[S, E, C]) Fire(ctx context.Context, event E, opts ...FireOptio // when the macrostep settles, so it never leaks into a later Fire. i.fireData = cfg.eventData i.hasFireData = cfg.hasData - // Guards, actions and reducers see this Fire's context as their Ctx. A Fire nested - // inside another on the same instance restores the outer one when it returns. + // Guards, actions and reducers see this Fire's context as their Ctx (as the + // middleware chain passes it down). A Fire nested inside another on the same + // instance restores the outer Fire's context when it returns; it doesn't restore + // the outer Fire's other per-Fire state, such as its WithEventData payload. outerCtx := i.callCtx i.callCtx = ctx defer func() { i.callCtx = outerCtx }() @@ -63,6 +65,9 @@ func (i *Instance[S, E, C]) eventData(event E) any { // outside-in, so the first-installed middleware is the outermost wrapper. func (i *Instance[S, E, C]) fireWithMiddleware(ctx context.Context, event E) FireResult[S] { next := func(ctx context.Context, event E) FireResult[S] { + // Callbacks see the context the middleware chain passes down, which may carry + // values or a deadline a middleware added. + i.callCtx = ctx return i.fireCore(ctx, event) } mws := i.machine.middleware @@ -379,6 +384,8 @@ func (i *Instance[S, E, C]) settleInitial(current S) { // first external event. i.entity = cur res := FireResult[S]{NewState: i.current, Effects: effects, Trace: tr} + // The settle runs to completion whatever WithContext's context says: a Cast has + // no result to abandon it with. Its callbacks still see that context as their Ctx. res = i.runToCompletion(context.Background(), res) i.initialEffects = res.Effects i.initialErr = res.Err diff --git a/state/kernel.go b/state/kernel.go index 00255be2..c0237618 100644 --- a/state/kernel.go +++ b/state/kernel.go @@ -560,7 +560,7 @@ type GuardCtx[C any] struct { Params map[string]any // Ctx is the context of the Fire running this callback, or of the Cast that // settles the initial state (see WithContext); context.Background() outside one, - // such as during PlanPath. It is never nil. Pass it to any call the callback makes + // such as a guard PlanPath evaluates. It is never nil. Pass it to any call the callback makes // back into the host that drives this instance, so the host can tell the call // comes from inside a step. Ctx context.Context @@ -575,7 +575,7 @@ type ActionCtx[C any] struct { Params map[string]any // Ctx is the context of the Fire running this callback, or of the Cast that // settles the initial state (see WithContext); context.Background() outside one, - // such as during PlanPath. It is never nil. Pass it to any call the callback makes + // such as a guard PlanPath evaluates. It is never nil. Pass it to any call the callback makes // back into the host that drives this instance, so the host can tell the call // comes from inside a step. Ctx context.Context diff --git a/state/options.go b/state/options.go index c5f4bfa6..f0cf68dd 100644 --- a/state/options.go +++ b/state/options.go @@ -180,8 +180,9 @@ type castConfig[S comparable] struct { ctx context.Context } -// WithContext sets the context Cast hands to the reducers that settle the initial -// state, as their AssignCtx.Ctx. A host that casts an instance inside its own step, +// WithContext sets the context Cast hands to the guards, actions and reducers that +// settle the initial state, as their Ctx. Cast settles the state to completion +// even if the context has ended. A host that casts an instance inside its own step, // such as an actor factory, passes that step's context. When omitted, it is // context.Background(). func WithContext[S comparable](ctx context.Context) CastOption[S] { From 1aea966e06598f378d85acbb6c7563c71d4c0261 Mon Sep 17 00:00:00 2001 From: Ray Karnes Date: Tue, 10 Nov 2026 13:14:11 -0700 Subject: [PATCH 4/9] docs(state): cover Ctx in every callback doc, and pin it at Cast and in regions Review follow-ups: - Cast clears the callbacks' context with defer, as Fire does. - Tests: an eventless transition settling during Cast, and a transition inside a parallel region, see the expected context. - Ctx's doc no longer suggests PlanPath runs actions or reducers. - The guards and actors pages mention Ctx and RegisterContext, the deferred-surface notes record Cast's WithContext, and CONTRIBUTING says CI runs on ready PRs. Signed-off-by: Ray Karnes --- CONTRIBUTING.md | 5 +- docs/src/content/docs/authoring/actors.md | 2 +- docs/src/content/docs/authoring/guards.md | 2 +- state/CHANGELOG.md | 6 ++- state/assign.go | 4 +- state/callback_context_test.go | 61 +++++++++++++++++++++++ state/doc.go | 4 +- state/fire.go | 8 +++ state/kernel.go | 8 ++- 9 files changed, 85 insertions(+), 15 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d9e7a302..5141cf0f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -136,7 +136,8 @@ Run `mage -l` to list everything. Each target iterates the suite's modules. Before you push, **tests, lint, vet, and `govulncheck` must all pass.** The quickest way to verify locally is `mage check`. CI runs the same gates across a -OS matrix on every PR. +OS matrix on every ready PR. A draft PR runs only the `gate` check, which fails until +the PR is marked ready for review; marking it ready runs the full CI. Docs-only pull requests (changing only `*.md`, `docs/`, `LICENSE`, or `NOTICE`) skip the Go matrix automatically and are gated solely by the aggregate `gate` @@ -144,7 +145,7 @@ check, so a README tweak doesn't spend the full test run. ## Performance -Performance regressions fail the build. On every pull request, CI runs the +Performance regressions fail the build. On every ready pull request, CI runs the `state` benchmarks on **both your branch head and the PR base, on the same runner**, then `benchstat`-diffs the two. Running both refs on one machine cancels machine-to-machine variance, so a genuine regression stands apart from diff --git a/docs/src/content/docs/authoring/actors.md b/docs/src/content/docs/authoring/actors.md index 08917a76..06cb13c1 100644 --- a/docs/src/content/docs/authoring/actors.md +++ b/docs/src/content/docs/authoring/actors.md @@ -10,7 +10,7 @@ sidebar: When a state needs to delegate work to a self-contained, concurrently-living unit, forge an **actor**: a child state machine the parent supervises through messages. Each actor has its own `(S, E, C)` types, runs independently, and reports back to the parent on completion. -An `ActorSystem` is the registry and supervisor. You bind a *behavior* (a factory that casts a fresh child instance) under a name, then a state invokes that name to spawn one. +An `ActorSystem` is the registry and supervisor. You bind a *behavior* (a factory that casts a fresh child instance) under a name, then a state invokes that name to spawn one. A behavior registered with `RegisterContext` also gets the context of the step spawning the actor, to pass to its child's `Cast` with `state.WithContext`. ```go sys := state.NewActorSystem(orderInstance). diff --git a/docs/src/content/docs/authoring/guards.md b/docs/src/content/docs/authoring/guards.md index 98a60bc4..cb46d4e7 100644 --- a/docs/src/content/docs/authoring/guards.md +++ b/docs/src/content/docs/authoring/guards.md @@ -37,7 +37,7 @@ Transition(Authorizing).On(Authorized). GoTo(Active).Assign("recordHold") ``` -Field predicates support `.Eq`, `.Ne`, `.Lt`, `.Le`, `.Gt`, `.Ge`, and `.In`, with operands built from `state.Str`, `state.Int`, `state.Float`, and `state.Bool`. `state.StateIn(s)` lets a guard depend on the active configuration. When a transition carries *both* `When` and `WhenExpr`, every named guard **and** the whole expression must evaluate true. +Field predicates support `.Eq`, `.Ne`, `.Lt`, `.Le`, `.Gt`, `.Ge`, and `.In`, with operands built from `state.Str`, `state.Int`, `state.Float`, and `state.Bool`. `state.StateIn(s)` lets a guard depend on the active configuration. A named guard also gets `Ctx`, the context of the `Fire` evaluating it, to pass on if it ever has to call back into the host driving the instance. When a transition carries *both* `When` and `WhenExpr`, every named guard **and** the whole expression must evaluate true. ```mermaid stateDiagram-v2 diff --git a/state/CHANGELOG.md b/state/CHANGELOG.md index cc44a452..c5a7b050 100644 --- a/state/CHANGELOG.md +++ b/state/CHANGELOG.md @@ -21,7 +21,8 @@ counts as an additive (minor) versus breaking (major) change. Use the `PlanPath`, it's `context.Background()`, never nil. Callbacks see the context as the middleware chain passes it down. The in-process bindings pass their `EvalGuard`, `EvalAction` and `EvalAssign` context through, and a binding registered with - `BindGuard` gets the Fire's context instead of a background one. + `BindGuard` gets the context of the Fire evaluating it, where it used to get a + background one. - **`WithContext`** sets the context `Cast` gives the guards, actions and reducers that settle the initial state. - **`ActorSystem.RegisterContext`** registers a `ContextActorBehavior`, an actor factory @@ -725,7 +726,8 @@ locks today's surface precisely so these can be added without a major bump. - `context.Context` on `Verify` / `PlanPath` / `Cast` / `Restore` and the snapshot codecs: these take no `context.Context` at v1. A future `WithContext` option on each existing option tail threads cancellation and deadlines without changing a - signature. (`RestoreActors` already takes a `context.Context`.) + signature. (`RestoreActors` already takes a `context.Context`. Unreleased: `Cast`'s + `WithContext` now gives the callbacks that settle its initial state a context.) - Relaxing `SnapshotActors`' non-quiesced refusal: it refuses to snapshot a non-quiesced actor system by design. A future `SnapshotActorsOption` lets a host opt into a defined non-quiesced capture; the strict refusal stays the default. diff --git a/state/assign.go b/state/assign.go index aaa6777c..f905416e 100644 --- a/state/assign.go +++ b/state/assign.go @@ -33,8 +33,8 @@ type AssignCtx[C any] struct { Event any Params map[string]any // Ctx is the context of the Fire running this callback, or of the Cast that - // settles the initial state (see WithContext); context.Background() outside one, - // such as a guard PlanPath evaluates. It is never nil. Pass it to any call the callback makes + // settles the initial state (see WithContext); context.Background() when there's + // none, such as a Cast without WithContext. It is never nil. Pass it to any call the callback makes // back into the host that drives this instance, so the host can tell the call // comes from inside a step. Ctx context.Context diff --git a/state/callback_context_test.go b/state/callback_context_test.go index 193bbca5..9d3d9755 100644 --- a/state/callback_context_test.go +++ b/state/callback_context_test.go @@ -191,3 +191,64 @@ func TestCallbacks_SeeTheContextMiddlewarePasses(t *testing.T) { t.Fatalf("the guard saw %q, want the middleware's context", seen.Guard) } } + +// TestCallbacks_EventlessSettleAtCastGetsTheCastContext proves a guard and reducer on an +// eventless transition that settles during Cast see WithContext's context. +func TestCallbacks_EventlessSettleAtCastGetsTheCastContext(t *testing.T) { + m := state.ForgeFor[*ctxSeen]("eventless"). + Guard("g", func(c state.GuardCtx[*ctxSeen]) bool { + c.Entity.Guard = tagOf(c.Ctx) + return true + }). + Reducer("r", func(c state.AssignCtx[*ctxSeen]) *ctxSeen { + c.Entity.Assign = tagOf(c.Ctx) + return c.Entity + }). + State("start"). + Always().GoTo("settled").When("g").Assign("r"). + State("settled"). + Initial("start"). + Quench() + seen := &ctxSeen{} + inst := m.Cast(seen, state.WithInitialState("start"), state.WithContext[string](tagged("cast"))) + if inst.Current() != "settled" { + t.Fatalf("Cast settled in %q, want settled", inst.Current()) + } + if seen.Guard != "cast" || seen.Assign != "cast" { + t.Fatalf("the eventless guard saw %q and reducer %q, want cast", seen.Guard, seen.Assign) + } +} + +// TestCallbacks_ParallelRegionGetsTheFiresContext proves a guard and reducer on a +// transition inside a parallel region see the Fire's context. +func TestCallbacks_ParallelRegionGetsTheFiresContext(t *testing.T) { + m := state.ForgeFor[*ctxSeen]("regions"). + Guard("g", func(c state.GuardCtx[*ctxSeen]) bool { + c.Entity.Guard = tagOf(c.Ctx) + return true + }). + Reducer("r", func(c state.AssignCtx[*ctxSeen]) *ctxSeen { + c.Entity.Assign = tagOf(c.Ctx) + return c.Entity + }). + SuperState("live"). + Region("r1"). + Initial("r1a"). + SubState("r1a").On("tick").GoTo("r1b").When("g").Assign("r"). + SubState("r1b"). + EndRegion(). + Region("r2"). + Initial("r2a"). + SubState("r2a"). + EndRegion(). + EndSuperState(). + Initial("live"). + Quench() + seen := &ctxSeen{} + if res := m.Cast(seen, state.WithInitialState("live")).Fire(tagged("fire"), "tick"); res.Err != nil { + t.Fatalf("Fire: %v", res.Err) + } + if seen.Guard != "fire" || seen.Assign != "fire" { + t.Fatalf("the region's guard saw %q and reducer %q, want fire", seen.Guard, seen.Assign) + } +} diff --git a/state/doc.go b/state/doc.go index 1d9064cf..bbebc2ca 100644 --- a/state/doc.go +++ b/state/doc.go @@ -426,8 +426,8 @@ // so adopting it never breaks a v1 caller. These are intentional boundaries, not // oversights: a payload-carrying raise, multi-target transitions (a .ToAll builder // over an optional Targets field, leaving To single-target), a context.Context -// option on Verify/PlanPath/Cast/Restore and the snapshot codecs (RestoreActors -// already takes one), an opt-in to snapshot a non-quiesced actor tree (today +// option on Verify/PlanPath/Restore and the snapshot codecs (RestoreActors already +// takes one, and Cast has WithContext for the callbacks that settle its initial state), an opt-in to snapshot a non-quiesced actor tree (today // SnapshotActors refuses with a typed error), a finer per-microstep Inspector // cadence (the v1 contract fires the Inspector once per macrostep), and a metrics // Meter seam alongside the logger and inspector seams. The frozen v1 surface is diff --git a/state/fire.go b/state/fire.go index a1fc6b54..69a4cccf 100644 --- a/state/fire.go +++ b/state/fire.go @@ -1109,6 +1109,14 @@ func (i *Instance[S, E, C]) runActions(refs []Ref, entity C, tr *Trace) (effects return effects, "", nil } +// settleWith settles the initial state with ctx as its callbacks' context, clearing it +// however the settle ends. +func (i *Instance[S, E, C]) settleWith(ctx context.Context, current S) { + i.callCtx = ctx + defer func() { i.callCtx = nil }() + i.settleInitial(current) +} + // callbackContext returns the context a guard, action or reducer gets as its Ctx: the // running Fire's or settling Cast's, or context.Background() outside one. func (i *Instance[S, E, C]) callbackContext() context.Context { diff --git a/state/kernel.go b/state/kernel.go index c0237618..1ba1b65c 100644 --- a/state/kernel.go +++ b/state/kernel.go @@ -574,8 +574,8 @@ type ActionCtx[C any] struct { Entity C Params map[string]any // Ctx is the context of the Fire running this callback, or of the Cast that - // settles the initial state (see WithContext); context.Background() outside one, - // such as a guard PlanPath evaluates. It is never nil. Pass it to any call the callback makes + // settles the initial state (see WithContext); context.Background() when there's + // none, such as a Cast without WithContext. It is never nil. Pass it to any call the callback makes // back into the host that drives this instance, so the host can tell the call // comes from inside a step. Ctx context.Context @@ -1700,9 +1700,7 @@ func (m *Machine[S, E, C]) Cast(entity C, opts ...CastOption[S]) *Instance[S, E, // done — so the starting state behaves like every state Fire enters. The // resulting effects are buffered on the instance (InitialEffects/StartEffects); // SCXML requires this entry to run before the first external event. - inst.callCtx = cfg.ctx - inst.settleInitial(current) - inst.callCtx = nil + inst.settleWith(cfg.ctx, current) return inst } From 2f3c928d5fa65755b2cad094ee6a91d85eb8dc2e Mon Sep 17 00:00:00 2001 From: Ray Karnes Date: Tue, 10 Nov 2026 15:41:14 -0700 Subject: [PATCH 5/9] fix(state): stop a Fire when a bound guard fails on an ended context Review follow-ups: - A guard registered with BindGuard whose EvalGuard fails after the Fire's context ended now stops the Fire with the context's error. It was read as false, so another transition was chosen and its callbacks ran under the ended context until the next cancellation check rolled the Fire back. An eventless transition chosen while the context ended isn't committed either. The CHANGELOG lists this under Changed. - Ctx's doc says not to branch on it: a replay runs the same callbacks under a different context. - A test covers RestoreActors through RegisterContext. - The deferred-surface note no longer reads as a promise that clashes with Cast's WithContext. Signed-off-by: Ray Karnes --- state/CHANGELOG.md | 19 ++++--- state/assign.go | 4 +- state/binding.go | 14 +++++- state/callback_context_internal_test.go | 66 +++++++++++++++++++++++++ state/callback_context_test.go | 16 ++++++ state/fire.go | 29 ++++++++++- state/kernel.go | 8 ++- 7 files changed, 142 insertions(+), 14 deletions(-) diff --git a/state/CHANGELOG.md b/state/CHANGELOG.md index c5a7b050..85865f45 100644 --- a/state/CHANGELOG.md +++ b/state/CHANGELOG.md @@ -20,9 +20,8 @@ counts as an additive (minor) versus breaking (major) change. Use the makes back into the host driving the instance. Outside a `Fire`, such as during `PlanPath`, it's `context.Background()`, never nil. Callbacks see the context as the middleware chain passes it down. The in-process bindings pass their `EvalGuard`, - `EvalAction` and `EvalAssign` context through, and a binding registered with - `BindGuard` gets the context of the Fire evaluating it, where it used to get a - background one. + `EvalAction` and `EvalAssign` context through. Don't branch on the context: a + replay runs the same callbacks under a different one. - **`WithContext`** sets the context `Cast` gives the guards, actions and reducers that settle the initial state. - **`ActorSystem.RegisterContext`** registers a `ContextActorBehavior`, an actor factory @@ -33,6 +32,12 @@ counts as an additive (minor) versus breaking (major) change. Use the ### Changed - Requires Go 1.27.3. +- **A guard registered with `BindGuard` gets the Fire's context** in `EvalGuard`, where it + used to get a background one. An error it returns after that context ended no longer + counts as a false verdict: the Fire fails with the context's error at once. Before, + another transition could be chosen and its actions and reducers run under the ended + context, until the next cancellation check rolled the Fire back. An eventless + transition chosen while the context ended isn't committed either. ## [1.0.0] — 2026-06-13 @@ -724,10 +729,10 @@ locks today's surface precisely so these can be added without a major bump. future `.ToAll(...)` builder, backed by an optional `Targets` IR field, expresses a fan-out; `To` keeps its current single-target meaning. - `context.Context` on `Verify` / `PlanPath` / `Cast` / `Restore` and the snapshot - codecs: these take no `context.Context` at v1. A future `WithContext` option on - each existing option tail threads cancellation and deadlines without changing a - signature. (`RestoreActors` already takes a `context.Context`. Unreleased: `Cast`'s - `WithContext` now gives the callbacks that settle its initial state a context.) + codecs: these take no `context.Context` at v1 for cancellation or deadlines. A + future option threads them without changing a signature. (`RestoreActors` already + takes a `context.Context`. Unreleased: `Cast`'s `WithContext` gives the callbacks that + settle its initial state a context to pass on, but doesn't make `Cast` cancellable.) - Relaxing `SnapshotActors`' non-quiesced refusal: it refuses to snapshot a non-quiesced actor system by design. A future `SnapshotActorsOption` lets a host opt into a defined non-quiesced capture; the strict refusal stays the default. diff --git a/state/assign.go b/state/assign.go index f905416e..433828e0 100644 --- a/state/assign.go +++ b/state/assign.go @@ -36,7 +36,9 @@ type AssignCtx[C any] struct { // settles the initial state (see WithContext); context.Background() when there's // none, such as a Cast without WithContext. It is never nil. Pass it to any call the callback makes // back into the host that drives this instance, so the host can tell the call - // comes from inside a step. + // comes from inside a step. Don't branch on it, its values, deadline or + // cancellation: a replay runs the same callbacks under a different context, and must + // reach the same result. Ctx context.Context } diff --git a/state/binding.go b/state/binding.go index b485cfae..dc5cbc47 100644 --- a/state/binding.go +++ b/state/binding.go @@ -179,16 +179,26 @@ func (r *Registry[C]) bindGuard(name string, b GuardBinding[C]) { // EvalGuard is called with the context of the Fire evaluating the guard (the // GuardCtx.Ctx the kernel hands the adapter) and the in-process context view; // an error it returns is treated as a false verdict, matching how a Go guard that -// cannot decide yields false rather than transitioning. An optional Describe option +// cannot decide yields false rather than transitioning. The exception is an error +// returned once that context has ended: the Fire then fails with the context's error, +// as it would at its next cancellation check, rather than take another transition. An optional Describe option // adds palette metadata exactly as Guard does. func (r *Registry[C]) BindGuard(name string, b GuardBinding[C], opts ...DescribeOption) *Registry[C] { r.guards[name] = func(gctx GuardCtx[C]) bool { - res, err := b.EvalGuard(nonNil(gctx.Ctx), GuardRequest[C]{ + ctx := nonNil(gctx.Ctx) + res, err := b.EvalGuard(ctx, GuardRequest[C]{ Name: name, Params: gctx.Params, Context: newInProcessView(gctx.Entity), }) if err != nil { + // A binding that failed once the Fire's context ended has no verdict. Reading it + // as false would choose another transition and run its callbacks under the ended + // context before the Fire's next cancellation check rolled it back. Surface the + // context's error instead, so the Fire stops here. + if cerr := ctx.Err(); cerr != nil { + panic(guardCanceled{err: cerr}) + } return false } return res.OK diff --git a/state/callback_context_internal_test.go b/state/callback_context_internal_test.go index 6d08b9ad..1f264308 100644 --- a/state/callback_context_internal_test.go +++ b/state/callback_context_internal_test.go @@ -2,6 +2,7 @@ package state import ( "context" + "errors" "testing" ) @@ -36,3 +37,68 @@ func TestBindGuard_GetsTheFiresContext(t *testing.T) { t.Fatalf("the bound guard's EvalGuard saw %q, want fire", seen) } } + +// cancelingGuard is a GuardBinding that ends the Fire's context while it evaluates, +// then fails, as a binding that honors cancellation would. +type cancelingGuard struct{ cancel context.CancelFunc } + +func (g cancelingGuard) EvalGuard(ctx context.Context, _ GuardRequest[bindOrder]) (GuardResult, error) { + g.cancel() + return GuardResult{}, ctx.Err() +} + +// TestBindGuard_CanceledMidEvaluation_FailsTheFire proves a bound guard that fails +// because the Fire's context ended stops the Fire with the context's error, on an +// event-driven transition and on an eventless one. The fallback transition's reducer +// never runs: the guard isn't read as false. (The cancellation check after the +// microstep would roll a committed fallback back, but only after its callbacks ran.) +func TestBindGuard_CanceledMidEvaluation_FailsTheFire(t *testing.T) { + tests := []struct { + name string + build func(*Builder[string, string, bindOrder]) *Builder[string, string, bindOrder] + }{ + {"event-driven", func(b *Builder[string, string, bindOrder]) *Builder[string, string, bindOrder] { + return b. + State("from"). + Transition("from").On("go").GoTo("guarded").When("bound"). + Transition("from").On("go").GoTo("fallback").Assign("ranFallback"). + State("guarded"). + State("fallback") + }}, + {"eventless", func(b *Builder[string, string, bindOrder]) *Builder[string, string, bindOrder] { + return b. + State("from"). + Transition("from").On("go").GoTo("deciding"). + State("deciding"). + Always().GoTo("guarded").When("bound"). + Always().GoTo("fallback").Assign("ranFallback"). + State("guarded"). + State("fallback") + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + b := ForgeFor[bindOrder]("canceled") + b.reg.BindGuard("bound", cancelingGuard{cancel: cancel}) + fallbackRan := false + b.Reducer("ranFallback", func(in AssignCtx[bindOrder]) bindOrder { + fallbackRan = true + return in.Entity + }) + m := tt.build(b).Initial("from").Quench() + inst := m.Cast(bindOrder{}, WithInitialState("from")) + res := inst.Fire(ctx, "go") + if !errors.Is(res.Err, context.Canceled) { + t.Fatalf("Fire err = %v, want context.Canceled", res.Err) + } + if got := inst.Current(); got != "from" { + t.Fatalf("state %q after a canceled Fire, want from", got) + } + if fallbackRan { + t.Fatal("the fallback transition's reducer ran under the ended context") + } + }) + } +} diff --git a/state/callback_context_test.go b/state/callback_context_test.go index 9d3d9755..40952dd2 100644 --- a/state/callback_context_test.go +++ b/state/callback_context_test.go @@ -152,6 +152,22 @@ func TestActorSystem_RegisterContext_GetsTheSpawnContext(t *testing.T) { if spawnTag != "spawn" || child.Entry != "spawn" { t.Fatalf("behavior saw %q and the child's entry reducer %q, want spawn for both", spawnTag, child.Entry) } + snaps, err := sys.SnapshotActors() + if err != nil { + t.Fatalf("SnapshotActors: %v", err) + } + var restoreTag string + restored := state.NewActorSystem(ctxMachine().Cast(&ctxSeen{}, state.WithInitialState("idle"))). + RegisterContext("kid", func(ctx context.Context, _ map[string]any) (state.ActorInstance, error) { + restoreTag = tagOf(ctx) + return state.NewActor(ctxMachine().Cast(&ctxSeen{}, state.WithInitialState("idle")), nil), nil + }) + if err = restored.RestoreActors(tagged("restore"), snaps); err != nil { + t.Fatalf("RestoreActors: %v", err) + } + if restoreTag != "restore" { + t.Fatalf("the behavior saw %q on restore, want restore", restoreTag) + } ref, ok := sys.Ref("kid-1") if !ok { t.Fatal("the actor didn't spawn") diff --git a/state/fire.go b/state/fire.go index 69a4cccf..ac508726 100644 --- a/state/fire.go +++ b/state/fire.go @@ -66,7 +66,8 @@ func (i *Instance[S, E, C]) eventData(event E) any { func (i *Instance[S, E, C]) fireWithMiddleware(ctx context.Context, event E) FireResult[S] { next := func(ctx context.Context, event E) FireResult[S] { // Callbacks see the context the middleware chain passes down, which may carry - // values or a deadline a middleware added. + // values or a deadline a middleware added. Fire's deferred restore puts the + // outer context back when it returns. i.callCtx = ctx return i.fireCore(ctx, event) } @@ -282,6 +283,14 @@ func (i *Instance[S, E, C]) runToCompletion(ctx context.Context, res FireResult[ // No pending internal events: fire one enabled eventless transition. t, anc, leaf, ok := i.selectEventless() + // Selection reads a guard that couldn't decide as not passing. If the context + // ended while the guards ran, that choice may not be the one a live context would + // make, so stop here, before its callbacks run, as the boundary check above would + // one microstep later. + if err := ctx.Err(); err != nil { + res.Err = err + return res + } if !ok { return res } @@ -673,7 +682,9 @@ func (i *Instance[S, E, C]) fireSpine(ctx context.Context, event E, tr Trace) Fi tr.recordGuard(g.Name) ok, gErr := m.evalGuard(i.callbackContext(), g, entity) if gErr != nil { - tr.Outcome = OutcomeGuardPanic + if !isCanceled(gErr) { + tr.Outcome = OutcomeGuardPanic + } return FireResult[S]{NewState: from, Trace: tr, Err: gErr} } if !ok { @@ -1142,12 +1153,26 @@ func (m *Machine[S, E, C]) evalGuard(ctx context.Context, g Ref, entity C) (ok b defer func() { if r := recover(); r != nil { ok = false + if gc, canceled := r.(guardCanceled); canceled { + err = gc.err + return + } err = &GuardPanicError{GuardName: g.Name, Recovered: r} } }() return fn(GuardCtx[C]{Entity: entity, Params: g.Params, Ctx: ctx}), nil } +// guardCanceled is how a guard adapter reports that it couldn't decide because the +// Fire's context ended: evalGuard returns err, the context's own error, so the Fire +// stops with it instead of reading the guard as false. +type guardCanceled struct{ err error } + +// isCanceled reports whether err is a context's cancellation or deadline error. +func isCanceled(err error) bool { + return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) +} + // evalAction resolves and runs an action ref, recovering a panicking host action // into a typed ActionPanicError so a faulty action fails the fire deterministically // rather than crashing Fire. Kernel built-in actions (e.g. the Cancel built-in) are diff --git a/state/kernel.go b/state/kernel.go index 1ba1b65c..d40889a5 100644 --- a/state/kernel.go +++ b/state/kernel.go @@ -562,7 +562,9 @@ type GuardCtx[C any] struct { // settles the initial state (see WithContext); context.Background() outside one, // such as a guard PlanPath evaluates. It is never nil. Pass it to any call the callback makes // back into the host that drives this instance, so the host can tell the call - // comes from inside a step. + // comes from inside a step. Don't branch on it, its values, deadline or + // cancellation: a replay runs the same callbacks under a different context, and must + // reach the same result. Ctx context.Context } @@ -577,7 +579,9 @@ type ActionCtx[C any] struct { // settles the initial state (see WithContext); context.Background() when there's // none, such as a Cast without WithContext. It is never nil. Pass it to any call the callback makes // back into the host that drives this instance, so the host can tell the call - // comes from inside a step. + // comes from inside a step. Don't branch on it, its values, deadline or + // cancellation: a replay runs the same callbacks under a different context, and must + // reach the same result. Ctx context.Context } From 7ebc15db507e1098312ac5b37d040c31649a217b Mon Sep 17 00:00:00 2001 From: Ray Karnes Date: Tue, 10 Nov 2026 16:08:32 -0700 Subject: [PATCH 6/9] fix(state): keep Cast's settle deterministic, and report canceled guards alike Review follow-ups: - Cast gives its callbacks WithContext's values without its cancellation or deadline. A bound guard that honored an ended context could fail there and quietly send the settle down another transition, because Cast settles to completion and has no error to report. - A guard stopped by an ended context no longer reports a guard panic or failure in the Trace on any path: named guards, guard expressions, parallel regions and the parallel state itself. - The Fire's error names the bound guard and keeps its own error, while still matching the context's. - Tests pin each of these, and a Fire whose eventless guard was canceled with nothing chosen. - Docs: ContextActorBehavior covers restore; the site pages say not to branch on Ctx. Signed-off-by: Ray Karnes --- .../docs/authoring/actions-and-effects.md | 2 +- docs/src/content/docs/authoring/assigns.md | 2 +- docs/src/content/docs/authoring/guards.md | 2 +- state/CHANGELOG.md | 9 +- state/actor_system.go | 3 +- state/binding.go | 7 +- state/callback_context_internal_test.go | 178 +++++++++++++++++- state/fire.go | 17 +- state/options.go | 5 +- state/parallel.go | 14 +- 10 files changed, 218 insertions(+), 21 deletions(-) diff --git a/docs/src/content/docs/authoring/actions-and-effects.md b/docs/src/content/docs/authoring/actions-and-effects.md index b9e8435f..003dbf67 100644 --- a/docs/src/content/docs/authoring/actions-and-effects.md +++ b/docs/src/content/docs/authoring/actions-and-effects.md @@ -27,7 +27,7 @@ func notifyCustomer(ctx state.ActionCtx[Order]) (state.Effect, error) { } ``` -The crucial discipline: **the kernel performs no IO**. An action returns an `Effect` (plain data) and the kernel collects every effect into the `FireResult`. Your host loop reads that slice and dispatches the real work. `ctx.Ctx` is the context of the `Fire` running the action. Pass it on if an action, guard or reducer ever has to call back into the host that drives the instance, so the host can tell the call comes from inside a step. This keeps `Fire` deterministic and pure: the same context and event always yield the same effects, which is what makes the engine testable, replayable, and safe to run anywhere. +The crucial discipline: **the kernel performs no IO**. An action returns an `Effect` (plain data) and the kernel collects every effect into the `FireResult`. Your host loop reads that slice and dispatches the real work. `ctx.Ctx` is the context of the `Fire` running the action. Pass it on if an action, guard or reducer ever has to call back into the host that drives the instance, so the host can tell the call comes from inside a step. Don't branch on it: a replay runs the same callbacks under a different context. This keeps `Fire` deterministic and pure: the same context and event always yield the same effects, which is what makes the engine testable, replayable, and safe to run anywhere. ```mermaid stateDiagram-v2 diff --git a/docs/src/content/docs/authoring/assigns.md b/docs/src/content/docs/authoring/assigns.md index c536da6e..c1cd6522 100644 --- a/docs/src/content/docs/authoring/assigns.md +++ b/docs/src/content/docs/authoring/assigns.md @@ -34,7 +34,7 @@ Transition(Authorizing).On(Authorized). Assign("recordHold") ``` -The `AssignCtx` carries the prior `Entity`, the triggering `Event`, and the ref's static `Params`, so a reducer can fold event payloads into context (for example, folding a saga's refunded amount in via the `onDone` event). It also carries `Ctx`, the context of the `Fire` running the reducer, as guards and actions do in `GuardCtx` and `ActionCtx`. +The `AssignCtx` carries the prior `Entity`, the triggering `Event`, and the ref's static `Params`, so a reducer can fold event payloads into context (for example, folding a saga's refunded amount in via the `onDone` event). It also carries `Ctx`, the context of the `Fire` running the reducer, as guards and actions do in `GuardCtx` and `ActionCtx`. Don't branch on it: a replay runs the same callbacks under a different context. ```mermaid stateDiagram-v2 diff --git a/docs/src/content/docs/authoring/guards.md b/docs/src/content/docs/authoring/guards.md index cb46d4e7..5ccb560c 100644 --- a/docs/src/content/docs/authoring/guards.md +++ b/docs/src/content/docs/authoring/guards.md @@ -37,7 +37,7 @@ Transition(Authorizing).On(Authorized). GoTo(Active).Assign("recordHold") ``` -Field predicates support `.Eq`, `.Ne`, `.Lt`, `.Le`, `.Gt`, `.Ge`, and `.In`, with operands built from `state.Str`, `state.Int`, `state.Float`, and `state.Bool`. `state.StateIn(s)` lets a guard depend on the active configuration. A named guard also gets `Ctx`, the context of the `Fire` evaluating it, to pass on if it ever has to call back into the host driving the instance. When a transition carries *both* `When` and `WhenExpr`, every named guard **and** the whole expression must evaluate true. +Field predicates support `.Eq`, `.Ne`, `.Lt`, `.Le`, `.Gt`, `.Ge`, and `.In`, with operands built from `state.Str`, `state.Int`, `state.Float`, and `state.Bool`. `state.StateIn(s)` lets a guard depend on the active configuration. A named guard also gets `Ctx`, the context of the `Fire` evaluating it, to pass on if it ever has to call back into the host driving the instance. Don't branch on it, its values, deadline or cancellation: a replay runs the same guards under a different context, and has to reach the same result. When a transition carries *both* `When` and `WhenExpr`, every named guard **and** the whole expression must evaluate true. ```mermaid stateDiagram-v2 diff --git a/state/CHANGELOG.md b/state/CHANGELOG.md index 85865f45..8e683e3f 100644 --- a/state/CHANGELOG.md +++ b/state/CHANGELOG.md @@ -23,7 +23,8 @@ counts as an additive (minor) versus breaking (major) change. Use the `EvalAction` and `EvalAssign` context through. Don't branch on the context: a replay runs the same callbacks under a different one. - **`WithContext`** sets the context `Cast` gives the guards, actions and reducers that - settle the initial state. + settle the initial state: its values, without its cancellation or deadline, since + `Cast` settles to completion. - **`ActorSystem.RegisterContext`** registers a `ContextActorBehavior`, an actor factory that gets the context of the step spawning or restoring the actor, so it can `Cast` its child `WithContext`. A child's own callbacks already get the context its @@ -36,8 +37,10 @@ counts as an additive (minor) versus breaking (major) change. Use the used to get a background one. An error it returns after that context ended no longer counts as a false verdict: the Fire fails with the context's error at once. Before, another transition could be chosen and its actions and reducers run under the ended - context, until the next cancellation check rolled the Fire back. An eventless - transition chosen while the context ended isn't committed either. + context, until the next cancellation check rolled the Fire back. The Fire's error + wraps the binding's own error. A Fire whose context ended while its eventless + transitions' guards ran now fails, whether or not one was chosen, where it could + succeed before. ## [1.0.0] — 2026-06-13 diff --git a/state/actor_system.go b/state/actor_system.go index dd44e342..10c7a84b 100644 --- a/state/actor_system.go +++ b/state/actor_system.go @@ -48,7 +48,8 @@ import ( type ActorBehavior func(input map[string]any) (ActorInstance, error) // ContextActorBehavior is an ActorBehavior that also gets the context of the step -// spawning the actor: the parent's Fire, or the host call that absorbed the spawn. A +// spawning the actor (the parent's Fire, or the host call that absorbed the spawn), or +// of the RestoreActors call restoring it. A // behavior that Casts its child passes it on with WithContext, so the callbacks that // settle the child's initial state see it too. Register one with RegisterContext. type ContextActorBehavior func(ctx context.Context, input map[string]any) (ActorInstance, error) diff --git a/state/binding.go b/state/binding.go index dc5cbc47..fa33902c 100644 --- a/state/binding.go +++ b/state/binding.go @@ -1,6 +1,9 @@ package state -import "context" +import ( + "context" + "fmt" +) // This file makes behavior invocation a binding rather than a bare Go func // pointer. A binding turns a serializable request envelope into a serializable @@ -197,7 +200,7 @@ func (r *Registry[C]) BindGuard(name string, b GuardBinding[C], opts ...Describe // context before the Fire's next cancellation check rolled it back. Surface the // context's error instead, so the Fire stops here. if cerr := ctx.Err(); cerr != nil { - panic(guardCanceled{err: cerr}) + panic(guardCanceled{err: fmt.Errorf("%w (guard %q: %v)", cerr, name, err)}) } return false } diff --git a/state/callback_context_internal_test.go b/state/callback_context_internal_test.go index 1f264308..b71408a1 100644 --- a/state/callback_context_internal_test.go +++ b/state/callback_context_internal_test.go @@ -3,6 +3,7 @@ package state import ( "context" "errors" + "strings" "testing" ) @@ -39,11 +40,18 @@ func TestBindGuard_GetsTheFiresContext(t *testing.T) { } // cancelingGuard is a GuardBinding that ends the Fire's context while it evaluates, -// then fails, as a binding that honors cancellation would. -type cancelingGuard struct{ cancel context.CancelFunc } +// then fails with err, or with the context's error when err is nil, as a binding that +// honors cancellation would. +type cancelingGuard struct { + cancel context.CancelFunc + err error +} func (g cancelingGuard) EvalGuard(ctx context.Context, _ GuardRequest[bindOrder]) (GuardResult, error) { g.cancel() + if g.err != nil { + return GuardResult{}, g.err + } return GuardResult{}, ctx.Err() } @@ -102,3 +110,169 @@ func TestBindGuard_CanceledMidEvaluation_FailsTheFire(t *testing.T) { }) } } + +// ctxGuard is a GuardBinding that fails once its context has ended and passes +// otherwise, as a binding that honors cancellation would. +type ctxGuard struct{} + +func (ctxGuard) EvalGuard(ctx context.Context, _ GuardRequest[bindOrder]) (GuardResult, error) { + if err := ctx.Err(); err != nil { + return GuardResult{}, err + } + return GuardResult{OK: true}, nil +} + +// TestCast_WithAnEndedContext_SettlesAsWithALiveOne proves Cast's callbacks don't see +// WithContext's context end: an eventless transition behind a bound guard that honors +// cancellation settles the same way with an ended context as with a live one, instead +// of falling through to the next transition. +func TestCast_WithAnEndedContext_SettlesAsWithALiveOne(t *testing.T) { + m := func() *Machine[string, string, bindOrder] { + b := ForgeFor[bindOrder]("castctx") + b.reg.BindGuard("bound", ctxGuard{}) + return b. + State("deciding"). + Always().GoTo("guarded").When("bound"). + Always().GoTo("fallback"). + State("guarded"). + State("fallback"). + Initial("deciding"). + Quench() + } + ended, cancel := context.WithCancel(context.WithValue(context.Background(), ctxTagKey{}, "kept")) + cancel() + live := m().Cast(bindOrder{}, WithInitialState("deciding")) + dead := m().Cast(bindOrder{}, WithInitialState("deciding"), WithContext[string](ended)) + if live.Current() != "guarded" || dead.Current() != "guarded" { + t.Fatalf("Cast settled in %q with a live context and %q with an ended one, want guarded for both", live.Current(), dead.Current()) + } +} + +// fireCanceled builds a machine whose "bound" guard ends the Fire's context and fails +// with bindErr (or the context's error), fires "go" from its initial state, and returns +// the instance and result. +func fireCanceled(t *testing.T, bindErr error, build func(*Builder[string, string, bindOrder]) *Builder[string, string, bindOrder], initial string) (*Instance[string, string, bindOrder], FireResult[string]) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + b := ForgeFor[bindOrder]("canceled") + b.reg.BindGuard("bound", cancelingGuard{cancel: cancel, err: bindErr}) + inst := build(b).Initial(initial).Quench().Cast(bindOrder{}, WithInitialState(initial)) + return inst, inst.Fire(ctx, "go") +} + +// TestCanceledGuard_IsNotReportedAsAGuardFailure proves a guard stopped by its ended +// context fails the Fire with the context's error and leaves the Trace's outcome as a +// Fire on an ended context does, wherever the guard sits: a named guard, a guard +// expression, a transition inside a parallel region, or one on the parallel state. +func TestCanceledGuard_IsNotReportedAsAGuardFailure(t *testing.T) { + tests := []struct { + name string + initial string + build func(*Builder[string, string, bindOrder]) *Builder[string, string, bindOrder] + }{ + {"named guard", "from", func(b *Builder[string, string, bindOrder]) *Builder[string, string, bindOrder] { + return b.State("from").Transition("from").On("go").GoTo("to").When("bound").State("to") + }}, + {"guard expression", "from", func(b *Builder[string, string, bindOrder]) *Builder[string, string, bindOrder] { + return b.State("from").Transition("from").On("go").GoTo("to").WhenExpr(Guard[string]("bound")).State("to") + }}, + {"parallel region", "live", func(b *Builder[string, string, bindOrder]) *Builder[string, string, bindOrder] { + return b.SuperState("live"). + Region("r1").Initial("r1a"). + SubState("r1a").On("go").GoTo("r1b").When("bound"). + SubState("r1b"). + EndRegion(). + Region("r2").Initial("r2a").SubState("r2a").EndRegion(). + EndSuperState() + }}, + {"parallel state", "live", func(b *Builder[string, string, bindOrder]) *Builder[string, string, bindOrder] { + return b.SuperState("live"). + Region("r1").Initial("r1a").SubState("r1a").EndRegion(). + Region("r2").Initial("r2a").SubState("r2a").EndRegion(). + EndSuperState(). + Transition("live").On("go").GoTo("out").When("bound"). + State("out") + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inst, res := fireCanceled(t, nil, tt.build, tt.initial) + if !errors.Is(res.Err, context.Canceled) { + t.Fatalf("Fire err = %v, want context.Canceled", res.Err) + } + if o := res.Trace.Outcome; o == OutcomeGuardPanic || o == OutcomeGuardFailed { + t.Fatalf("Trace outcome %v reports a guard failure, want the outcome of a canceled Fire", o) + } + if leaves := inst.Configuration(); len(leaves) == 0 || (leaves[0] != "from" && leaves[0] != "r1a") { + t.Fatalf("configuration %v after a canceled Fire, want it unchanged", leaves) + } + }) + } +} + +// TestCanceledGuard_KeepsTheBindingsError proves the Fire's error names the guard and +// keeps the binding's own error, while still matching the context's. +func TestCanceledGuard_KeepsTheBindingsError(t *testing.T) { + _, res := fireCanceled(t, errors.New("lookup failed"), func(b *Builder[string, string, bindOrder]) *Builder[string, string, bindOrder] { + return b.State("from").Transition("from").On("go").GoTo("to").When("bound").State("to") + }, "from") + if !errors.Is(res.Err, context.Canceled) { + t.Fatalf("Fire err = %v, want context.Canceled", res.Err) + } + if msg := res.Err.Error(); !strings.Contains(msg, "lookup failed") || !strings.Contains(msg, `"bound"`) { + t.Fatalf("Fire err %q doesn't name the guard and keep its error", msg) + } +} + +// TestCanceledEventlessGuard_WithNothingChosen_FailsTheFire proves a Fire whose context +// ended while its only eventless transition's guard ran fails and leaves the instance +// as it was, rather than settling where the triggering transition left it. +func TestCanceledEventlessGuard_WithNothingChosen_FailsTheFire(t *testing.T) { + inst, res := fireCanceled(t, nil, func(b *Builder[string, string, bindOrder]) *Builder[string, string, bindOrder] { + return b.State("from"). + Transition("from").On("go").GoTo("deciding"). + State("deciding"). + Always().GoTo("guarded").When("bound"). + State("guarded") + }, "from") + if !errors.Is(res.Err, context.Canceled) { + t.Fatalf("Fire err = %v, want context.Canceled", res.Err) + } + if got := inst.Current(); got != "from" { + t.Fatalf("state %q after a canceled Fire, want from", got) + } +} + +// valueGuard is a GuardBinding that records the tag and the error its context carries. +type valueGuard struct { + tag *string + err *error +} + +func (g valueGuard) EvalGuard(ctx context.Context, _ GuardRequest[bindOrder]) (GuardResult, error) { + *g.tag, _ = ctx.Value(ctxTagKey{}).(string) + *g.err = ctx.Err() + return GuardResult{OK: true}, nil +} + +// TestCast_WithAnEndedContext_KeepsItsValues proves Cast's callbacks get WithContext's +// values, such as a host's step marks, but not its cancellation. +func TestCast_WithAnEndedContext_KeepsItsValues(t *testing.T) { + var tag string + var ctxErr error + b := ForgeFor[bindOrder]("castvalues") + b.reg.BindGuard("bound", valueGuard{tag: &tag, err: &ctxErr}) + m := b. + State("deciding"). + Always().GoTo("done").When("bound"). + State("done"). + Initial("deciding"). + Quench() + ended, cancel := context.WithCancel(context.WithValue(context.Background(), ctxTagKey{}, "kept")) + cancel() + m.Cast(bindOrder{}, WithInitialState("deciding"), WithContext[string](ended)) + if tag != "kept" || ctxErr != nil { + t.Fatalf("the guard saw tag %q and context error %v, want kept and none", tag, ctxErr) + } +} diff --git a/state/fire.go b/state/fire.go index ac508726..b191595f 100644 --- a/state/fire.go +++ b/state/fire.go @@ -701,7 +701,9 @@ func (i *Instance[S, E, C]) fireSpine(ctx context.Context, event E, tr Trace) Fi if passed && t.GuardExpr != nil { res := i.evalGuardExpr(t.GuardExpr, entity, &tr) if res.err != nil { - tr.Outcome = OutcomeGuardPanic + if !isCanceled(res.err) { + tr.Outcome = OutcomeGuardPanic + } return FireResult[S]{NewState: from, Trace: tr, Err: res.err} } if !res.ok { @@ -1120,10 +1122,13 @@ func (i *Instance[S, E, C]) runActions(refs []Ref, entity C, tr *Trace) (effects return effects, "", nil } -// settleWith settles the initial state with ctx as its callbacks' context, clearing it -// however the settle ends. +// settleWith settles the initial state with ctx's values, but not its cancellation or +// deadline, as its callbacks' context, clearing it however the settle ends. Cast always +// settles to completion, so a callback mustn't see the context end partway: a guard +// that failed on an ended context would quietly send the settle down another +// transition, with no error to report it. func (i *Instance[S, E, C]) settleWith(ctx context.Context, current S) { - i.callCtx = ctx + i.callCtx = context.WithoutCancel(nonNil(ctx)) defer func() { i.callCtx = nil }() i.settleInitial(current) } @@ -1168,7 +1173,9 @@ func (m *Machine[S, E, C]) evalGuard(ctx context.Context, g Ref, entity C) (ok b // stops with it instead of reading the guard as false. type guardCanceled struct{ err error } -// isCanceled reports whether err is a context's cancellation or deadline error. +// isCanceled reports whether err is a context's cancellation or deadline error. A guard +// stopped that way leaves the Trace's outcome as a Fire on an ended context does, +// rather than reporting a guard panic. func isCanceled(err error) bool { return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) } diff --git a/state/options.go b/state/options.go index f0cf68dd..74d01403 100644 --- a/state/options.go +++ b/state/options.go @@ -181,8 +181,9 @@ type castConfig[S comparable] struct { } // WithContext sets the context Cast hands to the guards, actions and reducers that -// settle the initial state, as their Ctx. Cast settles the state to completion -// even if the context has ended. A host that casts an instance inside its own step, +// settle the initial state, as their Ctx. They get its values but not its cancellation +// or deadline: Cast settles the state to completion, so a callback mustn't see the +// context end partway. A host that casts an instance inside its own step, // such as an actor factory, passes that step's context. When omitted, it is // context.Background(). func WithContext[S comparable](ctx context.Context) CastOption[S] { diff --git a/state/parallel.go b/state/parallel.go index f0ff788c..99e10928 100644 --- a/state/parallel.go +++ b/state/parallel.go @@ -155,8 +155,12 @@ func (i *Instance[S, E, C]) fireParallel(ctx context.Context, parallel S, event // regionErrOutcome maps a region-level error to the appropriate outcome, // mirroring the main commit path: a bound action that errored surfaces as // OutcomeEffectError, an assign reducer panic as OutcomeAssignFailed, and a -// failing guard predicate as OutcomeGuardFailed. +// failing guard predicate as OutcomeGuardFailed. A guard stopped by an ended context +// reports OutcomeInvalidTransition, as a Fire on an ended context does. func regionErrOutcome(err error) Outcome { + if isCanceled(err) { + return OutcomeInvalidTransition + } var afe *ActionFailedError if errors.As(err, &afe) { return OutcomeEffectError @@ -611,7 +615,9 @@ func (i *Instance[S, E, C]) fireFromState(ctx context.Context, start S, event E, tr.recordGuard(g.Name) okg, gErr := m.evalGuard(i.callbackContext(), g, entity) if gErr != nil { - tr.Outcome = OutcomeGuardPanic + if !isCanceled(gErr) { + tr.Outcome = OutcomeGuardPanic + } return FireResult[S]{NewState: from, Trace: tr, Err: gErr} } if !okg { @@ -622,7 +628,9 @@ func (i *Instance[S, E, C]) fireFromState(ctx context.Context, start S, event E, if pass && t.GuardExpr != nil { res := i.evalGuardExpr(t.GuardExpr, entity, &tr) if res.err != nil { - tr.Outcome = OutcomeGuardPanic + if !isCanceled(res.err) { + tr.Outcome = OutcomeGuardPanic + } return FireResult[S]{NewState: from, Trace: tr, Err: res.err} } if !res.ok { From 250fa69d40811da89952cfcf0da8a506de30c724 Mon Sep 17 00:00:00 2001 From: Ray Karnes Date: Tue, 10 Nov 2026 16:22:20 -0700 Subject: [PATCH 7/9] fix(state): keep a canceled eventless guard's error An eventless transition's guard stopped by an ended context was read as not passing, so the Fire reported a bare context error, without the guard's name or the binding's own error. Eventless selection now stops with the guard's error, as event-driven evaluation does. A test covers both. Docs: a nested Fire clears the outer Fire's raised events; a bound guard's cancellation check reads the context it got, as middleware passed it down; at Cast, Ctx carries the values of WithContext's context. Signed-off-by: Ray Karnes --- state/CHANGELOG.md | 3 +- state/assign.go | 4 +-- state/binding.go | 5 ++- state/callback_context_internal_test.go | 34 +++++++++++++----- state/fire.go | 48 +++++++++++++++++-------- state/kernel.go | 8 ++--- 6 files changed, 72 insertions(+), 30 deletions(-) diff --git a/state/CHANGELOG.md b/state/CHANGELOG.md index 8e683e3f..318610fb 100644 --- a/state/CHANGELOG.md +++ b/state/CHANGELOG.md @@ -38,7 +38,8 @@ counts as an additive (minor) versus breaking (major) change. Use the counts as a false verdict: the Fire fails with the context's error at once. Before, another transition could be chosen and its actions and reducers run under the ended context, until the next cancellation check rolled the Fire back. The Fire's error - wraps the binding's own error. A Fire whose context ended while its eventless + names the guard and wraps the binding's own error, on event-driven and eventless + transitions alike. A Fire whose context ended while its eventless transitions' guards ran now fails, whether or not one was chosen, where it could succeed before. diff --git a/state/assign.go b/state/assign.go index 433828e0..8dad5d8e 100644 --- a/state/assign.go +++ b/state/assign.go @@ -32,8 +32,8 @@ type AssignCtx[C any] struct { Entity C Event any Params map[string]any - // Ctx is the context of the Fire running this callback, or of the Cast that - // settles the initial state (see WithContext); context.Background() when there's + // Ctx is the context of the Fire running this callback, or one carrying the values + // of the Cast's (see WithContext) while it settles the initial state; context.Background() when there's // none, such as a Cast without WithContext. It is never nil. Pass it to any call the callback makes // back into the host that drives this instance, so the host can tell the call // comes from inside a step. Don't branch on it, its values, deadline or diff --git a/state/binding.go b/state/binding.go index fa33902c..67507f2c 100644 --- a/state/binding.go +++ b/state/binding.go @@ -184,7 +184,10 @@ func (r *Registry[C]) bindGuard(name string, b GuardBinding[C]) { // an error it returns is treated as a false verdict, matching how a Go guard that // cannot decide yields false rather than transitioning. The exception is an error // returned once that context has ended: the Fire then fails with the context's error, -// as it would at its next cancellation check, rather than take another transition. An optional Describe option +// as it would at its next cancellation check, rather than take another transition. +// "That context" is the one the guard got: the Fire's, as the middleware chain passed +// it down, so a middleware that substitutes a context it cancels can end a Fire this +// way although the Fire's own context is live. An optional Describe option // adds palette metadata exactly as Guard does. func (r *Registry[C]) BindGuard(name string, b GuardBinding[C], opts ...DescribeOption) *Registry[C] { r.guards[name] = func(gctx GuardCtx[C]) bool { diff --git a/state/callback_context_internal_test.go b/state/callback_context_internal_test.go index b71408a1..09797cd4 100644 --- a/state/callback_context_internal_test.go +++ b/state/callback_context_internal_test.go @@ -212,16 +212,34 @@ func TestCanceledGuard_IsNotReportedAsAGuardFailure(t *testing.T) { } // TestCanceledGuard_KeepsTheBindingsError proves the Fire's error names the guard and -// keeps the binding's own error, while still matching the context's. +// keeps the binding's own error, while still matching the context's, on an +// event-driven transition and on an eventless one. func TestCanceledGuard_KeepsTheBindingsError(t *testing.T) { - _, res := fireCanceled(t, errors.New("lookup failed"), func(b *Builder[string, string, bindOrder]) *Builder[string, string, bindOrder] { - return b.State("from").Transition("from").On("go").GoTo("to").When("bound").State("to") - }, "from") - if !errors.Is(res.Err, context.Canceled) { - t.Fatalf("Fire err = %v, want context.Canceled", res.Err) + tests := []struct { + name string + build func(*Builder[string, string, bindOrder]) *Builder[string, string, bindOrder] + }{ + {"event-driven", func(b *Builder[string, string, bindOrder]) *Builder[string, string, bindOrder] { + return b.State("from").Transition("from").On("go").GoTo("to").When("bound").State("to") + }}, + {"eventless", func(b *Builder[string, string, bindOrder]) *Builder[string, string, bindOrder] { + return b.State("from"). + Transition("from").On("go").GoTo("deciding"). + State("deciding"). + Always().GoTo("to").When("bound"). + State("to") + }}, } - if msg := res.Err.Error(); !strings.Contains(msg, "lookup failed") || !strings.Contains(msg, `"bound"`) { - t.Fatalf("Fire err %q doesn't name the guard and keep its error", msg) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, res := fireCanceled(t, errors.New("lookup failed"), tt.build, "from") + if !errors.Is(res.Err, context.Canceled) { + t.Fatalf("Fire err = %v, want context.Canceled", res.Err) + } + if msg := res.Err.Error(); !strings.Contains(msg, "lookup failed") || !strings.Contains(msg, `"bound"`) { + t.Fatalf("Fire err %q doesn't name the guard and keep its error", msg) + } + }) } } diff --git a/state/fire.go b/state/fire.go index b191595f..0f8e70c3 100644 --- a/state/fire.go +++ b/state/fire.go @@ -34,7 +34,8 @@ func (i *Instance[S, E, C]) Fire(ctx context.Context, event E, opts ...FireOptio // Guards, actions and reducers see this Fire's context as their Ctx (as the // middleware chain passes it down). A Fire nested inside another on the same // instance restores the outer Fire's context when it returns; it doesn't restore - // the outer Fire's other per-Fire state, such as its WithEventData payload. + // the outer Fire's other per-Fire state, such as its WithEventData payload or its + // queue of raised events, which the nested Fire clears. outerCtx := i.callCtx i.callCtx = ctx defer func() { i.callCtx = outerCtx }() @@ -282,11 +283,16 @@ func (i *Instance[S, E, C]) runToCompletion(ctx context.Context, res FireResult[ } // No pending internal events: fire one enabled eventless transition. - t, anc, leaf, ok := i.selectEventless() - // Selection reads a guard that couldn't decide as not passing. If the context - // ended while the guards ran, that choice may not be the one a live context would - // make, so stop here, before its callbacks run, as the boundary check above would - // one microstep later. + t, anc, leaf, ok, selErr := i.selectEventless() + // A guard stopped by an ended context ends selection with its error, which names + // the guard. Any other guard that couldn't decide reads as not passing, so if the + // context ended while the guards ran, the choice may not be the one a live + // context would make: stop here, before its callbacks run, as the boundary check + // above would one microstep later. + if selErr != nil { + res.Err = selErr + return res + } if err := ctx.Err(); err != nil { res.Err = err return res @@ -505,7 +511,7 @@ func (i *Instance[S, E, C]) seedTrace(event string) Trace { // macrostep microstep fires exactly one eventless transition; multiple regions' // eventless transitions settle across successive microsteps in declaration // order, preserving the run-to-completion determinism contract. -func (i *Instance[S, E, C]) selectEventless() (t *Transition[S, E, C], anc, leaf S, ok bool) { +func (i *Instance[S, E, C]) selectEventless() (t *Transition[S, E, C], anc, leaf S, ok bool, err error) { m := i.machine for _, l := range i.config { for _, a := range m.ancestors(l) { @@ -518,14 +524,19 @@ func (i *Instance[S, E, C]) selectEventless() (t *Transition[S, E, C], anc, leaf if !cand.EventLess { continue } - if i.guardsPass(cand) { - return cand, a, l, true + pass, gErr := i.guardsPass(cand) + if gErr != nil { + var zero S + return nil, zero, zero, false, gErr + } + if pass { + return cand, a, l, true, nil } } } } var zero S - return nil, zero, zero, false + return nil, zero, zero, false, nil } // guardsPass reports whether every guard on a transition currently passes. A @@ -538,20 +549,29 @@ func (i *Instance[S, E, C]) selectEventless() (t *Transition[S, E, C], anc, leaf // fails LOUD — an error there sets OutcomeGuardPanic and is returned as a Fire // error (see the guard evaluation in fireSpine). The kernel owns this policy; the // expr subpackage documents the same asymmetry from its side. -func (i *Instance[S, E, C]) guardsPass(t *Transition[S, E, C]) bool { +// +// The one exception is a guard stopped by an ended context: it has no verdict, so +// guardsPass returns its error and selection stops there. +func (i *Instance[S, E, C]) guardsPass(t *Transition[S, E, C]) (bool, error) { for _, g := range t.Guards { ok, err := i.machine.evalGuard(i.callbackContext(), g, i.entity) + if err != nil && isCanceled(err) { + return false, err + } if err != nil || !ok { - return false + return false, nil } } if t.GuardExpr != nil { res := i.evalGuardExpr(t.GuardExpr, i.entity, nil) + if res.err != nil && isCanceled(res.err) { + return false, res.err + } if res.err != nil || !res.ok { - return false + return false, nil } } - return true + return true, nil } // absorbMicrosteps folds an internal sub-step's observable trace detail into the diff --git a/state/kernel.go b/state/kernel.go index d40889a5..bb4e43e1 100644 --- a/state/kernel.go +++ b/state/kernel.go @@ -558,8 +558,8 @@ type BatchResult[S comparable] struct { type GuardCtx[C any] struct { Entity C Params map[string]any - // Ctx is the context of the Fire running this callback, or of the Cast that - // settles the initial state (see WithContext); context.Background() outside one, + // Ctx is the context of the Fire running this callback, or one carrying the values + // of the Cast's (see WithContext) while it settles the initial state; context.Background() outside one, // such as a guard PlanPath evaluates. It is never nil. Pass it to any call the callback makes // back into the host that drives this instance, so the host can tell the call // comes from inside a step. Don't branch on it, its values, deadline or @@ -575,8 +575,8 @@ type GuardFn[C any] func(ctx GuardCtx[C]) bool type ActionCtx[C any] struct { Entity C Params map[string]any - // Ctx is the context of the Fire running this callback, or of the Cast that - // settles the initial state (see WithContext); context.Background() when there's + // Ctx is the context of the Fire running this callback, or one carrying the values + // of the Cast's (see WithContext) while it settles the initial state; context.Background() when there's // none, such as a Cast without WithContext. It is never nil. Pass it to any call the callback makes // back into the host that drives this instance, so the host can tell the call // comes from inside a step. Don't branch on it, its values, deadline or From c01d078707943131f05bbbc45bfdb65e5f97d914 Mon Sep 17 00:00:00 2001 From: Ray Karnes Date: Tue, 10 Nov 2026 16:42:06 -0700 Subject: [PATCH 8/9] fix(state): wrap a canceled bound guard's own error The Fire's error embedded the binding's error as text only, so a host couldn't match its sentinel with errors.Is, though the CHANGELOG said it was wrapped. It now wraps both the context's error and the binding's. The test checks errors.Is for each. Signed-off-by: Ray Karnes --- state/binding.go | 2 +- state/callback_context_internal_test.go | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/state/binding.go b/state/binding.go index 67507f2c..59c76f51 100644 --- a/state/binding.go +++ b/state/binding.go @@ -203,7 +203,7 @@ func (r *Registry[C]) BindGuard(name string, b GuardBinding[C], opts ...Describe // context before the Fire's next cancellation check rolled it back. Surface the // context's error instead, so the Fire stops here. if cerr := ctx.Err(); cerr != nil { - panic(guardCanceled{err: fmt.Errorf("%w (guard %q: %v)", cerr, name, err)}) + panic(guardCanceled{err: fmt.Errorf("%w (guard %q: %w)", cerr, name, err)}) } return false } diff --git a/state/callback_context_internal_test.go b/state/callback_context_internal_test.go index 09797cd4..4e5129af 100644 --- a/state/callback_context_internal_test.go +++ b/state/callback_context_internal_test.go @@ -212,7 +212,7 @@ func TestCanceledGuard_IsNotReportedAsAGuardFailure(t *testing.T) { } // TestCanceledGuard_KeepsTheBindingsError proves the Fire's error names the guard and -// keeps the binding's own error, while still matching the context's, on an +// wraps both the binding's own error and the context's, so errors.Is matches either, on an // event-driven transition and on an eventless one. func TestCanceledGuard_KeepsTheBindingsError(t *testing.T) { tests := []struct { @@ -232,9 +232,10 @@ func TestCanceledGuard_KeepsTheBindingsError(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, res := fireCanceled(t, errors.New("lookup failed"), tt.build, "from") - if !errors.Is(res.Err, context.Canceled) { - t.Fatalf("Fire err = %v, want context.Canceled", res.Err) + lookupErr := errors.New("lookup failed") + _, res := fireCanceled(t, lookupErr, tt.build, "from") + if !errors.Is(res.Err, context.Canceled) || !errors.Is(res.Err, lookupErr) { + t.Fatalf("Fire err = %v, want it to match context.Canceled and the binding's error", res.Err) } if msg := res.Err.Error(); !strings.Contains(msg, "lookup failed") || !strings.Contains(msg, `"bound"`) { t.Fatalf("Fire err %q doesn't name the guard and keep its error", msg) From f2eb64c6f42511553bfeb0fadd424844b5f8e407 Mon Sep 17 00:00:00 2001 From: Ray Karnes Date: Tue, 10 Nov 2026 16:57:59 -0700 Subject: [PATCH 9/9] fix(state): tell a canceled guard from one that panics with a context error A guard that panicked with a value wrapping context.Canceled was taken for a guard stopped by its ended context. Canceled guards now carry the kernel's own marker, so only those skip the guard-panic outcome. Tests: that case, a region's eventless transition settling during Cast, and a note that a nil Ctx fails the context tests. Signed-off-by: Ray Karnes --- state/callback_context_internal_test.go | 25 ++++++++++++++++ state/callback_context_test.go | 38 +++++++++++++++++++++++-- state/fire.go | 20 +++++++++---- 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/state/callback_context_internal_test.go b/state/callback_context_internal_test.go index 4e5129af..f4654269 100644 --- a/state/callback_context_internal_test.go +++ b/state/callback_context_internal_test.go @@ -3,6 +3,7 @@ package state import ( "context" "errors" + "fmt" "strings" "testing" ) @@ -295,3 +296,27 @@ func TestCast_WithAnEndedContext_KeepsItsValues(t *testing.T) { t.Fatalf("the guard saw tag %q and context error %v, want kept and none", tag, ctxErr) } } + +// contextPanicGuard is a guard that panics with an error wrapping context.Canceled, +// though its context is live. +func contextPanicGuard(GuardCtx[bindOrder]) bool { + panic(fmt.Errorf("lock lost: %w", context.Canceled)) +} + +// TestGuardPanickingWithAContextError_IsStillAGuardPanic proves a guard that panics with +// a value wrapping a context error is reported as a guard panic, not a cancellation: +// only the kernel's own signal, from a bound guard whose context ended, counts as one. +func TestGuardPanickingWithAContextError_IsStillAGuardPanic(t *testing.T) { + m := ForgeFor[bindOrder]("panicky"). + Guard("g", contextPanicGuard). + State("from"). + Transition("from").On("go").GoTo("to").When("g"). + State("to"). + Initial("from"). + Quench() + res := m.Cast(bindOrder{}, WithInitialState("from")).Fire(context.Background(), "go") + var gp *GuardPanicError + if !errors.As(res.Err, &gp) || res.Trace.Outcome != OutcomeGuardPanic { + t.Fatalf("Fire = %v with outcome %v, want a GuardPanicError and OutcomeGuardPanic", res.Err, res.Trace.Outcome) + } +} diff --git a/state/callback_context_test.go b/state/callback_context_test.go index 40952dd2..151e0b1d 100644 --- a/state/callback_context_test.go +++ b/state/callback_context_test.go @@ -16,7 +16,8 @@ func tagged(tag string) context.Context { return context.WithValue(context.Background(), ctxKey{}, tag) } -// tagOf reads the tag a callback's Ctx carries, or "" for none. +// tagOf reads the tag a callback's Ctx carries, "" for none, or "nil" for a nil +// context, which every test would reject. func tagOf(ctx context.Context) string { if ctx == nil { return "nil" @@ -87,7 +88,7 @@ func TestCallbacks_CastWithContext(t *testing.T) { } plain := &ctxSeen{Entry: "unset"} ctxMachine().Cast(plain, state.WithInitialState("idle")) - if plain.Entry != "" { // ran, with context.Background(): non-nil, no tag + if plain.Entry != "" { // ran with a non-nil context carrying no tag: tagOf reports "nil" for nil t.Fatalf("the entry reducer saw %q at a plain Cast, want a background context", plain.Entry) } } @@ -268,3 +269,36 @@ func TestCallbacks_ParallelRegionGetsTheFiresContext(t *testing.T) { t.Fatalf("the region's guard saw %q and reducer %q, want fire", seen.Guard, seen.Assign) } } + +// TestCallbacks_RegionEventlessSettleAtCastGetsTheCastContext proves a guard and reducer +// on an eventless transition inside a parallel region, settling during Cast, see +// WithContext's context. +func TestCallbacks_RegionEventlessSettleAtCastGetsTheCastContext(t *testing.T) { + m := state.ForgeFor[*ctxSeen]("region-cast"). + Guard("g", func(c state.GuardCtx[*ctxSeen]) bool { + c.Entity.Guard = tagOf(c.Ctx) + return true + }). + Reducer("r", func(c state.AssignCtx[*ctxSeen]) *ctxSeen { + c.Entity.Assign = tagOf(c.Ctx) + return c.Entity + }). + SuperState("live"). + Region("r1"). + Initial("r1a"). + SubState("r1a").Always().GoTo("r1b").When("g").Assign("r"). + SubState("r1b"). + EndRegion(). + Region("r2"). + Initial("r2a"). + SubState("r2a"). + EndRegion(). + EndSuperState(). + Initial("live"). + Quench() + seen := &ctxSeen{} + m.Cast(seen, state.WithInitialState("live"), state.WithContext[string](tagged("cast"))) + if seen.Guard != "cast" || seen.Assign != "cast" { + t.Fatalf("the region's eventless guard saw %q and reducer %q, want cast", seen.Guard, seen.Assign) + } +} diff --git a/state/fire.go b/state/fire.go index 0f8e70c3..ae3706bf 100644 --- a/state/fire.go +++ b/state/fire.go @@ -1179,7 +1179,7 @@ func (m *Machine[S, E, C]) evalGuard(ctx context.Context, g Ref, entity C) (ok b if r := recover(); r != nil { ok = false if gc, canceled := r.(guardCanceled); canceled { - err = gc.err + err = &canceledGuardError{err: gc.err} return } err = &GuardPanicError{GuardName: g.Name, Recovered: r} @@ -1193,11 +1193,21 @@ func (m *Machine[S, E, C]) evalGuard(ctx context.Context, g Ref, entity C) (ok b // stops with it instead of reading the guard as false. type guardCanceled struct{ err error } -// isCanceled reports whether err is a context's cancellation or deadline error. A guard -// stopped that way leaves the Trace's outcome as a Fire on an ended context does, -// rather than reporting a guard panic. +// canceledGuardError is the error evalGuard returns for a guard that couldn't decide +// because its context ended. It wraps the context's error, so errors.Is matches it, and +// marks the cause as the kernel's own signal, not a guard that panicked with a value +// that happens to wrap a context error. +type canceledGuardError struct{ err error } + +func (e *canceledGuardError) Error() string { return e.err.Error() } +func (e *canceledGuardError) Unwrap() error { return e.err } + +// isCanceled reports whether err is a guard stopped by its ended context. Such a guard +// leaves the Trace's outcome as a Fire on an ended context does, rather than reporting +// a guard panic; a guard that panicked is reported as one whatever its value wraps. func isCanceled(err error) bool { - return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) + var ce *canceledGuardError + return errors.As(err, &ce) } // evalAction resolves and runs an action ref, recovering a panicking host action