Restart without reboot; retrievable logs incl. mpv exit reasons
Two related gaps on the headless kiosk: no way to restart the wall short of a full reboot, and no way to see why a stream keeps flapping (the daemon's stderr goes to sway on tty1, and mpv's stderr was discarded). Restart in place: - `rtsp-streamer restart` sends a control-socket command; the daemon tears down its mpv children and syscall.Exec's the on-disk binary. Same PID, same parent (sway), same env — keeps WAYLAND_DISPLAY/ SWAYSOCK and comes back up on the new binary, no reboot. Handles the os.Executable() "(deleted)" sentinel from make install's rename. - sway config now launches the daemon in a relaunch loop, so a crash (or the restart) auto-recovers instead of leaving a black screen. - `make deploy` now does `install` + `restart` instead of restarting getty@tty1 (which left stale duplicate sessions and forced reboots). Retrievable logs: - New internal/logbuf ring; the daemon tees slog output into it and serves the tail over the socket via `rtsp-streamer logs [-n N]` — readable over SSH, no file wrangling, no reboot. - Capture the tail of each mpv's stderr and log its last line when the process exits, so "mpv exited, will restart" now carries the reason (connection refused, unsupported codec, 401, ...). This is the diagnostic for a single tile going unhealthy repeatedly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,12 +11,15 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/lwoodard/rtsp-streamer/internal/compositor"
|
||||
"github.com/lwoodard/rtsp-streamer/internal/config"
|
||||
"github.com/lwoodard/rtsp-streamer/internal/ipc"
|
||||
"github.com/lwoodard/rtsp-streamer/internal/logbuf"
|
||||
"github.com/lwoodard/rtsp-streamer/internal/player"
|
||||
"github.com/lwoodard/rtsp-streamer/internal/protect"
|
||||
"github.com/lwoodard/rtsp-streamer/internal/viewmap"
|
||||
@@ -26,6 +29,7 @@ import (
|
||||
type Daemon struct {
|
||||
cfgPath string
|
||||
log *slog.Logger
|
||||
logs *logbuf.Writer // ring of recent log lines, served by "logs"
|
||||
comp *compositor.Client
|
||||
runDir string
|
||||
|
||||
@@ -39,8 +43,10 @@ type Daemon struct {
|
||||
pcl *protect.Client // cached Protect session for view sync
|
||||
}
|
||||
|
||||
// New constructs a daemon bound to a config path.
|
||||
func New(cfgPath string, log *slog.Logger) (*Daemon, error) {
|
||||
// New constructs a daemon bound to a config path. logs, when non-nil, is the
|
||||
// ring the logger tees into so the "logs" control command can serve recent
|
||||
// activity over the socket.
|
||||
func New(cfgPath string, log *slog.Logger, logs *logbuf.Writer) (*Daemon, error) {
|
||||
comp, err := compositor.New()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -49,7 +55,7 @@ func New(cfgPath string, log *slog.Logger) (*Daemon, error) {
|
||||
if err := os.MkdirAll(runDir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Daemon{cfgPath: cfgPath, log: log, comp: comp, runDir: runDir}, nil
|
||||
return &Daemon{cfgPath: cfgPath, log: log, logs: logs, comp: comp, runDir: runDir}, nil
|
||||
}
|
||||
|
||||
// Run starts the wall and blocks until ctx is cancelled.
|
||||
@@ -509,14 +515,20 @@ func (d *Daemon) serveControl(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (d *Daemon) handleControl(ctx context.Context, conn net.Conn) {
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
|
||||
var req ipc.Request
|
||||
if err := json.NewDecoder(conn).Decode(&req); err != nil {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
resp := d.dispatch(ctx, req)
|
||||
_ = json.NewEncoder(conn).Encode(resp)
|
||||
conn.Close()
|
||||
// A restart replaces the process image; do it only after the reply has been
|
||||
// flushed to the client, so `rtsp-streamer restart` sees the ack.
|
||||
if req.Cmd == "restart" && resp.OK {
|
||||
d.execRestart(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) dispatch(ctx context.Context, req ipc.Request) ipc.Response {
|
||||
@@ -537,11 +549,61 @@ func (d *Daemon) dispatch(ctx context.Context, req ipc.Request) ipc.Response {
|
||||
d.log.Warn("could not persist active layout", "err", err)
|
||||
}
|
||||
return d.status()
|
||||
case "logs":
|
||||
if d.logs == nil {
|
||||
return ipc.Response{OK: false, Error: "log buffer not enabled"}
|
||||
}
|
||||
return ipc.Response{OK: true, ActiveLayout: d.layout, Logs: d.logs.Lines(req.Count)}
|
||||
case "restart":
|
||||
// Validate we can find the on-disk binary before acking; the actual
|
||||
// exec happens in handleControl once the reply is sent.
|
||||
if _, err := restartTarget(); err != nil {
|
||||
return ipc.Response{OK: false, Error: err.Error()}
|
||||
}
|
||||
return ipc.Response{OK: true, ActiveLayout: d.layout}
|
||||
default:
|
||||
return ipc.Response{OK: false, Error: fmt.Sprintf("unknown command %q", req.Cmd)}
|
||||
}
|
||||
}
|
||||
|
||||
// restartTarget resolves the path of the binary to re-exec. os.Executable can
|
||||
// return a "/path (deleted)" sentinel when the file was replaced (as `make
|
||||
// install` does via rename); we trim that so we exec the freshly installed
|
||||
// binary at the same path, and Stat confirms it is really there.
|
||||
func restartTarget() (string, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
exe = strings.TrimSuffix(exe, " (deleted)")
|
||||
if _, err := os.Stat(exe); err != nil {
|
||||
return "", fmt.Errorf("cannot restart: executable %q not found: %w", exe, err)
|
||||
}
|
||||
return exe, nil
|
||||
}
|
||||
|
||||
// execRestart tears down the wall and re-execs the daemon in place: same PID,
|
||||
// same parent (sway), same environment — so it keeps WAYLAND_DISPLAY/SWAYSOCK
|
||||
// and comes back up with the newly installed binary, no reboot. On success it
|
||||
// does not return. If the exec fails, it rebuilds the wall so the screen isn't
|
||||
// left blank.
|
||||
func (d *Daemon) execRestart(ctx context.Context) {
|
||||
exe, err := restartTarget()
|
||||
if err != nil {
|
||||
d.log.Error("restart aborted", "err", err)
|
||||
return
|
||||
}
|
||||
d.log.Info("restarting daemon in place", "exe", exe)
|
||||
d.stopLayout() // kill mpv so processes don't leak across the exec
|
||||
_ = os.Remove(ipc.SocketPath())
|
||||
err = syscall.Exec(exe, os.Args, os.Environ())
|
||||
// Only reached if exec failed.
|
||||
d.log.Error("restart exec failed; rebuilding wall", "err", err)
|
||||
if rerr := d.reload(ctx); rerr != nil {
|
||||
d.log.Error("failed to rebuild wall after failed restart", "err", rerr)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) persistActiveLayout(name string) error {
|
||||
cfg, err := config.Load(d.cfgPath)
|
||||
if err != nil {
|
||||
|
||||
@@ -17,8 +17,9 @@ import (
|
||||
|
||||
// Request is a command sent to the daemon.
|
||||
type Request struct {
|
||||
Cmd string `json:"cmd"` // "status" | "reload" | "set-layout"
|
||||
Name string `json:"name,omitempty"` // layout name for set-layout
|
||||
Cmd string `json:"cmd"` // "status"|"reload"|"set-layout"|"logs"|"restart"
|
||||
Name string `json:"name,omitempty"` // layout name for set-layout
|
||||
Count int `json:"count,omitempty"` // for "logs": most-recent N lines (0 = all)
|
||||
}
|
||||
|
||||
// SlotStatus reports one grid cell's state.
|
||||
@@ -36,6 +37,7 @@ type Response struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
ActiveLayout string `json:"active_layout,omitempty"`
|
||||
Slots []SlotStatus `json:"slots,omitempty"`
|
||||
Logs []string `json:"logs,omitempty"` // recent daemon log lines, for "logs"
|
||||
}
|
||||
|
||||
// SocketPath returns the control socket path inside the runtime dir.
|
||||
|
||||
60
internal/logbuf/logbuf.go
Normal file
60
internal/logbuf/logbuf.go
Normal file
@@ -0,0 +1,60 @@
|
||||
// Package logbuf provides an in-memory ring of recent log lines. The daemon
|
||||
// tees its slog output into one of these so `rtsp-streamer logs` can retrieve
|
||||
// recent activity over the control socket — the headless Pi has no reachable
|
||||
// place for the daemon's stderr to land (it goes to sway on tty1), so keeping
|
||||
// a buffer in the process is the practical way to inspect it over SSH.
|
||||
package logbuf
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Writer is an io.Writer that retains the last N newline-delimited lines
|
||||
// written to it. It is safe for concurrent use, so it can sit behind an
|
||||
// io.MultiWriter alongside os.Stderr in a slog handler.
|
||||
type Writer struct {
|
||||
mu sync.Mutex
|
||||
lines []string
|
||||
max int
|
||||
}
|
||||
|
||||
// New returns a ring holding at most max lines.
|
||||
func New(max int) *Writer {
|
||||
if max < 1 {
|
||||
max = 1
|
||||
}
|
||||
return &Writer{max: max, lines: make([]string, 0, max)}
|
||||
}
|
||||
|
||||
// Write appends each complete line in p to the ring, dropping the oldest when
|
||||
// it overflows. It always reports the full length written so it never trips up
|
||||
// the MultiWriter it sits behind.
|
||||
func (w *Writer) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
for _, line := range strings.Split(strings.TrimRight(string(p), "\n"), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
w.lines = append(w.lines, line)
|
||||
}
|
||||
if len(w.lines) > w.max {
|
||||
w.lines = append(w.lines[:0], w.lines[len(w.lines)-w.max:]...)
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Lines returns a copy of the buffered lines, oldest first. When n > 0 only
|
||||
// the most recent n are returned.
|
||||
func (w *Writer) Lines(n int) []string {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
start := 0
|
||||
if n > 0 && n < len(w.lines) {
|
||||
start = len(w.lines) - n
|
||||
}
|
||||
out := make([]string, len(w.lines)-start)
|
||||
copy(out, w.lines[start:])
|
||||
return out
|
||||
}
|
||||
48
internal/logbuf/logbuf_test.go
Normal file
48
internal/logbuf/logbuf_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package logbuf
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRingKeepsMostRecent(t *testing.T) {
|
||||
w := New(3)
|
||||
for i := 0; i < 5; i++ {
|
||||
fmt.Fprintf(w, "line %d\n", i)
|
||||
}
|
||||
got := w.Lines(0)
|
||||
want := []string{"line 2", "line 3", "line 4"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %d lines, want %d: %v", len(got), len(want), got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("line %d = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinesN(t *testing.T) {
|
||||
w := New(10)
|
||||
for i := 0; i < 5; i++ {
|
||||
fmt.Fprintf(w, "l%d\n", i)
|
||||
}
|
||||
if got := w.Lines(2); len(got) != 2 || got[0] != "l3" || got[1] != "l4" {
|
||||
t.Errorf("Lines(2) = %v, want [l3 l4]", got)
|
||||
}
|
||||
if got := w.Lines(100); len(got) != 5 {
|
||||
t.Errorf("Lines(100) = %d lines, want all 5", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultilineAndBlankWrite(t *testing.T) {
|
||||
w := New(10)
|
||||
if _, err := w.Write([]byte("a\nb\n\nc\n")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := w.Lines(0)
|
||||
want := []string{"a", "b", "c"} // blank line dropped
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,13 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/lwoodard/rtsp-streamer/internal/config"
|
||||
"github.com/lwoodard/rtsp-streamer/internal/logbuf"
|
||||
)
|
||||
|
||||
// Player supervises a single mpv instance bound to one grid slot.
|
||||
@@ -38,6 +40,7 @@ type Player struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
started time.Time
|
||||
stderr *logbuf.Writer // tail of the current process's stderr
|
||||
}
|
||||
|
||||
// New creates a player. runDir is where the mpv IPC socket lives.
|
||||
@@ -143,15 +146,19 @@ func (p *Player) start(ctx context.Context) error {
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
// Inherit the caller's environment (WAYLAND_DISPLAY etc. must be set).
|
||||
cmd.Env = os.Environ()
|
||||
// Discard mpv's chatty stdout/stderr; errors surface via exit code.
|
||||
// Keep the tail of mpv's stderr so a crash's reason (bad codec, dropped
|
||||
// connection, auth failure) is logged when the process exits, instead of
|
||||
// vanishing. Cheap: a small ring, not the whole firehose.
|
||||
stderr := logbuf.New(30)
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
cmd.Stderr = stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("starting mpv: %w", err)
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.cmd = cmd
|
||||
p.started = time.Now()
|
||||
p.stderr = stderr
|
||||
p.mu.Unlock()
|
||||
p.log.Info("mpv started", "pid", cmd.Process.Pid)
|
||||
return nil
|
||||
@@ -177,6 +184,7 @@ func (p *Player) Supervise(ctx context.Context) {
|
||||
}
|
||||
p.mu.Lock()
|
||||
cmd := p.cmd
|
||||
stderr := p.stderr
|
||||
p.mu.Unlock()
|
||||
err := cmd.Wait()
|
||||
|
||||
@@ -187,7 +195,10 @@ func (p *Player) Supervise(ctx context.Context) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
p.log.Warn("mpv exited, will restart", "err", err, "after", backoff)
|
||||
// Surface the last line(s) mpv printed — that's where the failure
|
||||
// reason lives (connection refused, unsupported codec, 401, ...).
|
||||
reason := lastLine(stderr)
|
||||
p.log.Warn("mpv exited, will restart", "err", err, "after", backoff, "reason", reason)
|
||||
if !sleep(ctx, backoff) {
|
||||
return
|
||||
}
|
||||
@@ -271,6 +282,22 @@ func (p *Player) Healthy() bool {
|
||||
return reply["error"] == "success"
|
||||
}
|
||||
|
||||
// lastLine returns the most recent non-empty stderr line mpv emitted, trimmed,
|
||||
// or "" if there was none. mpv prints its fatal error last, so the tail is the
|
||||
// useful part.
|
||||
func lastLine(w *logbuf.Writer) string {
|
||||
if w == nil {
|
||||
return ""
|
||||
}
|
||||
lines := w.Lines(0)
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
if s := strings.TrimSpace(lines[i]); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sleep(ctx context.Context, d time.Duration) bool {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
|
||||
Reference in New Issue
Block a user