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

@@ -41,8 +41,8 @@ func daemonCmd() *cobra.Command {
Use: "daemon",
Short: "Run the video wall (launched by the kiosk session)",
RunE: func(cmd *cobra.Command, _ []string) error {
log := newLogger()
d, err := daemon.New(cfgPath, log)
log, ring := newDaemonLogger()
d, err := daemon.New(cfgPath, log, ring)
if err != nil {
return err
}
@@ -375,6 +375,51 @@ func layoutCmd() *cobra.Command {
return c
}
// logsCmd retrieves recent daemon log lines over the control socket — the
// practical way to see why a stream is flapping on the headless kiosk.
func logsCmd() *cobra.Command {
var n int
c := &cobra.Command{
Use: "logs",
Short: "Show recent daemon log lines (incl. mpv exit reasons)",
RunE: func(cmd *cobra.Command, _ []string) error {
resp, err := ipc.Send(ipc.Request{Cmd: "logs", Count: n})
if err != nil {
return err
}
if !resp.OK {
return fmt.Errorf("%s", resp.Error)
}
for _, line := range resp.Logs {
fmt.Println(line)
}
return nil
},
}
c.Flags().IntVarP(&n, "lines", "n", 50, "show at most the most recent N lines (0 = all buffered)")
return c
}
// restartCmd asks the running daemon to re-exec itself in place, picking up a
// freshly installed binary without a reboot or a new sway session.
func restartCmd() *cobra.Command {
return &cobra.Command{
Use: "restart",
Short: "Restart the running daemon in place (reloads the binary; no reboot)",
RunE: func(cmd *cobra.Command, _ []string) error {
resp, err := ipc.Send(ipc.Request{Cmd: "restart"})
if err != nil {
return err
}
if !resp.OK {
return fmt.Errorf("%s", resp.Error)
}
fmt.Println("daemon is restarting in place (picking up the installed binary)…")
return nil
},
}
}
// reloadCmd tells the running daemon to re-read its config and re-apply the
// active layout. The daemon leaves streams untouched when nothing material
// changed, so this is safe to run casually.

View File

@@ -5,10 +5,12 @@ package main
import (
"fmt"
"io"
"log/slog"
"os"
"github.com/lwoodard/rtsp-streamer/internal/config"
"github.com/lwoodard/rtsp-streamer/internal/logbuf"
"github.com/spf13/cobra"
)
@@ -39,7 +41,9 @@ func main() {
daemonCmd(),
discoverCmd(),
layoutCmd(),
logsCmd(),
reloadCmd(),
restartCmd(),
statusCmd(),
tuiCmd(),
configCmd(),
@@ -53,17 +57,25 @@ func main() {
}
}
func newLogger() *slog.Logger {
var lvl slog.Level
func logLevelValue() slog.Level {
switch logLevel {
case "debug":
lvl = slog.LevelDebug
return slog.LevelDebug
case "warn":
lvl = slog.LevelWarn
return slog.LevelWarn
case "error":
lvl = slog.LevelError
return slog.LevelError
default:
lvl = slog.LevelInfo
return slog.LevelInfo
}
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lvl}))
}
// newDaemonLogger builds the daemon's logger, teeing output to stderr and to
// an in-memory ring so `rtsp-streamer logs` can retrieve recent activity over
// the control socket (the daemon's stderr itself is not reachable on the
// headless kiosk — it goes to sway on tty1).
func newDaemonLogger() (*slog.Logger, *logbuf.Writer) {
ring := logbuf.New(500)
h := slog.NewTextHandler(io.MultiWriter(os.Stderr, ring), &slog.HandlerOptions{Level: logLevelValue()})
return slog.New(h), ring
}