Smoother daemon: no-op reloads, batched placement, muted audio, graceful teardown

- Skip stream restarts when a reload resolves to the identical wall
  (same URLs, tile geometry, player settings) — saving an unrelated
  config edit no longer blanks the screen.
- Replace per-tile placement polling (a swaymsg fork per tile per second,
  plus 150ms get_tree polling per tile at startup) with one shared loop:
  a single get_tree snapshot per tick, re-placing only drifted windows,
  relaxing to a 2s tick once settled.
- Mute streams by default (--no-audio) to skip an audio decoder per
  stream; opt back in with player.audio: true.
- Graceful, parallel mpv teardown via cmd.Cancel/WaitDelay, fixing the
  double-Wait race between Stop and Supervise and cutting worst-case
  layout switches from ~2s x N streams to ~2s total.
- status: probe mpv IPC outside the daemon mutex so a slow probe can't
  block layout switches.
- View sync: reuse the Protect session across ticks (re-login only on
  expiry) and pick up view_refresh_seconds changes without a restart.
- Health strikes keyed by player, not slot, so they never carry across
  layout switches; prune stale entries.
- New `rtsp-streamer reload` CLI; fix dead sort in `layout ls`; back off
  on persistent control-socket accept errors; fsync config before the
  atomic rename (SD-card power-cut safety); bump IPC client deadline;
  gofmt stragglers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Woodard
2026-07-02 07:38:01 -05:00
parent 291d37ec83
commit 98ddf1645c
11 changed files with 325 additions and 140 deletions

View File

@@ -89,6 +89,11 @@ func (p *Player) args() []string {
"--framedrop=decoder+vo",
"--demuxer-lavf-o=stimeout=5000000,fflags=+nobuffer,flags=+low_delay",
}
if !p.cfg.Audio {
// A wall of simultaneous feeds is unwatchable with sound, and skipping
// the audio decoder saves CPU per stream on the Pi.
args = append(args, "--no-audio")
}
if p.cfg.MaxFPS > 0 {
args = append(args, fmt.Sprintf("--vf=fps=%d", p.cfg.MaxFPS))
}
@@ -121,6 +126,11 @@ func (p *Player) start(ctx context.Context) error {
p.log.Warn("stale ipc socket", "err", err)
}
cmd := exec.CommandContext(ctx, "mpv", p.args()...)
// On ctx cancel, ask mpv to exit cleanly first; WaitDelay escalates to
// SIGKILL if it hasn't gone away shortly after. This makes layout teardown
// graceful and parallel — every supervisor reaps its own process.
cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) }
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.
@@ -174,7 +184,11 @@ func (p *Player) Supervise(ctx context.Context) {
}
}
// Stop terminates the mpv process (SIGTERM, then SIGKILL after a grace period).
// Stop asks the mpv process to exit (SIGTERM, escalating to SIGKILL if it
// ignores it). It never calls Wait — Supervise owns the reap, notices the
// exit, and relaunches unless its context has been cancelled. Watching p.cmd
// (which Supervise clears after reaping) instead of double-Waiting avoids a
// race on the process handle.
func (p *Player) Stop() {
p.mu.Lock()
cmd := p.cmd
@@ -182,14 +196,21 @@ func (p *Player) Stop() {
if cmd == nil || cmd.Process == nil {
return
}
_ = cmd.Process.Signal(syscall.SIGTERM)
done := make(chan struct{})
go func() { _, _ = cmd.Process.Wait(); close(done) }()
select {
case <-done:
case <-time.After(2 * time.Second):
_ = cmd.Process.Kill()
}
proc := cmd.Process
_ = proc.Signal(syscall.SIGTERM)
go func() {
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
p.mu.Lock()
alive := p.cmd == cmd
p.mu.Unlock()
if !alive {
return
}
time.Sleep(100 * time.Millisecond)
}
_ = proc.Kill()
}()
}
// Command sends a JSON IPC command to mpv and returns the decoded reply. Used