Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions state/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ ready to freeze on sign-off. The `analysis`, `evolution`, `conformance`, and

### Changed

- **BREAKING: the actor-panic error type `ErrActorPanic` is renamed `ActorPanicError`.**
The `Err*` prefix is the Go convention for sentinel error values, not for struct
types a caller inspects with `errors.As`; this completes the `Err*` → `*Error`
struct-error rename so `*ActorPanicError` matches the rest of the typed errors.
- `WaitMode`, `HistoryType`, and `ActorKind` integer wire values are documented as
frozen and append-only; `JournalRandom` is specified to ride
`JournalEntry.Payload`.
Expand Down
14 changes: 7 additions & 7 deletions state/actor_escalation.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,45 +203,45 @@ func (s *ActorSystem[S, E, C]) parentOf(childID string) string {
return ""
}

// ErrActorPanic is the typed failure raised when a child-machine actor panics
// ActorPanicError is the typed failure raised when a child-machine actor panics
// while it steps an event. The ActorSystem recovers the panic so it never crashes
// the host driver, wraps the recovered value here, and settles the actor as a
// failure — routing its onError, or escalating to the parent when none is wired.
type ErrActorPanic struct {
type ActorPanicError struct {
// ActorID is the registry id of the actor that panicked.
ActorID string
// Value is the recovered panic value, rendered for the error message.
Value any
}

// Error renders the recovered actor panic.
func (e *ErrActorPanic) Error() string {
func (e *ActorPanicError) Error() string {
return fmt.Sprintf("crucible/state: actor %q panicked: %v", e.ActorID, e.Value)
}

// deliverFireGuarded fires event through inst, recovering any panic the actor raises
// while it steps so a panicking child never crashes the host driver. On a clean
// step it returns the actor's done flag and output with a nil error; on a panic it
// returns a non-nil *ErrActorPanic carrying the recovered value, which the caller
// returns a non-nil *ActorPanicError carrying the recovered value, which the caller
// settles as a failure (routing onError or escalating).
func deliverFireGuarded(ctx context.Context, inst ActorInstance, event any) (done bool, output any, panicErr error) {
defer func() {
if r := recover(); r != nil {
done, output, panicErr = false, nil, &ErrActorPanic{Value: r}
done, output, panicErr = false, nil, &ActorPanicError{Value: r}
}
}()
done, output = inst.DeliverFire(ctx, event)
// A clean Go step may still have returned a FireResult.Err: the kernel now
// recovers a panicking host action/guard/assign into a typed error rather than
// letting it unwind, so a child failure surfaces here instead of as a Go panic.
// Treat it as a failure so it settles (routing onError or escalating). A panic
// recovered into an *ActionPanicError is re-rendered as an *ErrActorPanic
// recovered into an *ActionPanicError is re-rendered as an *ActorPanicError
// carrying the original recovered value, preserving the panic-failure surface.
if fe, ok := inst.(fireErrer); ok {
if err := fe.FireErr(); err != nil {
var ap *ActionPanicError
if errors.As(err, &ap) {
return false, nil, &ErrActorPanic{Value: ap.Recovered}
return false, nil, &ActorPanicError{Value: ap.Recovered}
}
return false, nil, err
}
Expand Down
4 changes: 2 additions & 2 deletions state/actor_escalation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,9 @@ func TestActorEscalation_ChildPanic_NoOnError_Escalates(t *testing.T) {
if esc == nil {
t.Fatal("child panic was swallowed; LastEscalation = nil")
}
var pErr *state.ErrActorPanic
var pErr *state.ActorPanicError
if !errors.As(esc, &pErr) {
t.Fatalf("escalation cause is not *ErrActorPanic: %v", esc)
t.Fatalf("escalation cause is not *ActorPanicError: %v", esc)
}
if pErr.ActorID != id {
t.Fatalf("panic ActorID = %q, want %q", pErr.ActorID, id)
Expand Down
154 changes: 154 additions & 0 deletions state/actor_output_spawninput_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package state_test

import (
"context"
"testing"

"github.com/stablekernel/crucible/state"
)

// This file pins two previously-uncovered exported actor seams:
//
// - ActorInstance.Output (actor.go:381, via the *actorAdapter the exported
// NewActor returns): it must return nil before the child reaches its final
// state and the extracted completion output once it does.
// - WithSpawnInput (options.go:125): the input map declared on a dynamic Spawn
// must reach the spawned actor's ActorBehavior verbatim, so a child can be
// seeded from it.

// TestOutput_NilBeforeFinal_ValueAfterFinal pins ActorInstance.Output across the
// completion boundary. We hold the ActorInstance the behavior returns so we can
// call Output() directly: before the child reaches "done" it must report nil, and
// once "finish" steps it to its final state the output extractor's value surfaces.
func TestOutput_NilBeforeFinal_ValueAfterFinal(t *testing.T) {
cm := state.Forge[string, string, *childEntity]("outchild").
Action("record", func(c state.ActionCtx[*childEntity]) (state.Effect, error) {
c.Entity.result = "done-output"
return nil, nil
}).
State("working").
State("done").Final().OnEntry("record").
Initial("working").
Transition("working").On("finish").GoTo("done").
Quench()

// Capture the ActorInstance returned by NewActor so Output() can be called on
// it directly across the completion boundary.
var captured state.ActorInstance
behavior := func(input map[string]any) (state.ActorInstance, error) {
inst := cm.Cast(&childEntity{}, state.WithInitialState("working"))
ai := state.NewActor(inst, func(i *state.Instance[string, string, *childEntity]) any {
return i.Entity().result
})
captured = ai
return ai, nil
}

parent := state.Forge[string, string, *trec]("outparent").
State("idle").
State("supervising").InvokeActor("outchild",
state.WithInvokeOnDone("childDone"), state.WithInvokeOnError("childErr")).
State("complete").
Initial("idle").
CurrentStateFn(func(*trec) string { return "idle" }).
Transition("idle").On("start").GoTo("supervising").
Transition("supervising").On("childDone").GoTo("complete").
Quench()

p := parent.Cast(&trec{}, state.WithInitialState("idle"))
sys := state.NewActorSystem(p).Register("outchild", behavior)
ctx := context.Background()

res := p.Fire(ctx, "start")
sys.Absorb(ctx, res.Effects)
if captured == nil {
t.Fatal("ActorBehavior was not invoked; no ActorInstance captured")
}

// Before the child reaches its final state, Output() must be nil.
if got := captured.Output(); got != nil {
t.Fatalf("Output() before final = %v, want nil", got)
}

id := state.ActorID(parent.Name(), "supervising", 0)
ref, ok := sys.Ref(id)
if !ok {
t.Fatalf("no actor ref for id %q", id)
}
if !sys.Deliver(ctx, ref, "finish") {
t.Fatal("Deliver(finish) returned false; actor not running")
}

// After the child reached "done" (final), Output() returns the extracted value.
if got := captured.Output(); got != "done-output" {
t.Fatalf("Output() after final = %v, want %q", got, "done-output")
}
if p.Current() != "complete" {
t.Fatalf("parent state = %q, want complete", p.Current())
}
}

// TestWithSpawnInput_ReachesSpawnedActor pins WithSpawnInput: the input map
// declared on a dynamic Spawn must arrive verbatim at the spawned actor's
// ActorBehavior. We capture the input the behavior receives and assert each key
// round-trips. The child is then driven to completion as an end-to-end sanity
// check that the spawn produced a live, addressable actor.
func TestWithSpawnInput_ReachesSpawnedActor(t *testing.T) {
cm := state.Forge[string, string, *childEntity]("inworker").
State("working").
State("done").Final().
Initial("working").
Transition("working").On("finish").GoTo("done").
Quench()

var received map[string]any
behavior := func(input map[string]any) (state.ActorInstance, error) {
received = input
inst := cm.Cast(&childEntity{}, state.WithInitialState("working"))
return state.NewActor(inst, nil), nil
}

m := state.Forge[string, string, *trec]("inspawner").
State("idle").
State("active").
Initial("idle").
CurrentStateFn(func(*trec) string { return "idle" }).
Transition("idle").On("go").GoTo("active").
Spawn("inworker", "worker-1",
state.WithSpawnInput(map[string]any{"greeting": "hello", "count": float64(42)}),
state.WithSpawnOnDone("workerDone"), state.WithSpawnOnError("workerErr")).
Transition("active").On("workerDone").GoTo("idle").
Quench()

parent := m.Cast(&trec{}, state.WithInitialState("idle"))
sys := state.NewActorSystem(parent).Register("inworker", behavior)
ctx := context.Background()

res := parent.Fire(ctx, "go")
sys.Absorb(ctx, res.Effects)

if received == nil {
t.Fatal("spawned ActorBehavior received nil input; WithSpawnInput did not reach the actor")
}
if got := received["greeting"]; got != "hello" {
t.Fatalf("input[greeting] = %v, want %q", got, "hello")
}
if got := received["count"]; got != float64(42) {
t.Fatalf("input[count] = %v, want %v", got, float64(42))
}
if len(received) != 2 {
t.Fatalf("input has %d keys (%v), want exactly 2", len(received), received)
}

// The spawn produced a live, addressable actor under the explicit id.
ref, ok := sys.Ref("worker-1")
if !ok {
t.Fatal("spawned actor worker-1 is not running/addressable")
}
if !sys.Deliver(ctx, ref, "finish") {
t.Fatal("Deliver(finish) returned false; spawned actor not running")
}
if sys.Running() != 0 {
t.Fatalf("running actors after completion = %d, want 0", sys.Running())
}
}
2 changes: 1 addition & 1 deletion state/actor_system.go
Original file line number Diff line number Diff line change
Expand Up @@ -510,7 +510,7 @@ func (s *ActorSystem[S, E, C]) Tick(ctx context.Context, id string) []FireResult

done, output, panicErr := deliverFireGuarded(ctx, inst, env.event)
if panicErr != nil {
if p, ok := panicErr.(*ErrActorPanic); ok {
if p, ok := panicErr.(*ActorPanicError); ok {
p.ActorID = id
}
// A child that panicked while stepping is a failure: settle it as an error
Expand Down
73 changes: 73 additions & 0 deletions state/forbid_any_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package state_test

import (
"context"
"testing"

"github.com/stablekernel/crucible/state"
)

// This file pins ForbidAny (kernel.go:1255): a forbidden wildcard on a state
// consumes EVERY event not otherwise handled there, ignoring it in place instead
// of bubbling to an ancestor that would handle it. It is the wildcard counterpart
// of Forbid: where Forbid blocks one named event, ForbidAny blocks all unhandled
// events.

// TestForbidAny_ConsumesEveryUnhandledEventWithoutBubbling asserts the ForbidAny
// contract against an ancestor that would otherwise handle the events. The child
// "work" declares ForbidAny. The parent "running" handles "stop" and "kick". Both
// are unhandled at the child, so — unlike a child with NO handler (which lets the
// event bubble), and unlike a plain Forbid (which blocks only one named event) —
// the forbidden wildcard must consume both in place. Neither bubbles to the parent.
func TestForbidAny_ConsumesEveryUnhandledEventWithoutBubbling(t *testing.T) {
m := provide(state.Forge[string, string, *trec]("forbidany").
State("idle").
Transition("idle").On("start").GoTo("running").
SuperState("running").
Initial("work").
Transition("running").On("stop").GoTo("halted").
Transition("running").On("kick").GoTo("halted").
SubState("work").
ForbidAny().
EndSuperState().
State("halted").
Initial("idle"))

inst := m.Cast(&trec{}, state.WithInitialState("running"))
if got := inst.Current(); got != "work" {
t.Fatalf("setup: Current() = %q, want work", got)
}

// "stop" is handled by the parent but unhandled at the child: ForbidAny consumes
// it in place, so it must NOT bubble to the parent's stop -> halted.
res := inst.Fire(context.Background(), "stop")
if res.Err != nil {
t.Fatalf("Fire(stop) err = %v, want nil (forbidden wildcard ignores it)", res.Err)
}
if res.NewState == "halted" {
t.Fatalf("forbidden-wildcard event 'stop' bubbled to ancestor: NewState = halted")
}
if inst.Current() != "work" {
t.Fatalf("Current() = %q, want work (forbidden wildcard consumed 'stop' in place)", inst.Current())
}

// "kick" is also handled by the parent but unhandled at the child: unlike plain
// Forbid (which blocks only one named event and lets other events bubble),
// ForbidAny blocks this one too — the defining difference between the two.
res = inst.Fire(context.Background(), "kick")
if res.Err != nil {
t.Fatalf("Fire(kick) err = %v, want nil (forbidden wildcard ignores it)", res.Err)
}
if res.NewState == "halted" {
t.Fatalf("forbidden-wildcard event 'kick' bubbled to ancestor: NewState = halted")
}
if inst.Current() != "work" {
t.Fatalf("Current() = %q, want work (forbidden wildcard consumed 'kick' in place)", inst.Current())
}

// The outcome of a forbidden (consumed) event is a clean success, mirroring the
// specific-Forbid contract (TestFireFromState_ForbiddenEventIsConsumed).
if res.Trace.Outcome != state.OutcomeSuccess {
t.Fatalf("forbidden-wildcard outcome = %v, want OutcomeSuccess", res.Trace.Outcome)
}
}
Loading
Loading