aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--internal/report/report.go186
-rw-r--r--internal/report/report_test.go197
2 files changed, 383 insertions, 0 deletions
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))
+}