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:
Levi Woodard
2026-07-02 10:03:25 -05:00
parent 9985a93851
commit be1b53ae04
10 changed files with 308 additions and 33 deletions

View File

@@ -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 {