Close callback contexts when callbacks panic

This commit is contained in:
2026-10-25 21:25:57 +00:00
parent ea27f17f5c
commit 08a8c25542
4 changed files with 90 additions and 6 deletions

View File

@@ -184,6 +184,9 @@ Focused check: `go test ./internal/engine -run 'Projection|Inline|Resolution'`.
Finding: **B01**. Finding: **B01**.
Owners: `internal/engine/resolution.go`, `validation.go`, callback tests. Owners: `internal/engine/resolution.go`, `validation.go`, callback tests.
Status: **complete** (B01). Prior stage commits: `2736ddf`, `deb9aa5`,
`ae3408a`, `ea27f17`. Validation passed: `GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -run 'Callback|Resolution|Validation|Version' -count=1` and `GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -count=1`.
Wrap each application callback invocation in a small function with deferred Wrap each application callback invocation in a small function with deferred
context deactivation. The defer must run when that callback returns or panics, context deactivation. The defer must run when that callback returns or panics,
not at the end of subsequent projection/auditing work. Apply this independently not at the end of subsequent projection/auditing work. Apply this independently

View File

@@ -0,0 +1,73 @@
package engine
import (
"errors"
"testing"
)
func capturePanic(call func()) (recovered any) {
defer func() { recovered = recover() }()
call()
return nil
}
func requirePanicValue(t *testing.T, want any, call func()) {
t.Helper()
if got := capturePanic(call); got != want {
t.Fatalf("panic = %#v, want %#v", got, want)
}
}
func TestCallbackContextsCloseWhenCallbacksPanic(t *testing.T) {
type config struct {
Value string `yaml:"value"`
Version string `yaml:"version"`
}
t.Run("resolution", func(t *testing.T) {
sentinel := errors.New("resolution panic sentinel")
var retained *Resolution
got := capturePanic(func() {
_, _ = testLoad[config](NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
retained = resolution
panic(sentinel)
}}})
})
if got != sentinel {
t.Fatalf("callback panic = %#v, want original sentinel", got)
}
requirePanicValue(t, closedCallbackContextMessage, func() { retained.IsExplicit(Keys("value")) })
})
t.Run("validation", func(t *testing.T) {
sentinel := errors.New("validation panic sentinel")
var retained *Validation
got := capturePanic(func() {
_, _ = testLoad[config](NoSource(), Options[config]{Definition: Definition[config]{Validate: func(_ *config, validation *Validation) {
retained = validation
panic(sentinel)
}}})
})
if got != sentinel {
t.Fatalf("callback panic = %#v, want original sentinel", got)
}
requirePanicValue(t, closedCallbackContextMessage, func() { retained.Error(Keys("value"), "too late") })
})
t.Run("version", func(t *testing.T) {
sentinel := errors.New("version panic sentinel")
var retained *Validation
got := capturePanic(func() {
_, _ = testLoad[config](Bytes("version.yml", []byte("version: v1\n")), Options[config]{Definition: Definition[config]{
Version: &VersionRule{Path: Keys("version"), Check: func(_ Version, validation *Validation) error {
retained = validation
panic(sentinel)
}},
}})
})
if got != sentinel {
t.Fatalf("callback panic = %#v, want original sentinel", got)
}
requirePanicValue(t, closedCallbackContextMessage, func() { retained.Warning(Keys("version"), "too late") })
})
}

View File

@@ -287,8 +287,11 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
records: make(map[string]*resolutionRecord), records: make(map[string]*resolutionRecord),
} }
context := &Resolution{state: state} context := &Resolution{state: state}
err := resolver(configuration, context) var err error
state.active = false func() {
defer func() { state.active = false }()
err = resolver(configuration, context)
}()
if err != nil && !hasErrors(state.diagnostics) { if err != nil && !hasErrors(state.diagnostics) {
state.addDiagnostic(CodeResolveHookFailed, Path{}, "application resolution failed") state.addDiagnostic(CodeResolveHookFailed, Path{}, "application resolution failed")
} }

View File

@@ -102,8 +102,10 @@ func runValidation[T any](configuration *T, validator func(*T, *Validation), sch
invalidPath: CodeValidateInvalidPath, schema: schema, document: input, invalidPath: CodeValidateInvalidPath, schema: schema, document: input,
} }
context := &Validation{state: state} context := &Validation{state: state}
func() {
defer func() { state.active = false }()
validator(configuration, context) validator(configuration, context)
state.active = false }()
after, issue := projectConfiguration(schema, reflect.ValueOf(*configuration), defaultProjectionLimits()) after, issue := projectConfiguration(schema, reflect.ValueOf(*configuration), defaultProjectionLimits())
if issue != nil { if issue != nil {
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(issue.path)) state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(issue.path))
@@ -253,8 +255,11 @@ func reportVersion(rule *VersionRule, version Version, root *document.Node) []Di
invalidPath: CodeVersionError, document: root, invalidPath: CodeVersionError, document: root,
} }
validation := &Validation{state: state} validation := &Validation{state: state}
err := rule.Check(version, validation) var err error
state.active = false func() {
defer func() { state.active = false }()
err = rule.Check(version, validation)
}()
if err != nil && !hasErrors(state.diagnostics) { if err != nil && !hasErrors(state.diagnostics) {
location, _ := nearestDocumentLocation(root, rule.Path) location, _ := nearestDocumentLocation(root, rule.Path)
state.diagnostics = append(state.diagnostics, Diagnostic{ state.diagnostics = append(state.diagnostics, Diagnostic{