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. 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/actions-and-effects.md b/docs/src/content/docs/authoring/actions-and-effects.md index 8feaaf24..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. 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/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/assigns.md b/docs/src/content/docs/authoring/assigns.md index 0a0a1035..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). +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 98a60bc4..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. 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 e4894a80..318610fb 100644 --- a/state/CHANGELOG.md +++ b/state/CHANGELOG.md @@ -15,10 +15,33 @@ 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. Callbacks see the context as + the middleware chain passes it down. The in-process bindings pass their `EvalGuard`, + `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: 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 + delivery carries. ### 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. The Fire's error + 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. ## [1.0.0] — 2026-06-13 @@ -710,9 +733,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`.) + 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/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..10c7a84b 100644 --- a/state/actor_system.go +++ b/state/actor_system.go @@ -47,6 +47,13 @@ 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), 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) + // 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 @@ -116,8 +123,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. @@ -130,7 +138,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 +240,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 +251,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 +339,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..8dad5d8e 100644 --- a/state/assign.go +++ b/state/assign.go @@ -32,6 +32,14 @@ type AssignCtx[C any] struct { Entity C Event any Params map[string]any + // 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 + // cancellation: a replay runs the same callbacks under a different context, and must + // reach the same result. + Ctx context.Context } // AssignFn is the sole context writer: a total pure reducer producing the next @@ -80,8 +88,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..59c76f51 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 @@ -96,9 +99,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 +117,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 } @@ -176,18 +179,32 @@ 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 +// 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. +// "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 { - res, err := b.EvalGuard(context.Background(), 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: fmt.Errorf("%w (guard %q: %w)", cerr, name, err)}) + } return false } return res.OK 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_internal_test.go b/state/callback_context_internal_test.go new file mode 100644 index 00000000..f4654269 --- /dev/null +++ b/state/callback_context_internal_test.go @@ -0,0 +1,322 @@ +package state + +import ( + "context" + "errors" + "fmt" + "strings" + "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) + } +} + +// cancelingGuard is a GuardBinding that ends the Fire's context while it evaluates, +// 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() +} + +// 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") + } + }) + } +} + +// 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 +// 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 { + 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") + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + 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) + } + }) + } +} + +// 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) + } +} + +// 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 new file mode 100644 index 00000000..151e0b1d --- /dev/null +++ b/state/callback_context_test.go @@ -0,0 +1,304 @@ +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, "" for none, or "nil" for a nil +// context, which every test would reject. +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{Entry: "unset"} + ctxMachine().Cast(plain, state.WithInitialState("idle")) + 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) + } +} + +// 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) + } + 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") + } + 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) + } +} + +// 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) + } +} + +// 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) + } +} + +// 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/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 457f27fe..ae3706bf 100644 --- a/state/fire.go +++ b/state/fire.go @@ -31,6 +31,14 @@ 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 (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 or its + // queue of raised events, which the nested Fire clears. + outerCtx := i.callCtx + i.callCtx = ctx + defer func() { i.callCtx = outerCtx }() defer func() { i.fireData = nil i.hasFireData = false @@ -58,6 +66,10 @@ 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. Fire's deferred restore puts the + // outer context back when it returns. + i.callCtx = ctx return i.fireCore(ctx, event) } mws := i.machine.middleware @@ -271,7 +283,20 @@ 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() + 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 + } if !ok { return res } @@ -374,6 +399,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 @@ -484,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) { @@ -497,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 @@ -517,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(g, i.entity) + 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 @@ -659,9 +700,11 @@ 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 + if !isCanceled(gErr) { + tr.Outcome = OutcomeGuardPanic + } return FireResult[S]{NewState: from, Trace: tr, Err: gErr} } if !ok { @@ -678,7 +721,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 { @@ -1077,7 +1122,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 +1142,34 @@ func (i *Instance[S, E, C]) runActions(refs []Ref, entity C, tr *Trace) (effects return effects, "", nil } +// 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 = context.WithoutCancel(nonNil(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 { + 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. @@ -1107,17 +1178,43 @@ func (m *Machine[S, E, C]) evalGuard(g Ref, entity C) (ok bool, err error) { defer func() { if r := recover(); r != nil { ok = false + if gc, canceled := r.(guardCanceled); canceled { + err = &canceledGuardError{err: gc.err} + return + } 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 +} + +// 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 } + +// 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 { + var ce *canceledGuardError + return errors.As(err, &ce) } // 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 +1228,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 +1238,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 +1252,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 +1263,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..bb4e43e1 100644 --- a/state/kernel.go +++ b/state/kernel.go @@ -558,6 +558,14 @@ 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 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 + // cancellation: a replay runs the same callbacks under a different context, and must + // reach the same result. + Ctx context.Context } // GuardFn is a pure predicate on the entity. @@ -567,6 +575,14 @@ 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 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 + // cancellation: a replay runs the same callbacks under a different context, and must + // reach the same result. + Ctx context.Context } // ActionFn produces an effect (or error) for a transition. @@ -1688,7 +1704,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.settleInitial(current) + inst.settleWith(cfg.ctx, current) return inst } @@ -1746,6 +1762,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..74d01403 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,17 @@ 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 guards, actions and reducers that +// 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] { + 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..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 @@ -193,7 +197,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,9 +613,11 @@ 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 + 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 { 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.