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

@@ -33,8 +33,10 @@ type Daemon struct {
cfg *config.Config
players []*player.Player
layout string
wallSig string // signature of the running wall, to skip no-op reloads
cancelLo context.CancelFunc // cancels the current layout's supervisors
wg sync.WaitGroup
pcl *protect.Client // cached Protect session for view sync
}
// New constructs a daemon bound to a config path.
@@ -99,29 +101,31 @@ func (d *Daemon) reload(ctx context.Context) error {
return d.applyLayout(ctx, name)
}
// applyLayout tears down the current wall and builds the named layout.
func (d *Daemon) applyLayout(ctx context.Context, name string) error {
d.mu.Lock()
cfg := d.cfg
d.mu.Unlock()
// tileSpec is one resolved tile: which stream goes where. It doubles as the
// unit of the wall signature — if the specs (plus resolution and player
// settings) are unchanged, a reload need not touch the running streams.
type tileSpec struct {
slot int
name string
url string
rect compositor.Rect
}
// resolveTiles turns the named layout into concrete tile specs and the wall
// signature for change detection.
func (d *Daemon) resolveTiles(ctx context.Context, cfg *config.Config, name string) ([]tileSpec, string, error) {
layout := cfg.LayoutByName(name)
if layout == nil {
return fmt.Errorf("layout %q not found", name)
return nil, "", fmt.Errorf("layout %q not found", name)
}
cols, rows, err := layout.Dimensions()
if err != nil {
return err
return nil, "", err
}
w, h := d.resolution(ctx, cfg)
tiles := layout.EffectiveTiles()
d.stopLayout()
loCtx, cancel := context.WithCancel(ctx)
var players []*player.Player
for slot, tile := range tiles {
var specs []tileSpec
for slot, tile := range layout.EffectiveTiles() {
if tile.Camera == "" {
continue
}
@@ -137,83 +141,169 @@ func (d *Daemon) applyLayout(ctx context.Context, name string) error {
}
cs, rs := tile.Span()
rect := compositor.TileRect(w, h, cols, rows, tile.Col, tile.Row, cs, rs)
p := player.New(slot, cam.Name, url, cfg.Player, d.runDir, d.log)
specs = append(specs, tileSpec{slot: slot, name: cam.Name, url: url, rect: rect})
}
sig := fmt.Sprintf("%s|%dx%d|%+v|%v", name, w, h, cfg.Player, specs)
return specs, sig, nil
}
// applyLayout builds the named layout. When the resolved wall is identical to
// the one already running (same streams, geometry, and player settings), it
// leaves the streams alone — so saving an unrelated config edit never blanks
// the screen.
func (d *Daemon) applyLayout(ctx context.Context, name string) error {
d.mu.Lock()
cfg := d.cfg
d.mu.Unlock()
specs, sig, err := d.resolveTiles(ctx, cfg, name)
if err != nil {
return err
}
d.mu.Lock()
unchanged := d.layout == name && d.wallSig == sig && len(d.players) > 0
d.mu.Unlock()
if unchanged {
d.log.Info("layout unchanged; leaving streams running", "layout", name)
return nil
}
d.stopLayout()
loCtx, cancel := context.WithCancel(ctx)
players := make([]*player.Player, 0, len(specs))
places := make([]placement, 0, len(specs))
for _, s := range specs {
p := player.New(s.slot, s.name, s.url, cfg.Player, d.runDir, d.log)
players = append(players, p)
places = append(places, placement{p: p, rect: s.rect})
d.wg.Add(1)
go func() { defer d.wg.Done(); p.Supervise(loCtx) }()
// Position the window once it maps. Done per-tile so a slow camera
// doesn't block the others.
}
if len(places) > 0 {
d.wg.Add(1)
go func(p *player.Player, rect compositor.Rect) {
defer d.wg.Done()
d.placeWhenReady(loCtx, p, rect)
}(p, rect)
go func() { defer d.wg.Done(); d.placeLoop(loCtx, places) }()
}
d.mu.Lock()
d.players = players
d.layout = name
d.wallSig = sig
d.cancelLo = cancel
d.cfg.ActiveLayout = name
d.mu.Unlock()
d.log.Info("layout applied", "layout", name, "tiles", len(players), "grid", layout.Grid, "res", fmt.Sprintf("%dx%d", w, h))
d.log.Info("layout applied", "layout", name, "tiles", len(players))
return nil
}
// placeWhenReady waits for the mpv window to map, tiles it, then keeps
// re-asserting its geometry on a timer. The re-assertion matters because mpv
// resizes its own window to the camera's native resolution when the stream
// loads (and mpv 0.35 has no flag to disable that); re-issuing the sway
// resize snaps the window back into its cell, portably across mpv versions.
func (d *Daemon) placeWhenReady(ctx context.Context, p *player.Player, rect compositor.Rect) {
var readyPID int
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
// placement pairs a player with the rectangle its window belongs in.
type placement struct {
p *player.Player
rect compositor.Rect
}
// placeLoop keeps every tile's window at its assigned rectangle using one
// get_tree snapshot per tick, re-placing only windows that drifted. mpv
// resizes its own window to the video's native size when a stream loads (mpv
// 0.35 has no flag to disable that), so drift is expected on every stream
// (re)start; comparing against the snapshot means a settled wall costs one
// swaymsg per tick instead of one per tile. The tick runs fast while windows
// are mapping or drifting and relaxes once everything has been in place for a
// few consecutive ticks.
func (d *Daemon) placeLoop(ctx context.Context, places []placement) {
const fast, slow = 500 * time.Millisecond, 2 * time.Second
settled := 0
mapped := map[int]bool{} // pids whose window we have seen mapped
for {
if ctx.Err() != nil {
interval := fast
if settled >= 3 {
interval = slow
}
if !sleepCtx(ctx, interval) {
return
}
pid := p.PID()
if pid != 0 {
// New process: wait for its window to map before positioning.
if pid != readyPID {
if err := d.comp.WaitForWindow(ctx, pid, 15*time.Second); err != nil {
goto wait
rects, err := d.comp.WindowRects(ctx)
if err != nil {
d.log.Debug("get_tree failed", "err", err)
continue
}
clean := true
for _, pl := range places {
pid := pl.p.PID()
if pid == 0 {
continue // not running (backoff); nothing to place yet
}
actual, ok := rects[pid]
if !ok {
// Process is up but its window hasn't mapped: poll fast so it
// lands in its cell the moment it appears.
if !mapped[pid] {
clean = false
}
readyPID = pid
d.log.Debug("window mapped", "slot", p.Slot, "pid", pid, "rect", rect)
continue
}
// Re-assert geometry every tick to override mpv's auto-resize.
if err := d.comp.Place(ctx, pid, rect); err != nil {
d.log.Debug("place failed", "slot", p.Slot, "err", err)
if !mapped[pid] {
mapped[pid] = true
d.log.Debug("window mapped", "slot", pl.p.Slot, "pid", pid, "rect", pl.rect)
}
if actual != pl.rect {
clean = false
if err := d.comp.Place(ctx, pid, pl.rect); err != nil {
d.log.Debug("place failed", "slot", pl.p.Slot, "err", err)
}
}
}
wait:
select {
case <-ctx.Done():
return
case <-ticker.C:
if clean {
settled++
} else {
settled = 0
}
// Drop map entries for long-gone pids so restart churn over weeks of
// uptime doesn't grow the set unboundedly.
if len(mapped) > 2*len(places) {
current := make(map[int]bool, len(places))
for _, pl := range places {
current[pl.p.PID()] = true
}
for pid := range mapped {
if !current[pid] {
delete(mapped, pid)
}
}
}
}
}
// stopLayout cancels supervisors and kills current mpv processes.
// sleepCtx sleeps for d or until ctx is cancelled; false means cancelled.
func sleepCtx(ctx context.Context, dur time.Duration) bool {
t := time.NewTimer(dur)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}
// stopLayout tears down the current wall. Cancelling the layout context
// SIGTERMs every mpv concurrently (each supervisor's cmd.Cancel, escalating
// to SIGKILL via WaitDelay) and stops the placement loop; the WaitGroup
// confirms every process has been reaped before we build the next layout.
func (d *Daemon) stopLayout() {
d.mu.Lock()
cancel := d.cancelLo
players := d.players
d.cancelLo = nil
d.players = nil
d.wallSig = ""
d.mu.Unlock()
if cancel != nil {
cancel()
}
for _, p := range players {
p.Stop()
}
d.wg.Wait()
}
@@ -222,7 +312,9 @@ func (d *Daemon) stopLayout() {
func (d *Daemon) healthLoop(ctx context.Context) {
ticker := time.NewTicker(20 * time.Second)
defer ticker.Stop()
stalls := map[int]int{}
// Keyed by player (not slot) so strikes never carry over to a different
// stream after a layout switch; stale entries are pruned each tick.
stalls := map[*player.Player]int{}
for {
select {
case <-ctx.Done():
@@ -232,21 +324,28 @@ func (d *Daemon) healthLoop(ctx context.Context) {
d.mu.Lock()
players := append([]*player.Player(nil), d.players...)
d.mu.Unlock()
current := make(map[*player.Player]bool, len(players))
for _, p := range players {
current[p] = true
if !p.Running() {
continue
}
if p.Healthy() {
stalls[p.Slot] = 0
stalls[p] = 0
continue
}
stalls[p.Slot]++
stalls[p]++
// Require three consecutive unresponsive probes (~60s) before
// forcing a restart, so a brief IPC hiccup never bounces a stream.
if stalls[p.Slot] >= 3 {
d.log.Warn("stream hung, forcing restart", "slot", p.Slot, "camera", p.Name, "misses", stalls[p.Slot])
if stalls[p] >= 3 {
d.log.Warn("stream hung, forcing restart", "slot", p.Slot, "camera", p.Name, "misses", stalls[p])
p.Stop() // Supervise relaunches
stalls[p.Slot] = 0
stalls[p] = 0
}
}
for p := range stalls {
if !current[p] {
delete(stalls, p)
}
}
}
@@ -257,20 +356,22 @@ func (d *Daemon) healthLoop(ctx context.Context) {
// the wall without manual re-import. Off unless view_refresh_seconds > 0 and
// controller credentials are available to the daemon process.
func (d *Daemon) viewSyncLoop(ctx context.Context) {
d.mu.Lock()
interval := d.cfg.ViewRefreshSeconds
d.mu.Unlock()
if interval <= 0 {
return
}
ticker := time.NewTicker(time.Duration(interval) * time.Second)
defer ticker.Stop()
warnedNoCreds := false
for {
select {
case <-ctx.Done():
// Re-read the interval every pass so enabling or tuning
// view_refresh_seconds takes effect on reload, without a restart.
d.mu.Lock()
interval := d.cfg.ViewRefreshSeconds
d.mu.Unlock()
wait := time.Duration(interval) * time.Second
if interval <= 0 {
wait = 30 * time.Second // idle poll of the config; sync stays off
}
if !sleepCtx(ctx, wait) {
return
case <-ticker.C:
}
if interval <= 0 {
continue
}
if err := d.syncActiveView(ctx); err != nil {
if err == errNoCreds {
@@ -287,6 +388,22 @@ func (d *Daemon) viewSyncLoop(ctx context.Context) {
var errNoCreds = fmt.Errorf("no controller credentials")
// loginProtect builds a fresh authenticated Protect client and caches it for
// subsequent sync ticks.
func (d *Daemon) loginProtect(ctx context.Context, cfg *config.Config) (*protect.Client, error) {
cl, err := protect.New(cfg.Controller.Host, cfg.Controller.RTSPPort, cfg.Controller.VerifyTLS)
if err != nil {
return nil, err
}
if err := cl.Login(ctx, cfg.Controller.Username, cfg.Controller.ResolvePassword()); err != nil {
return nil, err
}
d.mu.Lock()
d.pcl = cl
d.mu.Unlock()
return cl, nil
}
// syncActiveView refetches the linked live view and re-applies the layout if it
// changed. No-op when the active layout isn't linked to a Protect view.
func (d *Daemon) syncActiveView(ctx context.Context) error {
@@ -302,16 +419,24 @@ func (d *Daemon) syncActiveView(ctx context.Context) error {
if cfg.Controller.Host == "" || cfg.Controller.ResolvePassword() == "" {
return errNoCreds
}
cl, err := protect.New(cfg.Controller.Host, cfg.Controller.RTSPPort, cfg.Controller.VerifyTLS)
if err != nil {
return err
// Reuse the cached session across sync ticks instead of logging in every
// interval; if the session has expired (or this is the first sync), log in
// fresh and retry once.
d.mu.Lock()
cl := d.pcl
d.mu.Unlock()
var views []protect.LiveView
var err error
if cl != nil {
views, _, err = cl.LiveViews(ctx)
}
if err := cl.Login(ctx, cfg.Controller.Username, cfg.Controller.ResolvePassword()); err != nil {
return err
}
views, _, err := cl.LiveViews(ctx)
if err != nil {
return err
if cl == nil || err != nil {
if cl, err = d.loginProtect(ctx, cfg); err != nil {
return err
}
if views, _, err = cl.LiveViews(ctx); err != nil {
return err
}
}
var view *protect.LiveView
for i := range views {
@@ -365,6 +490,11 @@ func (d *Daemon) serveControl(ctx context.Context) {
if ctx.Err() != nil {
return
}
// Back off briefly so a persistent accept error (e.g. the socket
// vanishing) never becomes a hot loop.
if !sleepCtx(ctx, time.Second) {
return
}
continue
}
go d.handleControl(ctx, conn)
@@ -415,10 +545,15 @@ func (d *Daemon) persistActiveLayout(name string) error {
}
func (d *Daemon) status() ipc.Response {
// Copy under the lock, probe outside it: Healthy() dials mpv's IPC socket
// (up to ~2s per stream), and holding the daemon mutex through that would
// block layout switches and reloads for the duration.
d.mu.Lock()
defer d.mu.Unlock()
resp := ipc.Response{OK: true, ActiveLayout: d.layout}
for _, p := range d.players {
layout := d.layout
players := append([]*player.Player(nil), d.players...)
d.mu.Unlock()
resp := ipc.Response{OK: true, ActiveLayout: layout}
for _, p := range players {
resp.Slots = append(resp.Slots, ipc.SlotStatus{
Slot: p.Slot,
Camera: p.Name,