diff --git a/pkg/raised/caching.go b/pkg/raised/caching.go index c01519c..385a55a 100644 --- a/pkg/raised/caching.go +++ b/pkg/raised/caching.go @@ -235,7 +235,7 @@ func (self *cacheSlot[K, V]) setVal(ck K, val V) { return } - for i := range self.next % maxCached { + for i := range min(self.next, maxCached) { if ck == self.keys[i] { self.vals[i] = val return diff --git a/pkg/raised/doc.go b/pkg/raised/doc.go index e612a8a..58c6cd4 100644 --- a/pkg/raised/doc.go +++ b/pkg/raised/doc.go @@ -21,16 +21,16 @@ // An optional ERROR(n) prefix embeds a numeric code for lightweight // classification: // -// type myPkg struct{} +// type pkg struct{} // // var ( -// ErrNotFound = raised.NewSentinelError[myPkg]("ERROR(1) not found") -// ErrBadRequest = raised.NewSentinelError[myPkg]("ERROR(2) bad request") +// ErrNotFound = raised.NewSentinelError[pkg]("ERROR(1) not found") +// ErrBadRequest = raised.NewSentinelError[pkg]("ERROR(2) bad request") // ) // // # Tracing // -// Trace and TraceAt wrap any error in a propagation trace. Each call records +// Trace wraps any error in a propagation trace. Each call records // the call site and an optional message. If the error is already a raised // Error it is extended in place; otherwise a new trace is created with the // error as its root cause: @@ -56,18 +56,6 @@ // fmt.Printf("%+v\n", err) // } // -// TraceAt is a hot-path variant that caches the call site PC, avoiding -// repeated runtime.Callers calls: -// -// const flk = 1 // unique constant within the package -// -// func process(item Item) error { -// if err := validate(item); err != nil { -// return raised.TraceAt(flk, err, "process item") -// } -// // ... -// } -// // # Classification // // When a package receives a foreign error it can assert its own sentinel @@ -89,18 +77,18 @@ // // # Error identity and keying // -// A Keyer computes a stable ErrorKey for a raised Error, derived from its +// An ErrorKeyer computes a stable ErrorKey for a raised Error, derived from its // propagation path and terminal root cause. Two errors sharing the same // ErrorKey represent the same problem: identical code path and equivalent // root cause. This is useful for error aggregation, deduplication, and // monitoring. // -// A Keyer is scoped to the sentinel family T, consistent with the phantom +// An ErrorKeyer is scoped to the sentinel family T, consistent with the phantom // type used for sentinel declaration: // -// type myPkg struct {} +// type pkg struct {} // -// var Keyer, _ = raised.NewKeyer[myPkg]() +// var Keyer, _ = raised.NewErrorKeyer[pkg](nil) // // func handle(err error) { // key, ok := Keyer.Key(err) @@ -112,11 +100,4 @@ // The ErrorKey is stable across process restarts and hosts as long as the // source code has not changed — it is derived from file/line strings rather // than runtime memory addresses. -// -// # Reliability -// -// raised relies on the Go runtime's pclntab for file/line resolution. -// pclntab survives standard production build flags including -s -w and -// -trimpath. File/line resolution will degrade only under deliberate -// obfuscation tools such as garble. package raised diff --git a/pkg/raised/error.go b/pkg/raised/error.go index 1a42e41..1a14080 100644 --- a/pkg/raised/error.go +++ b/pkg/raised/error.go @@ -123,6 +123,10 @@ type errTrace struct { // total Trace call count including compressed-out entries. next int + // epc (entry PC) holds the first program counter that is module local. + // due to trace compression epc may not be present in pcs. + epc uintptr + // pcs holds the program counter for each recorded Trace call site. pcs [traceSize]uintptr @@ -350,6 +354,7 @@ func (self *errTrace) snapshot(dst *errTraceSnapshot) { dst.cause = self.cause dst.class = self.class dst.next = self.next + dst.epc = self.epc dst.pcs = self.pcs } @@ -358,6 +363,7 @@ type errTraceSnapshot struct { cause error class SentinelError next int + epc uintptr pcs [traceSize]uintptr } @@ -383,6 +389,26 @@ func (self *errTraceSnapshot) Unwrap() []error { return rv } +// entryPoint returns the program counter of the module entry point for this trace. +// If epc was recorded, it is returned directly as it represents the first module-local +// call site encountered during error propagation, which is the most efficient location +// for a Classify call to stabilize the error Key. +// If epc is zero, falls back to the most recent PC in the trace as a best-effort approximation. +func (self *errTraceSnapshot) entryPoint() uintptr { + if 0 != self.epc { + return self.epc + } + + // use most recent pc as entry point + // we may do better iterating pcs in reverse order, looking at pc package until it is different than pcs[c]... + c := min(self.next, traceSize) - 1 + if c >= 0 { + return self.pcs[c] + } else { + return 0 + } +} + // getFileLines resolves a slice of PCs to formatted file/line strings. func getFileLines(pcs []uintptr) []string { if 0 == len(pcs) { @@ -441,7 +467,7 @@ var buildModPath string func init() { if info, ok := debug.ReadBuildInfo(); ok { - buildModPath = info.Main.Path + "/" + buildModPath = info.Main.Path + "/" // if info.Main.Path is "", buildModPath is not a valid pkgpath prefix. } } @@ -469,6 +495,11 @@ func addCallerInfo[K ~int](err *errTrace, flk K, msg string, skip int) { err.pcs[pos] = pc err.msgs[pos] = strings.TrimSpace(msg) + // record module "entry point" + if err.epc == 0 && isLocal(pc) { + err.epc = pc + } + // clear cached summary & trace err._summary = "" err._trace = "" @@ -476,6 +507,16 @@ func addCallerInfo[K ~int](err *errTrace, flk K, msg string, skip int) { err.next += 1 } +// isLocal returns true if pc is a program counter within "project" module. +// TODO: we need an heuristic in case module is undefined. +func isLocal(pc uintptr) bool { + // FuncForPC does not alloc where as CallersFrames do + if fn := runtime.FuncForPC(pc - 1); fn != nil && pc > 0 { + return strings.HasPrefix(fn.Name(), buildModPath) + } + return false +} + // --- // program counter resolution caching @@ -546,6 +587,11 @@ func init() { } } +// L1Key is a stable identifier for an error's propagation path, derived from +// the module entry point, the recorded propagation PCs, and the total Trace +// call count. +type L1Key = [2 + traceSize]uintptr + // traceL1Key is the stable outer key for traceCache, derived from the recorded // PCs and total turn count. Two errTrace values sharing this key took the same // code path and map to the same cacheSlot. diff --git a/pkg/raised/keying.go b/pkg/raised/keying.go index 3a075b7..332e6c1 100644 --- a/pkg/raised/keying.go +++ b/pkg/raised/keying.go @@ -8,6 +8,7 @@ import ( "slices" ) +// keySize is the number of bytes in an ErrorKey. const keySize = 16 // ErrorKey is a fixed-size hash derived from an error's propagation path and @@ -19,35 +20,86 @@ type ErrorKey = [keySize]byte // The hash must produce at least keySize bytes. type HashFunc = func() hash.Hash -// Keyer computes a stable ErrorKey for a raised Error. -type Keyer interface { - // Key returns a stable ErrorKey for err and true if err is a raised Error - // with a resolvable terminal cause. Returns a zero ErrorKey and false - // if err is not a raised Error or has no resolvable terminal cause. +// ErrorKeyer computes a stable ErrorKey for a raised Error. +// The key is derived from the error's propagation path and terminal root cause, +// independently of any dynamic context embedded in error messages. +// Key returns true only when a key could be determined. +type ErrorKeyer interface { + // Key returns an ErrorKey and a bool indicating if the key could be determined. Key(error) (ErrorKey, bool) + + isErrorKeyer() bool } -// NewKeyer returns a Keyer using SHA256 as the default hash function, +// NewErrorKeyer returns an ErrorKeyer using SHA256 as the default hash function, // scoped to the sentinel family identified by the phantom type T. -func NewKeyer[T any]() (Keyer, error) { +func NewErrorKeyer[T any](ukl UnstableKeyListener) (ErrorKeyer, error) { // sha256 should provide better collision resistance than fnv128 - return NewSentinelKeyer[T](sha256.New) + return NewSentinelErrorKeyer[T](sha256.New, ukl) +} + +// UnstableKeyEvent is delivered to an UnstableKeyListener when the ErrorKey +// for a given code path could not be stably determined, indicating that the +// terminal cause varies across calls originating from the same location. +// This typically occurs when a foreign error embeds transient state — such as +// a request ID or a dynamic value — in its message, preventing key stabilisation. +// +// The most efficient fix is to call Classify on the propagating Error at the +// EntryPoint location, asserting a stable sentinel identity that overrides the +// unstable foreign cause. +type UnstableKeyEvent struct { + // Error is the full raised Error for which a stable key could not be determined. + Error Error + + // K1 is the stable propagation path key for Error. + // K1 remains constant for a given code path and can be used by the listener to + // track instability frequency per origin site. + K1 L1Key + + // Key is the ErrorKey that was derived for Error on this call. + // It may differ across calls originating from the same code path. + Key ErrorKey + + // EntryPoint is the program counter of the module entry site for Error. + // This is the recommended location at which to call Classify in order to + // assert a stable sentinel identity and resolve the instability. + EntryPoint uintptr +} + +// UnstableKeyListener is implemented by types that wish to observe key instability. +type UnstableKeyListener interface { + // OnUnstableKey is called each time an ErrorKey fluctuates for a given + // code path, with the associated event. + OnUnstableKey(UnstableKeyEvent) +} + +// UnstableKeyListenerFunc is an adapter type to allow the use of ordinary +// functions as UnstableKeyListener. +type UnstableKeyListenerFunc func(UnstableKeyEvent) + +// OnUnstableKey calls self. +func (self UnstableKeyListenerFunc) OnUnstableKey(evt UnstableKeyEvent) { + self(evt) } -// sentinelKeyer is the Keyer implementation scoped to sentinel family T. -type sentinelKeyer[T any] struct { +// sentinelErrorKeyer is the ErrorKeyer implementation scoped to sentinel family T. +// It is immutable after construction. +type sentinelErrorKeyer[T any] struct { // hf is the hash factory used to compute ErrorKeys. hf HashFunc // tc caches computed ErrorKeys keyed by code path and terminal cause string, // amortizing the cost of hash computation and file/line resolution on hot paths. tc *keyCache + + // ukl is the optional listener notified on each cache miss. May be nil. + ukl UnstableKeyListener } -// NewSentinelKeyer returns a Keyer scoped to the sentinel family identified +// NewSentinelErrorKeyer returns a ErrorKeyer scoped to the sentinel family identified // by the phantom type T, using hf as the hash function. // Returns ErrInvalidHash if hf is nil or produces fewer than keySize bytes. -func NewSentinelKeyer[T any](hf HashFunc) (Keyer, error) { +func NewSentinelErrorKeyer[T any](hf HashFunc, ukl UnstableKeyListener) (ErrorKeyer, error) { // validate hf if nil == hf { return nil, Trace(ErrInvalidHash, "nil hash function") @@ -57,17 +109,17 @@ func NewSentinelKeyer[T any](hf HashFunc) (Keyer, error) { return nil, Trace(ErrInvalidHash, "insufficient hash size %d < %d", h.Size(), keySize) } - sk := sentinelKeyer[T]{hf: hf, tc: &keyCache{clock: ticks}} + sk := sentinelErrorKeyer[T]{hf: hf, tc: &keyCache{clock: ticks}, ukl: ukl} return &sk, nil } // Key computes a stable ErrorKey for err. err must be a raised Error produced -// by Trace or TraceAt. The key is derived from the error's propagation path +// by Trace. The key is derived from the error's propagation path // (as file/line strings) and the terminal cause resolved via UnwrapTerminal[T]. // Results are cached by code path and terminal cause string. // Returns false if err is not a raised Error or has no resolvable terminal cause. -func (self *sentinelKeyer[T]) Key(err error) (ErrorKey, bool) { +func (self *sentinelErrorKeyer[T]) Key(err error) (ErrorKey, bool) { erk := ErrorKey{} // abort if err is not an *errTrace @@ -86,10 +138,21 @@ func (self *sentinelKeyer[T]) Key(err error) (ErrorKey, bool) { } // determine caching keys - k1 := traceL1Key{turnCount: snp.next, pcs: snp.pcs} - cause := trm.Error() // less noisy than ert.cause which can be any error... - erk, rs := self.tc.Get(k1, cause) + k1 := L1Key{} + k1[0] = snp.epc + copy(k1[1:(1+traceSize)], snp.pcs[:]) + k1[1+traceSize] = uintptr(snp.next) // not a valid PC, used to simplify k1 + + k2 := "" + stn, ok := trm.(SentinelError) + if ok { + k2 = stn.Fingerprint() + } else { + k2 = trm.Error() // less noisy than ert.cause which can be any error... + } + + erk, rs := self.tc.Get(k1, k2) if cchHit == rs { return erk, true } @@ -102,9 +165,9 @@ func (self *sentinelKeyer[T]) Key(err error) (ErrorKey, bool) { // --- // cause component hs.Write([]byte("CS")) - binary.BigEndian.PutUint64(ib[:], uint64(len(cause))) + binary.BigEndian.PutUint64(ib[:], uint64(len(k2))) hs.Write(ib[:]) - hs.Write([]byte(cause)) + hs.Write([]byte(k2)) // --- // next component @@ -123,12 +186,14 @@ func (self *sentinelKeyer[T]) Key(err error) (ErrorKey, bool) { case flc > traceSize: flc = traceSize } + flc += 1 // pc are read from k1 which is [epc|pcs...|next] hs.Write([]byte("FLC")) binary.BigEndian.PutUint64(ib[:], uint64(flc)) hs.Write(ib[:]) // hash each fileline in ert code path - fls := getFileLines(snp.pcs[:flc]) + // flc allows excluding next which is not a valid pc + fls := getFileLines(k1[:flc]) for _, fln := range fls { hs.Write([]byte("FLN")) binary.BigEndian.PutUint64(ib[:], uint64(len(fln))) @@ -141,15 +206,25 @@ func (self *sentinelKeyer[T]) Key(err error) (ErrorKey, bool) { copy(erk[:], hs.Sum(nil)) if rs == cchMissCacheNew { - self.tc.Set(k1, cause, erk) + self.tc.Set(k1, k2, erk) + } + + // dispatch new KeyMissEvent... + if nil != self.ukl { + evt := UnstableKeyEvent{Error: ert, K1: k1, Key: erk, EntryPoint: snp.entryPoint()} + self.ukl.OnUnstableKey(evt) } return erk, true } +func (self *sentinelErrorKeyer[T]) isErrorKeyer() bool { + return true +} + // keyCache is a timedCache mapping (code path, terminal cause string) to ErrorKey. -type keyCache = timedCache[traceL1Key, string, ErrorKey] +type keyCache = timedCache[L1Key, string, ErrorKey] // UnwrapTerminal returns "minimal" error obtained by recursively unwrapping err or // casting err to Sentinel[T], SentinelError... diff --git a/pkg/raised/keying_test.go b/pkg/raised/keying_test.go new file mode 100644 index 0000000..a8b0640 --- /dev/null +++ b/pkg/raised/keying_test.go @@ -0,0 +1,407 @@ +package raised + +import ( + "crypto/sha256" + "errors" + "fmt" + "hash" + "sync" + "testing" +) + +// ---- phantom types for test sentinel families ---- + +type familyA struct{} +type familyB struct{} + +// ---- sentinel declarations ---- + +var ( + errA1 = NewSentinelError[familyA]("ERROR(1) sentinel A1") + errA2 = NewSentinelError[familyA]("ERROR(2) sentinel A2") + errB1 = NewSentinelError[familyB]("ERROR(1) sentinel B1") +) + +// ---- construction tests ---- + +func TestKeying_NewSentinelErrorKeyer_NilHash(t *testing.T) { + _, err := NewSentinelErrorKeyer[familyA](nil, nil) + if err == nil { + t.Fatal("expected error for nil hash function, got nil") + } + if !errors.Is(err, ErrInvalidHash) { + t.Fatalf("expected ErrInvalidHash, got %v", err) + } +} + +func TestKeying_NewSentinelErrorKeyer_InsufficientHashSize(t *testing.T) { + _, err := NewSentinelErrorKeyer[familyA](smallHashFactory, nil) + if err == nil { + t.Fatal("expected error for insufficient hash size, got nil") + } + if !errors.Is(err, ErrInvalidHash) { + t.Fatalf("expected ErrInvalidHash, got %v", err) + } +} + +func TestKeying_NewSentinelErrorKeyer_Valid(t *testing.T) { + ek, err := NewSentinelErrorKeyer[familyA](sha256Factory, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ek == nil { + t.Fatal("expected non-nil ErrorKeyer") + } + if !ek.isErrorKeyer() { + t.Fatal("isErrorKeyer() returned false") + } +} + +func TestKeying_NewErrorKeyer_Valid(t *testing.T) { + ek, err := NewErrorKeyer[familyA](nil) + if err != nil { + t.Fatalf("NewErrorKeyer: unexpected error: %v", err) + } + if ek == nil { + t.Fatal("expected non-nil ErrorKeyer") + } +} + +// ---- Key: input-validation tests ---- + +func TestKeying_Key_NonTracedError(t *testing.T) { + ek := mustKeyer(t, nil) + _, ok := ek.Key(errors.New("plain")) + if ok { + t.Error("expected false for a non-*errTrace error") + } +} + +func TestKeying_Key_NilError(t *testing.T) { + ek := mustKeyer(t, nil) + _, ok := ek.Key(nil) + if ok { + t.Error("expected false for nil error") + } +} + +// TestKey_NoResolvableTerminal documents the limitation: reaching a genuinely +// nil UnwrapTerminal result requires package-internal errTrace construction. +// The non-traced and nil paths above already cover the false-return branches +// that are reachable from outside the package. +func TestKeying_Key_NoResolvableTerminal(t *testing.T) { + t.Skip("nil-terminal path requires internal construction; covered by TestKey_NonTracedError and TestKey_NilError") +} + +// ---- Key: correctness tests ---- + +func TestKeying_Key_SameCallSiteSameCause_Equal(t *testing.T) { + ek := mustKeyer(t, nil) + + // Create the error once; key it twice. The key must be stable for the + // same (call-site PC, cause) pair regardless of how many times Key is called. + e := traceA(errA1) + k1, ok1 := ek.Key(e) + k2, ok2 := ek.Key(e) + + if !ok1 || !ok2 { + t.Fatalf("Key returned false: ok1=%v ok2=%v", ok1, ok2) + } + if k1 != k2 { + t.Errorf("expected equal keys for same call site and cause:\n k1=%x\n k2=%x", k1, k2) + } +} + +func TestKeying_Key_SameCallSiteDifferentCause_NotEqual(t *testing.T) { + ek := mustKeyer(t, nil) + + k1, ok1 := ek.Key(traceA(errA1)) + k2, ok2 := ek.Key(traceA(errA2)) + + if !ok1 || !ok2 { + t.Fatalf("Key returned false: ok1=%v ok2=%v", ok1, ok2) + } + if k1 == k2 { + t.Error("expected different keys for different causes at the same call site") + } +} + +func TestKeying_Key_DifferentCallSiteSameCause_NotEqual(t *testing.T) { + ek := mustKeyer(t, nil) + + // traceA and traceB are defined on different source lines, giving distinct PCs. + k1, ok1 := ek.Key(traceA(errA1)) + k2, ok2 := ek.Key(traceB(errA1)) + + if !ok1 || !ok2 { + t.Fatalf("Key returned false: ok1=%v ok2=%v", ok1, ok2) + } + if k1 == k2 { + t.Error("expected different keys for the same cause at different call sites") + } +} + +func TestKeying_Key_ClassifyOverridesDeterminesKey(t *testing.T) { + ek := mustKeyer(t, nil) + + // Both errors must originate from the same call-site so that their + // propagation-path component (k1) is identical. traceClassifyPair + // creates them on a single source line. + native, foreign := traceClassifyPair(errA1, errors.New("transient foreign error"), errA1) + + kn, okn := ek.Key(native) + kf, okf := ek.Key(foreign) + + if !okn || !okf { + t.Fatalf("Key returned false: okn=%v okf=%v", okn, okf) + } + if kn != kf { + t.Errorf("expected Classify to yield equal keys:\n native=%x\n classified=%x", kn, kf) + } +} + +func TestKeying_Key_ForeignErrorLeafHeuristic_Stable(t *testing.T) { + // A foreign error with a fixed message must produce a stable key across calls. + ek := mustKeyer(t, nil) + + // Create the traced error once; key it twice. Creating it twice would give + // two different call-site PCs (different source lines) and different keys. + e := traceA(errors.New("fixed foreign message")) + k1, ok1 := ek.Key(e) + k2, ok2 := ek.Key(e) + + if !ok1 || !ok2 { + t.Fatalf("Key returned false: ok1=%v ok2=%v", ok1, ok2) + } + if k1 != k2 { + t.Errorf("expected stable key for fixed foreign message:\n k1=%x\n k2=%x", k1, k2) + } +} + +func TestKeying_Key_CrossFamilySentinels_NotEqual(t *testing.T) { + // errA1 and errB1 share the same ERROR code but different phantom families, + // so their Fingerprints differ and the keys must differ. + ekA, _ := NewErrorKeyer[familyA](nil) + ekB, _ := NewErrorKeyer[familyB](nil) + + kA, okA := ekA.Key(traceA(errA1)) + kB, okB := ekB.Key(traceA(errB1)) + + if !okA || !okB { + t.Fatalf("Key returned false: okA=%v okB=%v", okA, okB) + } + if kA == kB { + t.Error("expected different keys for sentinels from different phantom families") + } +} + +// ---- Key: caching tests ---- + +func TestKeying_Key_CacheHit_ConsistentResult(t *testing.T) { + ek := mustKeyer(t, nil) + + // 64 iterations is enough to push the cacheSlot past cchGrowing into + // cchLearning/cchStable, exercising the cchHit branch. + e := traceA(errA1) + var firstKey ErrorKey + const iterations = 64 + for i := range iterations { + k, ok := ek.Key(e) + if !ok { + t.Fatalf("Key returned false on iteration %d", i) + } + if i == 0 { + firstKey = k + } else if k != firstKey { + t.Errorf("key changed on iteration %d:\n want=%x\n got =%x", i, firstKey, k) + } + } +} + +func TestKeying_Key_NilListener_NoPanic(t *testing.T) { + ek, err := NewSentinelErrorKeyer[familyA](sha256Factory, nil) + if err != nil { + t.Fatalf("NewSentinelErrorKeyer: %v", err) + } + // A foreign error causes a cache miss; the nil-listener branch must not panic. + _, ok := ek.Key(traceA(errors.New("foreign no-listener"))) + if !ok { + t.Error("expected ok=true for a foreign error with a resolvable leaf terminal") + } +} + +func TestKeying_Key_UnstableKeyListener_Called(t *testing.T) { + listener := &recordingListener{} + ek, err := NewSentinelErrorKeyer[familyA](sha256Factory, listener) + if err != nil { + t.Fatalf("NewSentinelErrorKeyer: %v", err) + } + + // Two errors at the same call site but different foreign messages produce + // different k2 values, causing cache misses and triggering the listener. + k1, ok1 := ek.Key(traceA(errors.New("msg-variant-1"))) + k2, ok2 := ek.Key(traceA(errors.New("msg-variant-2"))) + + if !ok1 || !ok2 { + t.Fatalf("Key returned false: ok1=%v ok2=%v", ok1, ok2) + } + if k1 == k2 { + t.Error("keys must differ for different foreign messages") + } + + if listener.count() == 0 { + t.Fatal("expected at least one UnstableKeyEvent, got none") + } + var zeroKey ErrorKey + for i, evt := range listener.all() { + if evt.EntryPoint == 0 { + t.Errorf("event[%d]: EntryPoint should be non-zero", i) + } + if evt.Key == zeroKey { + t.Errorf("event[%d]: Key should be non-zero", i) + } + } +} + +// ---- UnwrapTerminal tests ---- + +func TestKeying_UnwrapTerminal_SentinelT(t *testing.T) { + // errA1 is *Sentinel[familyA]; errors.As should match it immediately. + wrapped := fmt.Errorf("outer: %w", errA1) + got := UnwrapTerminal[familyA](wrapped) + if got == nil { + t.Fatal("expected non-nil terminal") + } + if got != errA1 { + t.Errorf("expected errA1, got %v", got) + } +} + +func TestKeying_UnwrapTerminal_SentinelError(t *testing.T) { + // errB1 is *Sentinel[familyB]: satisfies SentinelError but not Sentinel[familyA]. + // UnwrapTerminal[familyA] should fall through to the SentinelError branch. + wrapped := fmt.Errorf("outer: %w", errB1) + got := UnwrapTerminal[familyA](wrapped) + if got == nil { + t.Fatal("expected non-nil terminal") + } + if got != errB1 { + t.Errorf("expected errB1, got %v", got) + } +} + +func TestKeying_UnwrapTerminal_PlainError_DeepestLeaf(t *testing.T) { + leaf := errors.New("leaf") + outer := fmt.Errorf("outer: %w", fmt.Errorf("middle: %w", leaf)) + + got := UnwrapTerminal[familyA](outer) + if got == nil { + t.Fatal("expected non-nil terminal") + } + if got != leaf { + t.Errorf("expected leaf error, got %v", got) + } +} + +func TestKeying_UnwrapTerminal_MultiBranchUnwrap(t *testing.T) { + // errors.Join produces Unwrap() []error; both leaves are at depth 1. + // The sort order favours lower pos, so leafX (pos=1) beats leafY (pos=2). + leafX := errors.New("leaf-X") + leafY := errors.New("leaf-Y") + joined := errors.Join(leafX, leafY) + + got := UnwrapTerminal[familyA](joined) + if got == nil { + t.Fatal("expected non-nil terminal for joined errors") + } + if got != leafX { + t.Errorf("expected leafX as terminal, got %v", got) + } +} + +func TestKeying_UnwrapTerminal_Nil(t *testing.T) { + got := UnwrapTerminal[familyA](nil) + if got != nil { + t.Errorf("expected nil for nil input, got %v", got) + } +} + +func TestKeying_UnwrapTerminal_DirectSentinelT(t *testing.T) { + // Passing errA1 directly (not wrapped): should be returned as-is. + got := UnwrapTerminal[familyA](errA1) + if got != errA1 { + t.Errorf("expected errA1 returned directly, got %v", got) + } +} + +// ---- helpers ---- + +// mustKeyer creates a sentinelErrorKeyer[familyA] or fails the test. +func mustKeyer(t *testing.T, ukl UnstableKeyListener) ErrorKeyer { + t.Helper() + ek, err := NewErrorKeyer[familyA](ukl) + if err != nil { + t.Fatalf("NewErrorKeyer: unexpected error: %v", err) + } + return ek +} + +// traceA wraps err with one Trace call and returns the resulting Error. +// Keeping this as a named function gives a stable, single call-site PC +// that tests can rely on for equality assertions. +func traceA(err error) Error { return Trace(err, "wrap") } +func traceB(err error) Error { return Trace(err, "wrap") } // different call site + +// traceClassifyPair creates two errors from the same call-site on a single +// source line: one whose cause is nativeCause and one whose cause is +// foreignCause classified as classAs. Both share identical propagation-path +// PCs, making their L1 keys equal so Key can compare only the terminal cause. +func traceClassifyPair(nativeCause error, foreignCause error, classAs SentinelError) (Error, Error) { + n, f := Trace(nativeCause, "wrap"), Trace(foreignCause, "wrap") //nolint:wsl // intentional single-line + f.Classify(classAs) + return n, f +} + +// sha256Factory is the SHA-256 hash factory passed to NewSentinelErrorKeyer. +var sha256Factory HashFunc = sha256.New + +// smallHashFactory returns a hash whose Size() is keySize-1, used to trigger +// the ErrInvalidHash path in NewSentinelErrorKeyer. +func smallHashFactory() hash.Hash { return &tinyHash{} } + +// tinyHash is a minimal hash.Hash stub whose Size() is below keySize. +// All methods except Size and BlockSize delegate to a real (but ignored) hash +// so the interface is fully satisfied without an embed that could conflict. +type tinyHash struct{} + +func (h *tinyHash) Write(p []byte) (int, error) { return len(p), nil } +func (h *tinyHash) Sum(b []byte) []byte { return b } +func (h *tinyHash) Reset() {} +func (h *tinyHash) Size() int { return keySize - 1 } +func (h *tinyHash) BlockSize() int { return 64 } + +// recordingListener records every UnstableKeyEvent it receives, thread-safely. +type recordingListener struct { + mu sync.Mutex + events []UnstableKeyEvent +} + +func (r *recordingListener) OnUnstableKey(evt UnstableKeyEvent) { + r.mu.Lock() + defer r.mu.Unlock() + r.events = append(r.events, evt) +} + +func (r *recordingListener) count() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.events) +} + +func (r *recordingListener) all() []UnstableKeyEvent { + r.mu.Lock() + defer r.mu.Unlock() + cp := make([]UnstableKeyEvent, len(r.events)) + copy(cp, r.events) + return cp +}