feat: log structured reconnect diagnostics for kiosk display layers (#2)
Build Ground Station / build (push) Successful in 1m2s

This commit was merged in pull request #13.
This commit is contained in:
2026-10-02 10:23:28 +02:00
parent 41552b4797
commit 2ced44b47b
4 changed files with 314 additions and 4 deletions
+67 -3
View File
@@ -49,12 +49,47 @@ type Opener interface {
Open(ctx context.Context) (Session, error)
}
// Reconnect-boundary diagnostic events.
const (
// EventOpenFailed marks a failed attempt to open the display target.
EventOpenFailed = "open_failed"
// EventSessionEnded marks a session broken by a target failure.
EventSessionEnded = "session_ended"
)
// Diagnostic is one structured record from a reconnect boundary of the
// kiosk loop. Exactly one record is emitted per failed open or broken
// session; cancellation and shutdown stay silent.
type Diagnostic struct {
// Target is the display target name, for example "x11".
Target string
// Layer is the display layer name within the target, for example
// "fallback-canvas"; empty for single-layer runs.
Layer string
// Event names the boundary: EventOpenFailed or EventSessionEnded.
Event string
// Err is the underlying error from Opener.Open or Session.WaitEvent.
Err error
// RetryDelay is the wait scheduled after this boundary.
RetryDelay time.Duration
}
// Options tune the kiosk lifecycle loop. Zero values select the defaults.
type Options struct {
// RetryDelay is the first wait after a failed connect or lost session.
RetryDelay time.Duration
// MaxDelay bounds the exponential reconnect backoff.
MaxDelay time.Duration
// Target names the display target in diagnostics. Empty selects
// "unknown".
Target string
// Layer names the kiosk layer in diagnostics; empty for single-layer
// runs.
Layer string
// Diagnostic receives one structured record per reconnect boundary.
// Nil disables diagnostics. It runs on the loop goroutine and must not
// block.
Diagnostic func(Diagnostic)
}
func (o Options) withDefaults() Options {
@@ -67,10 +102,35 @@ func (o Options) withDefaults() Options {
return o
}
func (o Options) targetName() string {
if o.Target == "" {
return "unknown"
}
return o.Target
}
// diagnose emits one reconnect-boundary record. Cancellation is shutdown,
// not a failure, so it stays silent; so does a nil hook.
func (o Options) diagnose(ctx context.Context, event string, err error, retryDelay time.Duration) {
if o.Diagnostic == nil || err == nil || ctx.Err() != nil {
return
}
o.Diagnostic(Diagnostic{
Target: o.targetName(),
Layer: o.Layer,
Event: event,
Err: err,
RetryDelay: retryDelay,
})
}
// Run owns the kiosk display lifecycle until ctx is cancelled. Temporary
// unavailability of the target, for example X11 not being up yet, never
// returns an error: Run retries with bounded backoff and only stops when the
// context is cancelled, so the Ground Station supervisor stays up.
// context is cancelled, so the Ground Station supervisor stays up. Each
// reconnect boundary (failed open, broken session) emits one structured
// Diagnostic record through Options.Diagnostic, never through an error
// return; cancellation stays silent.
func Run(ctx context.Context, opener Opener, template Template, options Options) error {
opts := options.withDefaults()
delay := opts.RetryDelay
@@ -81,6 +141,7 @@ func Run(ctx context.Context, opener Opener, template Template, options Options)
session, err := opener.Open(ctx)
if err != nil {
opts.diagnose(ctx, EventOpenFailed, err, delay)
if !sleep(ctx, delay) {
return nil
}
@@ -91,9 +152,11 @@ func Run(ctx context.Context, opener Opener, template Template, options Options)
delay = opts.RetryDelay
canvas := session.Canvas()
template.Draw(canvas)
var sessionErr error
for ctx.Err() == nil {
redraw, err := session.WaitEvent(ctx)
if err != nil {
var redraw bool
redraw, sessionErr = session.WaitEvent(ctx)
if sessionErr != nil {
break
}
if redraw {
@@ -105,6 +168,7 @@ func Run(ctx context.Context, opener Opener, template Template, options Options)
if ctx.Err() != nil {
return nil
}
opts.diagnose(ctx, EventSessionEnded, sessionErr, delay)
if !sleep(ctx, delay) {
return nil
}