diff options
| author | Ophestra <cat@gensokyo.uk> | 2026-05-26 13:36:28 +0900 |
|---|---|---|
| committer | Ophestra <cat@gensokyo.uk> | 2026-05-26 14:56:16 +0900 |
| commit | 12b9f51128b2683303bc9ea500c07d6f4417988f (patch) | |
| tree | 001bc9f2791ba6776c4cc1337553007c6b87abd5 /internal/report/report.go | |
| parent | d15f965d0c5587e79d8da9a514f94be32d817f67 (diff) | |
internal/report: report errors with persistent backing
This is useful for reporting errors from processes that must never terminate.
Signed-off-by: Ophestra <cat@gensokyo.uk>
Diffstat (limited to 'internal/report/report.go')
| -rw-r--r-- | internal/report/report.go | 186 |
1 files changed, 186 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) + } +} |
