diff --git a/internal/pkg/exec_test.go b/internal/pkg/exec_test.go index 558ab39..b2fad60 100644 --- a/internal/pkg/exec_test.go +++ b/internal/pkg/exec_test.go @@ -11,6 +11,7 @@ import ( "os/exec" "slices" "testing" + "unique" "hakurei.app/container/check" "hakurei.app/container/stub" @@ -74,7 +75,14 @@ func TestExec(t *testing.T) { return stub.UniqueError(0xcafe) }, }), - ), nil, pkg.Checksum{}, errors.Join(stub.UniqueError(0xcafe))}, + ), nil, pkg.Checksum{}, &pkg.DependencyCureError{ + { + Ident: unique.Make(pkg.ID(pkg.MustDecode( + "CWEoJqnSBpWf8uryC2qnIe3O1a_FZWUWZGbiVPsQFGW7pvDHiSwoK3QCU9-uxN87", + ))), + Err: stub.UniqueError(0xcafe), + }, + }}, {"invalid paths", pkg.NewExec( "", nil, 0, diff --git a/internal/pkg/pkg.go b/internal/pkg/pkg.go index e0b50cf..65bf5e1 100644 --- a/internal/pkg/pkg.go +++ b/internal/pkg/pkg.go @@ -394,7 +394,7 @@ type pendingArtifactDep struct { // Address of result error slice populated during [Cache.Cure], dereferenced // after acquiring errsMu if curing fails. No additional action is taken, // [Cache] and its caller are responsible for further error handling. - errs *[]error + errs *DependencyCureError // Address of mutex synchronising access to errs. errsMu *sync.Mutex @@ -1108,6 +1108,76 @@ func (c *Cache) Cure(a Artifact) ( return c.cure(a) } +// CureError wraps a non-nil error returned attempting to cure an [Artifact]. +type CureError struct { + Ident unique.Handle[ID] + Err error +} + +// Unwrap returns the underlying error. +func (e *CureError) Unwrap() error { return e.Err } + +// Error returns the error message from the underlying Err. +func (e *CureError) Error() string { return e.Err.Error() } + +// A DependencyCureError wraps errors returned while curing dependencies. +type DependencyCureError []*CureError + +// sort sorts underlying errors by their identifier. +func (e *DependencyCureError) sort() { + var identBuf [2]ID + slices.SortFunc(*e, func(a, b *CureError) int { + identBuf[0], identBuf[1] = a.Ident.Value(), b.Ident.Value() + return slices.Compare(identBuf[0][:], identBuf[1][:]) + }) +} + +// unwrap recursively expands and deduplicates underlying errors. +func (e *DependencyCureError) unwrap() DependencyCureError { + errs := make(DependencyCureError, 0, len(*e)) + for _, err := range *e { + if _e, ok := err.Err.(*DependencyCureError); ok { + errs = append(errs, _e.unwrap()...) + continue + } + errs = append(errs, err) + } + me := make(map[unique.Handle[ID]]*CureError, len(errs)) + for _, err := range errs { + me[err.Ident] = err + } + return slices.AppendSeq( + make(DependencyCureError, 0, len(me)), + maps.Values(me), + ) +} + +// Unwrap returns a deduplicated slice of underlying errors. +func (e *DependencyCureError) Unwrap() []error { + errs := e.unwrap() + errs.sort() + _errs := make([]error, len(errs)) + for i, err := range errs { + _errs[i] = err + } + return _errs +} + +// Error returns a user-facing multiline error message. +func (e *DependencyCureError) Error() string { + errs := e.unwrap() + errs.sort() + if len(errs) == 0 { + return "invalid dependency cure outcome" + } + var buf strings.Builder + buf.WriteString("errors curing dependencies:") + for _, err := range errs { + buf.WriteString("\n\t" + Encode(err.Ident.Value()) + ": " + err.Error()) + } + return buf.String() +} + // cure implements Cure without checking the full dependency graph. func (c *Cache) cure(a Artifact) ( pathname *check.Absolute, @@ -1290,7 +1360,7 @@ func (c *Cache) cure(a Artifact) ( var wg sync.WaitGroup wg.Add(len(deps)) res := make([]*check.Absolute, len(deps)) - errs := make([]error, 0, len(deps)) + errs := make(DependencyCureError, 0, len(deps)) var errsMu sync.Mutex for i, d := range deps { pending := pendingArtifactDep{d, &res[i], &errs, &errsMu, &wg} @@ -1306,11 +1376,7 @@ func (c *Cache) cure(a Artifact) ( wg.Wait() if len(errs) > 0 { - err = errors.Join(errs...) - if err == nil { - // unreachable - err = syscall.ENOTRECOVERABLE - } + err = &errs return } for i, p := range res { @@ -1411,7 +1477,7 @@ func (pending *pendingArtifactDep) cure(c *Cache) { } pending.errsMu.Lock() - *pending.errs = append(*pending.errs, err) + *pending.errs = append(*pending.errs, &CureError{c.Ident(pending.a), err}) pending.errsMu.Unlock() } diff --git a/internal/pkg/pkg_test.go b/internal/pkg/pkg_test.go index 586385b..6717869 100644 --- a/internal/pkg/pkg_test.go +++ b/internal/pkg/pkg_test.go @@ -534,6 +534,28 @@ func TestCache(t *testing.T) { }}}, nil, pkg.Checksum{}, pkg.InvalidFileModeError( 0400, )}, + + {"noncomparable error", &stubArtifactF{ + kind: pkg.KindExec, + params: []byte("artifact with dependency returning noncomparable error"), + deps: []pkg.Artifact{newStubFile( + pkg.KindHTTPGet, + pkg.ID{0xff, 3}, + nil, + nil, struct { + _ []byte + stub.UniqueError + }{UniqueError: 0xbad}, + )}, + }, nil, pkg.Checksum{}, &pkg.DependencyCureError{ + { + Ident: unique.Make(pkg.ID{0xff, 3}), + Err: struct { + _ []byte + stub.UniqueError + }{UniqueError: 0xbad}, + }, + }}, }) if c0, err := unsafeOpen( @@ -1020,6 +1042,78 @@ errors during scrub: } } +func TestDependencyCureError(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + err pkg.DependencyCureError + want string + unwrap []error + }{ + {"simple", pkg.DependencyCureError{ + {Ident: unique.Make(pkg.ID{0xff, 9}), Err: stub.UniqueError(0xbad09)}, + {Ident: unique.Make(pkg.ID{0xff, 0}), Err: stub.UniqueError(0xbad00)}, + {Ident: unique.Make(pkg.ID{0xff, 0xf}), Err: stub.UniqueError(0xbad0f)}, + {Ident: unique.Make(pkg.ID{0xff, 1}), Err: stub.UniqueError(0xbad01)}, + }, `errors curing dependencies: + _wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA: unique error 765184 injected by the test suite + _wEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA: unique error 765185 injected by the test suite + _wkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA: unique error 765193 injected by the test suite + _w8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA: unique error 765199 injected by the test suite`, []error{ + &pkg.CureError{Ident: unique.Make(pkg.ID{0xff, 0}), Err: stub.UniqueError(0xbad00)}, + &pkg.CureError{Ident: unique.Make(pkg.ID{0xff, 1}), Err: stub.UniqueError(0xbad01)}, + &pkg.CureError{Ident: unique.Make(pkg.ID{0xff, 9}), Err: stub.UniqueError(0xbad09)}, + &pkg.CureError{Ident: unique.Make(pkg.ID{0xff, 0xf}), Err: stub.UniqueError(0xbad0f)}, + }}, + + {"dedup", pkg.DependencyCureError{ + {Ident: unique.Make(pkg.ID{0xff, 9}), Err: stub.UniqueError(0xbad09)}, + {Ident: unique.Make(pkg.ID{0xff, 0}), Err: stub.UniqueError(0xbad00)}, + {Ident: unique.Make(pkg.ID{0xff, 0xfd}), Err: &pkg.DependencyCureError{ + {Ident: unique.Make(pkg.ID{0xff, 9}), Err: stub.UniqueError(0xbad09)}, + {Ident: unique.Make(pkg.ID{0xff, 0xc}), Err: &pkg.DependencyCureError{ + {Ident: unique.Make(pkg.ID{0xff, 0xf}), Err: stub.UniqueError(0xbad0f)}, + {Ident: unique.Make(pkg.ID{0xff, 0}), Err: stub.UniqueError(0xbad00)}, + }}, + {Ident: unique.Make(pkg.ID{0xff, 0}), Err: stub.UniqueError(0xbad00)}, + {Ident: unique.Make(pkg.ID{0xff, 0}), Err: stub.UniqueError(0xbad00)}, + }}, + {Ident: unique.Make(pkg.ID{0xff, 0xff}), Err: &pkg.DependencyCureError{ + {Ident: unique.Make(pkg.ID{0xff, 9}), Err: stub.UniqueError(0xbad09)}, + {Ident: unique.Make(pkg.ID{0xff, 0xc}), Err: &pkg.DependencyCureError{ + {Ident: unique.Make(pkg.ID{0xff, 0}), Err: stub.UniqueError(0xbad00)}, + }}, + {Ident: unique.Make(pkg.ID{0xff, 0}), Err: stub.UniqueError(0xbad00)}, + }}, + {Ident: unique.Make(pkg.ID{0xff, 0xf}), Err: stub.UniqueError(0xbad0f)}, + {Ident: unique.Make(pkg.ID{0xff, 1}), Err: stub.UniqueError(0xbad01)}, + }, `errors curing dependencies: + _wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA: unique error 765184 injected by the test suite + _wEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA: unique error 765185 injected by the test suite + _wkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA: unique error 765193 injected by the test suite + _w8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA: unique error 765199 injected by the test suite`, []error{ + &pkg.CureError{Ident: unique.Make(pkg.ID{0xff, 0}), Err: stub.UniqueError(0xbad00)}, + &pkg.CureError{Ident: unique.Make(pkg.ID{0xff, 1}), Err: stub.UniqueError(0xbad01)}, + &pkg.CureError{Ident: unique.Make(pkg.ID{0xff, 9}), Err: stub.UniqueError(0xbad09)}, + &pkg.CureError{Ident: unique.Make(pkg.ID{0xff, 0xf}), Err: stub.UniqueError(0xbad0f)}, + }}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := tc.err.Error(); got != tc.want { + t.Errorf("Error:\n%s\nwant\n%s", got, tc.want) + } + + if unwrap := tc.err.Unwrap(); !reflect.DeepEqual(unwrap, tc.unwrap) { + t.Errorf("Unwrap: %#v, want %#v", unwrap, tc.unwrap) + } + }) + } +} + func TestNew(t *testing.T) { t.Parallel()