internal/report: report errors with persistent backing
All checks were successful
Test / Create distribution (push) Successful in 1m4s
Test / Sandbox (push) Successful in 2m48s
Test / ShareFS (push) Successful in 3m47s
Test / Hakurei (push) Successful in 3m57s
Test / Sandbox (race detector) (push) Successful in 5m30s
Test / Hakurei (race detector) (push) Successful in 6m37s
Test / Flake checks (push) Successful in 1m20s
All checks were successful
Test / Create distribution (push) Successful in 1m4s
Test / Sandbox (push) Successful in 2m48s
Test / ShareFS (push) Successful in 3m47s
Test / Hakurei (push) Successful in 3m57s
Test / Sandbox (race detector) (push) Successful in 5m30s
Test / Hakurei (race detector) (push) Successful in 6m37s
Test / Flake checks (push) Successful in 1m20s
This is useful for reporting errors from processes that must never terminate. Signed-off-by: Ophestra <cat@gensokyo.uk>
This commit is contained in:
186
internal/report/report.go
Normal file
186
internal/report/report.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
197
internal/report/report_test.go
Normal file
197
internal/report/report_test.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user