Files
RTSP-Streamer/internal/ipc/ipc.go
Levi Woodard be1b53ae04 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>
2026-07-02 10:03:25 -05:00

80 lines
2.4 KiB
Go

// Package ipc defines the tiny control protocol shared by the daemon (server)
// and the CLI/TUI (clients). Messages are newline-delimited JSON over a unix
// socket, so switching layouts or reading status never requires restarting
// the video wall.
package ipc
import (
"bufio"
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"time"
)
// Request is a command sent to the daemon.
type Request struct {
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.
type SlotStatus struct {
Slot int `json:"slot"`
Camera string `json:"camera"`
PID int `json:"pid"`
Running bool `json:"running"`
Healthy bool `json:"healthy"`
}
// Response is the daemon's reply.
type Response struct {
OK bool `json:"ok"`
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.
func SocketPath() string {
return filepath.Join(RunDir(), "control.sock")
}
// RunDir is the per-user runtime directory for sockets, created on demand.
func RunDir() string {
base := os.Getenv("XDG_RUNTIME_DIR")
if base == "" {
base = filepath.Join(os.TempDir(), "rtsp-streamer-"+strconv.Itoa(os.Getuid()))
} else {
base = filepath.Join(base, "rtsp-streamer")
}
return base
}
// Send dials the daemon, sends one request, and returns the reply.
func Send(req Request) (*Response, error) {
conn, err := net.DialTimeout("unix", SocketPath(), 3*time.Second)
if err != nil {
return nil, fmt.Errorf("cannot reach daemon (is it running?): %w", err)
}
defer conn.Close()
// Generous deadline: a reload that rebuilds the wall tears down and
// respawns every mpv before replying, and status probes each stream's IPC.
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
enc := json.NewEncoder(conn)
if err := enc.Encode(req); err != nil {
return nil, err
}
var resp Response
if err := json.NewDecoder(bufio.NewReader(conn)).Decode(&resp); err != nil {
return nil, err
}
return &resp, nil
}