From 12b9f51128b2683303bc9ea500c07d6f4417988f Mon Sep 17 00:00:00 2001 From: Ophestra Date: Tue, 26 May 2026 13:36:28 +0900 Subject: [PATCH] internal/report: report errors with persistent backing This is useful for reporting errors from processes that must never terminate. Signed-off-by: Ophestra --- internal/report/report.go | 186 +++++++++++++++++++++++++++++++ internal/report/report_test.go | 197 +++++++++++++++++++++++++++++++++ 2 files changed, 383 insertions(+) create mode 100644 internal/report/report.go create mode 100644 internal/report/report_test.go diff --git a/internal/report/report.go b/internal/report/report.go new file mode 100644 index 00000000..133176da --- /dev/null +++ b/internal/report/report.go @@ -0,0 +1,186 @@ +package report + +import ( + "encoding/json" + "errors" + "fmt" + "log" + "os" + "strconv" + "sync" + "sync/atomic" + "time" + + "hakurei.app/check" +) + +const ( + // Inconsistent denotes an error diagnosed due to inconsistent state. + Inconsistent = iota + // Degraded denotes an error condition causing a degraded state. + Degraded + // Fatal denotes an unrecoverable error. This is generally followed by + // notifying the user and suggesting immediate restart, or an automatic + // restart of a nonessential daemon process. + Fatal +) + +// A Reporter represents an error reporting object backed by user-facing display +// and optional persistent storage. A Reporter can be used simultaneously from +// multiple goroutines. +type Reporter struct { + // Backing logger, the zero value implies the return value of [log.Default]. + log atomic.Pointer[log.Logger] + // Backing persistent storage. + pathname atomic.Pointer[check.Absolute] + // Reporting behaviour. + flag atomic.Uint64 + + // Caller-supplied reporting functions. + notify []func(e Error) + // Synchronises access to notify. + notifyMu sync.RWMutex + + // Synchronises access to persistent storage. + mu sync.Mutex +} + +// SetOutput sets the backing [log.Logger]. +func (r *Reporter) SetOutput(l *log.Logger) { r.log.Store(l) } + +// SetPathname sets the pathname to persistent storage. +func (r *Reporter) SetPathname(a *check.Absolute) { r.pathname.Store(a) } + +const ( + // DStrict causes all dispatches to end in a call to panic. + DStrict = 1 << iota + // DNoRecover disallows recovering from error write to persistent storage. + DNoRecover + // DBypassEarly allows persistent storage to be skipped before it is ready. + DBypassEarly +) + +// SetFlags sets flags for r. +func (r *Reporter) SetFlags(flag uint64) { r.flag.Store(flag) } + +// An Error represents an error reported via [Reporter.Dispatch]. +type Error struct { + // Nature of error. + Severity int + // User-facing reporting message. + Message string + // Underlying error. + Err error +} + +// RepresentableError is implemented by errors friendly to JSON serialisation. +type RepresentableError interface { + error + Representable() +} + +// MarshalJSON returns an incomplete JSON representation of e. +func (e Error) MarshalJSON() (data []byte, err error) { + v := struct { + Severity any `json:"kind"` + Message string `json:"message"` + Err json.RawMessage `json:"error"` + }{Message: e.Message} + + switch e.Severity { + case Inconsistent: + v.Severity = "inconsistent" + case Degraded: + v.Severity = "degradation" + case Fatal: + v.Severity = "fatal" + default: + v.Severity = e.Severity + } + + var re RepresentableError + if errors.As(e.Err, &re) { + v.Err, err = json.Marshal(re) + } else { + v.Err, err = json.Marshal(e.Err.Error()) + } + if err != nil { + return + } + return json.Marshal(&v) +} + +// Error is generally only called during a [Reporter.Dispatch] call ending in +// a panic or an otherwise unrecoverable condition. +func (e Error) Error() string { + return fmt.Sprintf("%s: %v", e.Message, e.Err) +} + +// Notify arranges for f to be called for all future dispatched errors. +func (r *Reporter) Notify(f func(Error)) { + r.notifyMu.Lock() + r.notify = append(r.notify, f) + r.notifyMu.Unlock() +} + +// getLog returns the address of the underlying [log.Logger], or the return +// value of [log.Default]. +func (r *Reporter) getLog() *log.Logger { + l := r.log.Load() + if l == nil { + l = log.Default() + } + return l +} + +// dispatch implements Dispatch but returns the reporting error. +func (r *Reporter) dispatch(severity int, message string, e error) (err error) { + p := Error{severity, message, e} + r.getLog().Println(message+":", e) + + flag := r.flag.Load() + defer func() { + if flag&DNoRecover != 0 && err != nil { + panic(err) + } + }() + + if a := r.pathname.Load(); a != nil { + var w *os.File + r.mu.Lock() + w, err = os.OpenFile( + a.Append(strconv.FormatUint(uint64(time.Now().UnixNano()), 10)).String(), + os.O_CREATE|os.O_EXCL|os.O_WRONLY, + 0400, + ) + if err != nil { + r.mu.Unlock() + return + } + err = json.NewEncoder(w).Encode(p) + if _err := w.Close(); err == nil { + err = _err + } + r.mu.Unlock() + } else if flag&DBypassEarly == 0 { + panic(p) + } + + if flag&DStrict != 0 { + panic(p) + } + + r.notifyMu.RLock() + defer r.notifyMu.RUnlock() + for _, f := range r.notify { + f(p) + } + return +} + +// Dispatch reports an error and saves it to backing storage if available. +func (r *Reporter) Dispatch(severity int, message string, e error) { + if err := r.dispatch(severity, message, e); err != nil { + r.getLog().Println(err) + } +} diff --git a/internal/report/report_test.go b/internal/report/report_test.go new file mode 100644 index 00000000..307968b4 --- /dev/null +++ b/internal/report/report_test.go @@ -0,0 +1,197 @@ +package report_test + +import ( + "bytes" + "errors" + "log" + "os" + "reflect" + "slices" + "testing" + "unsafe" + + "hakurei.app/check" + "hakurei.app/internal/report" + "hakurei.app/internal/stub" +) + +type representableUE struct{ stub.UniqueError } + +func (representableUE) Representable() {} + +func TestDispatch(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + flag uint64 + errs []report.Error + + persist bool + wantFiles []string + wantLog string + wantPanic error + }{ + {"default", 0, []report.Error{ + { + Severity: report.Fatal, + Message: "rejecting coldboot loop", + Err: stub.UniqueError(0xcafe), + }, + }, true, []string{ + `{"kind":"fatal","message":"rejecting coldboot loop","error":"unique error 51966 injected by the test suite"} +`, + }, "dispatch: rejecting coldboot loop: unique error 51966 injected by the test suite\n", nil}, + + {"strict", report.DStrict, []report.Error{ + { + Severity: report.Degraded, + Message: "rejecting coldboot loop", + Err: stub.UniqueError(0xcafe), + }, + }, true, []string{ + `{"kind":"degradation","message":"rejecting coldboot loop","error":"unique error 51966 injected by the test suite"} +`, + }, "dispatch: rejecting coldboot loop: unique error 51966 injected by the test suite\n", report.Error{ + Severity: report.Degraded, + Message: "rejecting coldboot loop", + Err: stub.UniqueError(0xcafe), + }}, + + {"strict no recover", report.DStrict | report.DNoRecover, []report.Error{ + { + Severity: report.Inconsistent, + Message: "rejecting coldboot loop", + Err: stub.UniqueError(0xcafe), + }, + }, true, []string{ + `{"kind":"inconsistent","message":"rejecting coldboot loop","error":"unique error 51966 injected by the test suite"} +`, + }, "dispatch: rejecting coldboot loop: unique error 51966 injected by the test suite\n", report.Error{ + Severity: report.Inconsistent, + Message: "rejecting coldboot loop", + Err: stub.UniqueError(0xcafe), + }}, + + {"no recover", report.DNoRecover, []report.Error{ + { + Severity: 0xbeef, + Message: "rejecting coldboot loop", + Err: representableUE{stub.UniqueError(0xcafe)}, + }, + }, true, []string{ + `{"kind":48879,"message":"rejecting coldboot loop","error":{"UniqueError":51966}} +`, + }, "dispatch: rejecting coldboot loop: unique error 51966 injected by the test suite\n", nil}, + + {"early", 0, []report.Error{ + { + Severity: report.Fatal, + Message: "rejecting coldboot loop", + Err: stub.UniqueError(0xcafe), + }, + }, false, nil, "dispatch: rejecting coldboot loop: unique error 51966 injected by the test suite\n", report.Error{ + Severity: report.Fatal, + Message: "rejecting coldboot loop", + Err: stub.UniqueError(0xcafe), + }}, + + {"bypass early", report.DBypassEarly, []report.Error{ + { + Severity: report.Fatal, + Message: "rejecting coldboot loop", + Err: stub.UniqueError(0xcafe), + }, + }, false, nil, "dispatch: rejecting coldboot loop: unique error 51966 injected by the test suite\n", nil}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var r report.Reporter + r.SetFlags(tc.flag) + var buf bytes.Buffer + l := log.New(&buf, "dispatch: ", 0) + r.SetOutput(l) + + a := check.MustAbs(t.TempDir()) + if tc.persist { + r.SetPathname(a) + } + + var got []report.Error + r.Notify(func(p report.Error) { got = append(got, p) }) + + var gotPanic any + func() { + defer func() { gotPanic = recover() }() + for _, p := range tc.errs { + r.Dispatch(p.Severity, p.Message, p.Err) + } + }() + + if gotPanic == nil && !reflect.DeepEqual(got, tc.errs) { + t.Errorf("Dispatch: %#v, want %#v", got, tc.errs) + } + if !reflect.DeepEqual(gotPanic, tc.wantPanic) { + t.Errorf("Dispatch: panic = %v, want %v", gotPanic, tc.wantPanic) + } + if gotLog := buf.String(); gotLog != tc.wantLog { + t.Errorf("Dispatch: log =\n%s\nwant\n%s", gotLog, tc.wantLog) + } + + if tc.persist { + var names []string + if dents, err := os.ReadDir(a.String()); err != nil { + t.Fatal(err) + } else { + names = make([]string, len(dents)) + for i, dent := range dents { + names[i] = dent.Name() + } + slices.Sort(names) + } + + gotFiles := make([]string, len(names)) + for i, name := range names { + if p, err := os.ReadFile(a.Append(name).String()); err != nil { + t.Fatal(err) + } else { + gotFiles[i] = unsafe.String(unsafe.SliceData(p), len(p)) + } + } + + if !slices.Equal(gotFiles, tc.wantFiles) { + t.Errorf("Dispatch: persist = %s, want %s", gotFiles, tc.wantFiles) + } + } + }) + } +} + +func TestBadPersist(t *testing.T) { + t.Parallel() + + var r report.Reporter + r.SetFlags(report.DNoRecover) + r.SetPathname(check.MustAbs("/proc/nonexistent")) + + var pathError *os.PathError + func() { + defer func() { + if !errors.As(recover().(error), &pathError) { + t.Fatal("invalid panic kind") + } + }() + r.Dispatch(report.Fatal, "\x00", stub.UniqueError(0xbad)) + }() + if !errors.Is(pathError.Err, os.ErrNotExist) { + t.Fatalf("Dispatch: panic = %v", pathError) + } + + var buf bytes.Buffer + l := log.New(&buf, "persist: ", 0) + r.SetOutput(l) + r.SetFlags(0) + r.Dispatch(report.Fatal, "\x00", stub.UniqueError(0xbad)) +}