Skip to content
Merged
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
24 changes: 4 additions & 20 deletions state/actor_escalation.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,45 +203,29 @@ 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)
// 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{Recovered: ap.Recovered}
}
return false, nil, err
}
Expand Down
103 changes: 101 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 Expand Up @@ -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)
}
})
}
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
27 changes: 27 additions & 0 deletions state/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading