diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index a351c1c..2e0c074 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -960,6 +960,13 @@ func watchSessionReady(ctx context.Context, client *agent.Client, hsess *engine. ticker := time.NewTicker(200 * time.Millisecond) defer ticker.Stop() for { + // Session torn down through a path that didn't cancel ctx (registry + // replace, idle sweep, internal kill). Bail before polling further — + // without this check the watcher could keep alive for up to 60 s on + // a dead HLSSession that's never going to become ready. + if hsess.IsClosed() { + return + } // Cache HIT or seg-0 ready → notify + done. if hsess.FromCache() || hsess.ReadyCount() >= 1 { // Parent ctx so a session cancel mid-POST (user closed tab, diff --git a/internal/engine/hls.go b/internal/engine/hls.go index 4938c11..6acde30 100644 --- a/internal/engine/hls.go +++ b/internal/engine/hls.go @@ -534,6 +534,13 @@ func (s *HLSSession) ReadyCount() int { // circuit polling — a cache HIT is ready the moment we return. func (s *HLSSession) FromCache() bool { return s.fromCache } +// IsClosed reports whether Close() has been invoked. Exposed (vs the +// internal isClosed) so external watchers — the ready-webhook +// goroutine in cmd/daemon.go — can short-circuit polling on a session +// that was torn down through a different code path (registry replace, +// idle sweep) without racing on the unexported helper. +func (s *HLSSession) IsClosed() bool { return s.isClosed() } + // MasterPlaylist returns the rendered master.m3u8 contents. func (s *HLSSession) MasterPlaylist() string { return s.manifestRoot }