Client progress reports could keep a zero-byte phantom session alive and, worse, make it outrank a genuinely-serving stream when the enforcer picked over-cap victims. `streammonitor.LiveLocalSessions` substituted `Session.LastActivityAt` for a zero `LastServedAt`, and `LastActivityAt` is advanced by `UpdateProgress` and by the realtime WebSocket hello/ack/result handlers. Since `streamenforcer.selectVictims` keeps the `limit` most-recently-served streams, a progress-only phantom sorted ahead of a real stream and the real one was trimmed instead. Reaping had the same root cause: `sessionIsInactiveLocked` keyed idleness on `LastActivityAt`, so a client that kept pinging held a session open forever. Per decision A5 (Option C), client progress is now UI metadata only and never feeds enforcement or reaping: - `LiveLocalSessions` projects `LastServedAt` verbatim, emitting an empty timestamp when the session has never served, so it sorts as the stalest over-cap victim. - `sessionIsInactiveLocked` measures idleness from `LastServedAt`, falling back only to `StartedAt`. - A configurable never-served window (`DefaultUnservedSessionGrace`, 2m, via `SetUnservedSessionGrace`) keeps a legitimately slow start from being reaped before its first byte, without granting a phantom unbounded life. It is a separate knob rather than a hardcoded floor so it cannot silently override `SetLivenessGracePeriods`. In-flight transports remain exempt, so direct-play and remux long pours and per-segment HLS serves are unaffected. Paused sessions with an open realtime/WebSocket connection are exempt from reaping. That preserves the issue #243 fix (reaping a paused transcode froze clients) while staying within Option C: an open, ping-checked connection is server-observed, unlike a client's reported progress, and the session still consumes one of the user's cap slots. Part 1 of 3 for the Batch 4 liveness/replica work. Part of #305
119 lines
3.5 KiB
Go
119 lines
3.5 KiB
Go
package playback
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestNeverServedProgressPingDoesNotPreventReaping(t *testing.T) {
|
|
m := NewSessionManager(0, 0)
|
|
m.SetLivenessGracePeriods(time.Minute, time.Hour)
|
|
m.SetUnservedSessionGrace(2 * time.Minute)
|
|
|
|
session, err := m.StartSession(1, "profile", 100, PlayDirect, false)
|
|
if err != nil {
|
|
t.Fatalf("StartSession: %v", err)
|
|
}
|
|
m.mu.Lock()
|
|
m.sessions[session.ID].StartedAt = time.Now().Add(-90 * time.Second)
|
|
m.mu.Unlock()
|
|
if err := m.UpdateProgress(session.ID, 42, false); err != nil {
|
|
t.Fatalf("UpdateProgress: %v", err)
|
|
}
|
|
if expired := m.CleanStale(); len(expired) != 0 {
|
|
t.Fatalf("expired within configured unserved grace = %+v, want none", expired)
|
|
}
|
|
|
|
m.mu.Lock()
|
|
m.sessions[session.ID].StartedAt = time.Now().Add(-3 * time.Minute)
|
|
m.mu.Unlock()
|
|
if err := m.UpdateProgress(session.ID, 43, false); err != nil {
|
|
t.Fatalf("UpdateProgress after grace: %v", err)
|
|
}
|
|
expired := m.CleanStale()
|
|
if len(expired) != 1 || expired[0].ID != session.ID {
|
|
t.Fatalf("expired = %+v, want progress-pinged never-served session %q", expired, session.ID)
|
|
}
|
|
}
|
|
|
|
func TestActiveTransportPreventsServerObservedReaping(t *testing.T) {
|
|
m := NewSessionManager(0, 0)
|
|
m.SetUnservedSessionGrace(0)
|
|
|
|
session, err := m.StartSession(1, "profile", 100, PlayDirect, false)
|
|
if err != nil {
|
|
t.Fatalf("StartSession: %v", err)
|
|
}
|
|
if err := m.BeginTransport(session.ID); err != nil {
|
|
t.Fatalf("BeginTransport: %v", err)
|
|
}
|
|
|
|
if expired := m.CleanInactive(0, 0); len(expired) != 0 {
|
|
t.Fatalf("expired active transport = %+v, want none", expired)
|
|
}
|
|
}
|
|
|
|
func TestFreshLastServedSurvivesStaleClientActivity(t *testing.T) {
|
|
m := NewSessionManager(0, 0)
|
|
m.SetUnservedSessionGrace(0)
|
|
|
|
session, err := m.StartSession(1, "profile", 100, PlayDirect, false)
|
|
if err != nil {
|
|
t.Fatalf("StartSession: %v", err)
|
|
}
|
|
m.mu.Lock()
|
|
s := m.sessions[session.ID]
|
|
s.LastServedAt = time.Now()
|
|
s.LastActivityAt = time.Now().Add(-time.Hour)
|
|
s.UpdatedAt = s.LastActivityAt
|
|
m.mu.Unlock()
|
|
|
|
if expired := m.CleanInactive(time.Minute, time.Hour); len(expired) != 0 {
|
|
t.Fatalf("expired freshly-served session = %+v, want none", expired)
|
|
}
|
|
}
|
|
|
|
func TestPausedWebSocketConnectionPreventsServerObservedReaping(t *testing.T) {
|
|
m := NewSessionManager(0, 0)
|
|
m.SetUnservedSessionGrace(0)
|
|
|
|
session, err := m.StartSession(1, "profile", 100, PlayTranscode, false)
|
|
if err != nil {
|
|
t.Fatalf("StartSession: %v", err)
|
|
}
|
|
if err := m.UpdateProgress(session.ID, 42, true); err != nil {
|
|
t.Fatalf("UpdateProgress(paused): %v", err)
|
|
}
|
|
if err := m.SetWebSocket(session.ID, true); err != nil {
|
|
t.Fatalf("SetWebSocket: %v", err)
|
|
}
|
|
m.mu.Lock()
|
|
m.sessions[session.ID].LastServedAt = time.Now().Add(-time.Hour)
|
|
m.mu.Unlock()
|
|
|
|
if expired := m.CleanInactive(time.Minute, 30*time.Minute); len(expired) != 0 {
|
|
t.Fatalf("expired WebSocket-connected paused session = %+v, want none", expired)
|
|
}
|
|
}
|
|
|
|
func TestPausedWithoutRealtimeConnectionReapsFromLastServed(t *testing.T) {
|
|
m := NewSessionManager(0, 0)
|
|
m.SetUnservedSessionGrace(0)
|
|
|
|
session, err := m.StartSession(1, "profile", 100, PlayTranscode, false)
|
|
if err != nil {
|
|
t.Fatalf("StartSession: %v", err)
|
|
}
|
|
m.mu.Lock()
|
|
m.sessions[session.ID].LastServedAt = time.Now().Add(-time.Hour)
|
|
m.mu.Unlock()
|
|
if err := m.UpdateProgress(session.ID, 42, true); err != nil {
|
|
t.Fatalf("UpdateProgress(paused): %v", err)
|
|
}
|
|
|
|
expired := m.CleanInactive(time.Minute, 30*time.Minute)
|
|
if len(expired) != 1 || expired[0].ID != session.ID {
|
|
t.Fatalf("expired = %+v, want paused disconnected session %q", expired, session.ID)
|
|
}
|
|
}
|