diff --git a/state/actor_escalation.go b/state/actor_escalation.go index b5a0e23..d5aa46b 100644 --- a/state/actor_escalation.go +++ b/state/actor_escalation.go @@ -203,31 +203,15 @@ func (s *ActorSystem[S, E, C]) parentOf(childID string) string { return "" } -// ErrActorPanic 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 { - // 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 { - 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{Recovered: r} } }() done, output = inst.DeliverFire(ctx, event) @@ -235,13 +219,13 @@ func deliverFireGuarded(ctx context.Context, inst ActorInstance, event any) (don // 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{Recovered: ap.Recovered} } return false, nil, err } diff --git a/state/actor_escalation_test.go b/state/actor_escalation_test.go index e5d06a5..b54a043 100644 --- a/state/actor_escalation_test.go +++ b/state/actor_escalation_test.go @@ -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) @@ -414,3 +414,102 @@ func TestActorEscalation_UnboundSrc_NoOnError_Escalates(t *testing.T) { t.Fatalf("escalation cause is not *UnboundActorError: %v", esc) } } + +// TestActorPanicError_TypeAndUnwrap asserts the conformance of ActorPanicError with +// its sibling panic error types: the type is discoverable via errors.As, and Unwrap +// exposes an underlying error cause when the panic value is itself an error. +func TestActorPanicError_TypeAndUnwrap(t *testing.T) { + t.Run("errors.As finds ActorPanicError in escalation chain", func(t *testing.T) { + sys, _, id := startSupervising(t, noErrorParent(), panicChildBehavior()) + ctx := context.Background() + + ref, ok := sys.Ref(id) + if !ok { + t.Fatalf("no ref for actor %q", id) + } + sys.Deliver(ctx, ref, "boom") + + esc := sys.LastEscalation() + if esc == nil { + t.Fatal("LastEscalation is nil; child panic was swallowed") + } + + var pErr *state.ActorPanicError + if !errors.As(esc, &pErr) { + t.Fatalf("errors.As(*ActorPanicError) returned false; escalation = %v", esc) + } + if pErr.ActorID == "" { + t.Fatal("ActorPanicError.ActorID is empty; back-fill did not run") + } + }) + + t.Run("Unwrap returns nil when panic value is not an error", func(t *testing.T) { + // panic("child blew up") — a string, not an error. + sys, _, id := startSupervising(t, noErrorParent(), panicChildBehavior()) + ctx := context.Background() + + ref, ok := sys.Ref(id) + if !ok { + t.Fatalf("no ref for actor %q", id) + } + sys.Deliver(ctx, ref, "boom") + + esc := sys.LastEscalation() + if esc == nil { + t.Fatal("LastEscalation is nil") + } + var pErr *state.ActorPanicError + if !errors.As(esc, &pErr) { + t.Fatalf("errors.As(*ActorPanicError) = false") + } + // The panic value is the string "child blew up", not an error — Unwrap must return nil. + if pErr.Unwrap() != nil { + t.Fatalf("Unwrap() = %v, want nil for non-error panic value", pErr.Unwrap()) + } + }) + + t.Run("Unwrap reaches inner error when panic value is an error", func(t *testing.T) { + // Build a child that panics with an error value so we can verify Unwrap traversal. + inner := errors.New("inner cause") + cm := state.Forge[string, string, *childEntity]("child"). + Action("errboom", func(state.ActionCtx[*childEntity]) (state.Effect, error) { + panic(inner) + }). + State("working"). + State("blow").OnEntry("errboom"). + State("done").Final(). + Initial("working"). + Transition("working").On("boom").GoTo("blow"). + Quench() + behavior := func(map[string]any) (state.ActorInstance, error) { + inst := cm.Cast(&childEntity{}, state.WithInitialState("working")) + return state.NewActor(inst, nil), nil + } + + sys, _, id := startSupervising(t, noErrorParent(), behavior) + ctx := context.Background() + + ref, ok := sys.Ref(id) + if !ok { + t.Fatalf("no ref for actor %q", id) + } + sys.Deliver(ctx, ref, "boom") + + esc := sys.LastEscalation() + if esc == nil { + t.Fatal("LastEscalation is nil") + } + + var pErr *state.ActorPanicError + if !errors.As(esc, &pErr) { + t.Fatalf("errors.As(*ActorPanicError) = false") + } + // Unwrap must surface the inner error so errors.Is can traverse to it. + if !errors.Is(pErr, inner) { + t.Fatalf("errors.Is(pErr, inner) = false; Unwrap did not expose inner error") + } + if unwrapped := pErr.Unwrap(); unwrapped != inner { + t.Fatalf("Unwrap() = %v, want %v", unwrapped, inner) + } + }) +} diff --git a/state/actor_system.go b/state/actor_system.go index 48d7e66..8d95f28 100644 --- a/state/actor_system.go +++ b/state/actor_system.go @@ -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 diff --git a/state/errors.go b/state/errors.go index 4da3537..1ac786c 100644 --- a/state/errors.go +++ b/state/errors.go @@ -395,3 +395,30 @@ type UnknownEffectKindError struct { func (e *UnknownEffectKindError) Error() string { return fmt.Sprintf("crucible/state: unknown effect kind %q (not registered for dispatch)", e.Kind) } + +// 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. +// When the recovered value is itself an error, Unwrap exposes it so errors.Is / +// errors.As can reach the inner cause. +type ActorPanicError struct { + // ActorID is the registry id of the actor that panicked. + ActorID string + // Recovered is the recovered panic value, rendered for the error message. + Recovered any +} + +// Error renders the recovered actor panic. +func (e *ActorPanicError) Error() string { + return fmt.Sprintf("crucible/state: actor %q panicked: %v", e.ActorID, e.Recovered) +} + +// Unwrap exposes the recovered value when it is an error, so errors.Is / errors.As +// can traverse to the inner cause; it returns nil for non-error panic values. +func (e *ActorPanicError) Unwrap() error { + if err, ok := e.Recovered.(error); ok { + return err + } + return nil +}