LoadErrors.Is() causes infinite recursion (fatal stack overflow) when a custom UnmarshalYAML implementation wraps the error returned by the unmarshal callback with %w.
Minimal reproduction
package main
import (
"errors"
"fmt"
"os"
"time"
"go.yaml.in/yaml/v4"
)
type Inner struct {
Value string `yaml:"value"`
}
type Wrapper struct {
Item Inner `yaml:"item"`
}
func (w *Wrapper) UnmarshalYAML(unmarshal func(any) error) error {
var single Inner
if err := unmarshal(&single); err != nil {
// Standard Go idiom — wrapping with %w
return fmt.Errorf("wrapper failed: %w", err)
}
return nil
}
type Root struct {
Items []Wrapper `yaml:"items"`
}
func main() {
done := make(chan struct{})
go func() {
data := []byte("items:\n - not-an-object\n")
var r Root
err := yaml.Unmarshal(data, &r)
if err != nil {
// This triggers fatal error: stack overflow
_ = errors.Is(err, errors.New("test"))
}
close(done)
}()
select {
case <-done:
fmt.Println("Completed")
case <-time.After(3 * time.Second):
fmt.Println("TIMEOUT: stack overflow")
os.Exit(1)
}
}
Root cause
yaml.Unmarshal reuses a single LoadErrors object. When the custom UnmarshalYAML wraps the callback's error with %w, and yaml.Unmarshal adds that wrapped error back to the same LoadErrors, it creates a cycle:
LoadErrors → ConstructError → fmt.wrapError → LoadErrors
LoadErrors.Is() calls errors.Is(err, target) on each ConstructError, which unwraps back to the same LoadErrors, causing infinite recursion.
Environment
go.yaml.in/yaml/v4 v4.0.0-rc.4
- Go 1.26
Workaround
Custom UnmarshalYAML implementations must use %v instead of %w when wrapping errors from the unmarshal callback, but this is non-obvious and breaks standard Go error wrapping conventions.
LoadErrors.Is()causes infinite recursion (fatal stack overflow) when a customUnmarshalYAMLimplementation wraps the error returned by theunmarshalcallback with%w.Minimal reproduction
Root cause
yaml.Unmarshalreuses a singleLoadErrorsobject. When the customUnmarshalYAMLwraps the callback's error with%w, andyaml.Unmarshaladds that wrapped error back to the sameLoadErrors, it creates a cycle:LoadErrors.Is()callserrors.Is(err, target)on eachConstructError, which unwraps back to the sameLoadErrors, causing infinite recursion.Environment
go.yaml.in/yaml/v4 v4.0.0-rc.4Workaround
Custom
UnmarshalYAMLimplementations must use%vinstead of%wwhen wrapping errors from theunmarshalcallback, but this is non-obvious and breaks standard Go error wrapping conventions.