internal/report: report errors with persistent backing
Test / Create distribution (push) Successful in 1m3s
Test / Sandbox (push) Successful in 2m49s
Test / Hakurei (push) Successful in 3m49s
Test / ShareFS (push) Successful in 3m43s
Test / Sandbox (race detector) (push) Successful in 5m22s
Test / Hakurei (race detector) (push) Successful in 6m32s
Test / Flake checks (push) Successful in 1m17s

This is useful for reporting errors from processes that must never terminate.

Signed-off-by: Ophestra <cat@gensokyo.uk>
This commit is contained in:
2026-05-26 13:36:28 +09:00
parent d15f965d0c
commit c9cabbb5fa
2 changed files with 316 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
package report
import (
"encoding/gob"
"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
}
// 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 = gob.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)
}
}
+168
View File
@@ -0,0 +1,168 @@
package report_test
import (
"bytes"
"encoding/gob"
"errors"
"log"
"os"
"reflect"
"slices"
"testing"
"hakurei.app/check"
"hakurei.app/internal/report"
"hakurei.app/internal/stub"
)
func init() { gob.Register(stub.UniqueError(0)) }
func TestDispatch(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
flag uint64
errs []report.Error
persist bool
wantLog string
wantPanic error
}{
{"default", 0, []report.Error{
{
Severity: report.Fatal,
Message: "rejecting coldboot loop",
Err: stub.UniqueError(0xcafe),
},
}, true, "dispatch: rejecting coldboot loop: unique error 51966 injected by the test suite\n", nil},
{"strict", report.DStrict, []report.Error{
{
Severity: report.Fatal,
Message: "rejecting coldboot loop",
Err: stub.UniqueError(0xcafe),
},
}, true, "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),
}},
{"early", 0, []report.Error{
{
Severity: report.Fatal,
Message: "rejecting coldboot loop",
Err: stub.UniqueError(0xcafe),
},
}, false, "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, "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)
}
gotPersist := make([]report.Error, len(names))
for i, name := range names {
f, err := os.Open(a.Append(name).String())
if err != nil {
t.Fatal(err)
}
if err = gob.NewDecoder(f).Decode(&gotPersist[i]); err != nil {
_ = f.Close()
t.Fatal(err)
} else if err = f.Close(); err != nil {
t.Fatal(err)
}
}
if !reflect.DeepEqual(gotPersist, tc.errs) {
t.Errorf("Dispatch: persist = %#v, want %#v", gotPersist, tc.errs)
}
}
})
}
}
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))
}