feat(agent): el auto-update difiere hasta que no haya stream activo

Un auto-update reiniciaba el daemon al momento y cortaba la
reproducción en curso (mata la sesión HLS viva → freeze → F5). Ahora
el path AUTO (OnUpgrade) difiere indefinido mientras haya streams
activos y aplica solo en idle. Ningún update en segundo plano vale
cortar un visionado.

- HLSSessionRegistry.Count() + playerSessionRegistry.count() →
  GetActiveStreamCount() = player (HLS/direct/remux) + transcode HLS.
- deferAutoUpgradeUntilIdle: guard de un solo waiter, ticker 30s,
  aplica al llegar a 0 streams.
- `unarr update` (manual) SIN cambios: aplica al momento = escape
  hatch para un fix urgente.
- SyncRequest.agentStatus ("updating") reportado antes del restart
  para que la web pueda avisar en vez de dar error de sesión.
This commit is contained in:
Deivid Soto 2026-06-12 09:46:23 +02:00
parent 91ee5e4b6f
commit 59da949a53
6 changed files with 84 additions and 1 deletions

View file

@ -56,6 +56,11 @@ type Daemon struct {
OnStreamSession func(sess StreamSession)
OnControlAction func(action, taskID string, deleteFiles bool)
GetActiveCount func() int // returns number of active downloads (wired from manager)
// GetActiveStreamCount returns the number of live stream sessions (player +
// HLS transcode). Wired from cmd. The graceful AUTO-upgrade path defers
// while this is > 0 so it never cuts a viewer mid-playback; a MANUAL
// `unarr update` ignores it and applies immediately.
GetActiveStreamCount func() int
// OnAgentKeyMinted fires when a register reply carries a freshly-minted
// per-machine key (the daemon registered with a general/legacy key). cmd
// persists it so the next start authenticates with the bound agent key —
@ -68,6 +73,8 @@ type Daemon struct {
Info AgentInfo
State DaemonState
lastNotifiedVersion string
// upgradeDeferring guards a single defer-until-idle waiter for auto-upgrade.
upgradeDeferring atomic.Bool
// Managed-VPN split-tunnel state, set by cmd/daemon.go before Run and folded
// into DaemonState on every write so external tools (`unarr vpn status`) see it.
@ -285,7 +292,7 @@ func (d *Daemon) Run(ctx context.Context) error {
return
}
log.Printf("[upgrade] new version available: %s — applying auto-upgrade", version)
go d.applyAutoUpgrade(version)
go d.deferAutoUpgradeUntilIdle(version)
}
d.sync.OnScan = func() {
log.Printf("Library scan requested by server")
@ -303,6 +310,9 @@ func (d *Daemon) Run(ctx context.Context) error {
d.sync.GetFunnelURL = func() string {
return d.funnelURL
}
d.sync.GetAgentStatus = func() string {
return d.State.Status
}
d.sync.OnSyncSuccess = func() {
d.State.LastHeartbeat = time.Now()
if d.GetActiveCount != nil {
@ -332,6 +342,40 @@ func (d *Daemon) Deregister() {
RemoveState()
}
// deferAutoUpgradeUntilIdle holds an AUTO-upgrade until the agent is idle (no
// active stream), then applies it. The user's call: no background update is
// worth cutting a viewer mid-playback. A MANUAL `unarr update` bypasses this
// entirely (see cmd/self_update.go) and is the escape hatch for an urgent fix.
//
// Runs in its own goroutine. A process-lifetime guard keeps exactly ONE waiter
// even though the server re-sends the upgrade signal on every sync.
func (d *Daemon) deferAutoUpgradeUntilIdle(version string) {
if !d.upgradeDeferring.CompareAndSwap(false, true) {
return
}
defer d.upgradeDeferring.Store(false)
activeStreams := func() int {
if d.GetActiveStreamCount == nil {
return 0
}
return d.GetActiveStreamCount()
}
if n := activeStreams(); n > 0 {
log.Printf("[upgrade] v%s deferred — %d active stream(s); will apply when idle", version, n)
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
if n := activeStreams(); n == 0 {
break
}
}
log.Printf("[upgrade] no active streams — applying deferred upgrade to v%s", version)
}
d.applyAutoUpgrade(version) // exits the process on success
}
// applyAutoUpgrade downloads the target version and exits so the service
// supervisor (systemd Restart=always on Linux) respawns on the new binary.
// Triggered by the server's upgrade signal — opt-in flag set by the user from
@ -360,6 +404,13 @@ func (d *Daemon) applyAutoUpgrade(targetVersion string) {
return
}
// Tell the web we're updating so a NEW playback attempt during the brief
// restart sees "agent updating" instead of a hard session error. One
// heartbeat carries this before the (blocking) download + os.Exit below.
d.State.Status = "updating"
WriteState(&d.State)
d.TriggerSync()
upgrader := &upgrade.Upgrader{
CurrentVersion: currentClean,
OnProgress: func(msg string) {