Six defects on byte-serving paths, all of which made the PR's monitoring and
kill-switch claims narrower than documented.
#2/GAP-10 -- the ABS in-flight kill switch was a production no-op. accessLog
wraps every ABS route, and its statusRecorder implemented Write, WriteHeader,
Hijack and Flush but not Unwrap, so http.NewResponseController dead-ended and
SetWriteDeadline returned ErrNotSupported. A multi-GB audiobook pour survived a
RevokeUser. The existing test passed throughout because it called handlers
directly and never saw the middleware; the new test drives the mounted router
over a real socket, and both new assertions fail if Unwrap is removed again.
GAP-11 -- ebook, comic and PDF serving was invisible and un-killable: no meter,
no transfer record, no Refuse, no watcher, on a route that serves cbz/cbr/pdf
files routinely 100 MB-1 GB+. It now follows the ABS file-handler idiom. Note
guardRevocationCut is deliberately *not* reused here: it keys on a session_id
URL param and an ?st= token this route does not carry, so it would have
compiled and silently guarded nothing. Cap-exempt per decision A4 -- admission
is untouched and neither route consumes a stream slot.
#10 -- the no-proxy remote transcode hop forwarded segments through a bare
RollingDeadlineWriter, so bytes on the API hop went unaccounted for a supported
topology. Metering is scoped to media bodies; manifests are excluded so a
rewritten playlist is not counted as media, and a mid-copy failure is no longer
silently discarded.
#16 -- native, proxy and compat subtitle pours were entry-gated only. They now
carry a transport span, a meter and an in-flight watcher. Proxy subtitle bytes
are attributed only when a tracker record already exists: taking Track/Remove
lifecycle ownership per subtitle request would walk straight into the
overlapping-request defect (#1) that Batch 2 addresses. Compat subtitle
extraction is buffered and rejects bitmap formats, so a cut stops delivery but
not extraction already in progress.
M2 -- the proxy deferred tracker Remove with the request context, which is
already canceled on client disconnect, so the Redis DEL never happened and the
key lingered until TTL -- a false over-cap window that could get a legitimate
stream killed. Cleanup now uses a short bounded context.
GAP-13 -- mergeStreams took the freshest record wholesale and never merged
BytesServed, so a stream that poured 8 GiB at an edge could report 0. Merged as
a max, not a sum: the records are two observers of one pour. Fixed in
DedupeSessionInfos too, which had the same hole and feeds the admin view.
#11 needed no behaviour change -- that route was already metered, registered and
watched, and commit c24d8396 plus this Unwrap fix are what make its cut work.
Its comment claimed download-class exemption while the comment above it said the
?token= form is for iOS streaming; both facts and decision A4 are now stated.
Part of #305.
321 lines
11 KiB
Go
321 lines
11 KiB
Go
package streammonitor
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/nodesessions"
|
|
)
|
|
|
|
func fakeFn(infos []nodesessions.SessionInfo) func(ctx context.Context) ([]nodesessions.SessionInfo, error) {
|
|
return func(ctx context.Context) ([]nodesessions.SessionInfo, error) {
|
|
return infos, nil
|
|
}
|
|
}
|
|
|
|
func TestFuncSourceGrouping(t *testing.T) {
|
|
infos := []nodesessions.SessionInfo{
|
|
{SessionID: "s1", AuthUserID: 1, Type: "direct_play", StartedAt: "2026-07-04T10:00:00Z", LastServedAt: "2026-07-04T10:05:00Z"},
|
|
{SessionID: "s2", AuthUserID: 1, Type: "transcode", StartedAt: "2026-07-04T10:01:00Z", LastServedAt: "2026-07-04T10:06:00Z"},
|
|
{SessionID: "s3", AuthUserID: 2, Type: "remux", StartedAt: "2026-07-04T10:02:00Z", LastServedAt: "2026-07-04T10:07:00Z"},
|
|
{SessionID: "s4", AuthUserID: 0, Type: "direct_play"}, // no user, no timestamps
|
|
}
|
|
|
|
snap, err := NewFuncSource(fakeFn(infos)).Snapshot(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Snapshot: %v", err)
|
|
}
|
|
|
|
if got := len(snap.Streams); got != 4 {
|
|
t.Fatalf("Streams len = %d, want 4", got)
|
|
}
|
|
|
|
if got := snap.CountByUser(1); got != 2 {
|
|
t.Errorf("CountByUser(1) = %d, want 2", got)
|
|
}
|
|
if got := snap.CountByUser(2); got != 1 {
|
|
t.Errorf("CountByUser(2) = %d, want 1", got)
|
|
}
|
|
if got := snap.CountByUser(0); got != 1 {
|
|
t.Errorf("CountByUser(0) = %d, want 1", got)
|
|
}
|
|
if got := snap.CountByUser(99); got != 0 {
|
|
t.Errorf("CountByUser(99) = %d, want 0", got)
|
|
}
|
|
|
|
u1 := snap.StreamsForUser(1)
|
|
if len(u1) != 2 {
|
|
t.Fatalf("StreamsForUser(1) len = %d, want 2", len(u1))
|
|
}
|
|
for _, st := range u1 {
|
|
if st.UserID != 1 {
|
|
t.Errorf("StreamsForUser(1) returned stream with UserID %d", st.UserID)
|
|
}
|
|
}
|
|
|
|
byUser := snap.ByUser()
|
|
if len(byUser) != 3 {
|
|
t.Fatalf("ByUser len = %d, want 3 (users 0,1,2)", len(byUser))
|
|
}
|
|
if len(byUser[1]) != 2 || len(byUser[2]) != 1 || len(byUser[0]) != 1 {
|
|
t.Errorf("ByUser grouping wrong: %#v", map[int]int{0: len(byUser[0]), 1: len(byUser[1]), 2: len(byUser[2])})
|
|
}
|
|
}
|
|
|
|
func TestTimestampParsing(t *testing.T) {
|
|
infos := []nodesessions.SessionInfo{
|
|
{SessionID: "s1", AuthUserID: 1, StartedAt: "2026-07-04T10:00:00Z", LastServedAt: "2026-07-04T10:05:00Z"},
|
|
{SessionID: "s2", AuthUserID: 1, StartedAt: "not-a-time", LastServedAt: ""},
|
|
}
|
|
snap, err := NewFuncSource(fakeFn(infos)).Snapshot(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Snapshot: %v", err)
|
|
}
|
|
bySession := map[string]LiveStream{}
|
|
for _, st := range snap.Streams {
|
|
bySession[st.SessionID] = st
|
|
}
|
|
|
|
want := time.Date(2026, 7, 4, 10, 5, 0, 0, time.UTC)
|
|
if !bySession["s1"].LastServedAt.Equal(want) {
|
|
t.Errorf("s1 LastServedAt = %v, want %v", bySession["s1"].LastServedAt, want)
|
|
}
|
|
if !bySession["s2"].StartedAt.IsZero() {
|
|
t.Errorf("s2 StartedAt = %v, want zero (unparseable)", bySession["s2"].StartedAt)
|
|
}
|
|
if !bySession["s2"].LastServedAt.IsZero() {
|
|
t.Errorf("s2 LastServedAt = %v, want zero (empty)", bySession["s2"].LastServedAt)
|
|
}
|
|
}
|
|
|
|
func TestDedupeKeepsNewest(t *testing.T) {
|
|
// Same SessionID observed on two nodes (proxy vs transcode node). Keep the
|
|
// record with the most recent LastServedAt.
|
|
infos := []nodesessions.SessionInfo{
|
|
{SessionID: "dup", AuthUserID: 5, NodeName: "proxy", LastServedAt: "2026-07-04T10:00:00Z", BytesServed: 100},
|
|
{SessionID: "dup", AuthUserID: 5, NodeName: "transcode", LastServedAt: "2026-07-04T10:09:00Z", BytesServed: 900},
|
|
{SessionID: "other", AuthUserID: 5, NodeName: "proxy", LastServedAt: "2026-07-04T10:03:00Z"},
|
|
}
|
|
snap, err := NewFuncSource(fakeFn(infos)).Snapshot(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Snapshot: %v", err)
|
|
}
|
|
|
|
if got := len(snap.Streams); got != 2 {
|
|
t.Fatalf("Streams len = %d, want 2 (deduped)", got)
|
|
}
|
|
if got := snap.CountByUser(5); got != 2 {
|
|
t.Errorf("CountByUser(5) = %d, want 2", got)
|
|
}
|
|
|
|
var dup LiveStream
|
|
found := false
|
|
for _, st := range snap.Streams {
|
|
if st.SessionID == "dup" {
|
|
dup = st
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("deduped session 'dup' not present")
|
|
}
|
|
if dup.NodeName != "transcode" {
|
|
t.Errorf("dedupe kept NodeName %q, want transcode (newest LastServedAt)", dup.NodeName)
|
|
}
|
|
if dup.BytesServed != 900 {
|
|
t.Errorf("dedupe kept BytesServed %d, want 900", dup.BytesServed)
|
|
}
|
|
}
|
|
|
|
func TestMergeStreamsDirect(t *testing.T) {
|
|
// Direct unit test of the merge helper, mirroring RedisSource dedupe.
|
|
in := []LiveStream{
|
|
{SessionID: "a", NodeName: "n1", LastServedAt: time.Unix(100, 0)},
|
|
{SessionID: "a", NodeName: "n2", LastServedAt: time.Unix(200, 0)},
|
|
{SessionID: "b", NodeName: "n1", LastServedAt: time.Unix(150, 0)},
|
|
}
|
|
out := mergeStreams(in)
|
|
if len(out) != 2 {
|
|
t.Fatalf("mergeStreams len = %d, want 2", len(out))
|
|
}
|
|
for _, st := range out {
|
|
if st.SessionID == "a" && st.NodeName != "n2" {
|
|
t.Errorf("merge kept %q for session a, want n2 (newest)", st.NodeName)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMergeStreamsCarriesOwnershipForward(t *testing.T) {
|
|
// The transcode node's own start record has no resolved owner (UserID 0) and
|
|
// can be the freshest copy of a session. Taking it wholesale would bucket the
|
|
// session under user 0, which the enforcer skips — exempting it from the cap.
|
|
// The merge must recover the owner from the proxy's (staler) owned record.
|
|
in := []LiveStream{
|
|
{SessionID: "s", NodeName: "proxy", UserID: 42, ProfileID: "p1", MediaFileID: 7, LastServedAt: time.Unix(100, 0)},
|
|
{SessionID: "s", NodeName: "transcode", UserID: 0, LastServedAt: time.Unix(200, 0)},
|
|
}
|
|
out := mergeStreams(in)
|
|
if len(out) != 1 {
|
|
t.Fatalf("mergeStreams len = %d, want 1", len(out))
|
|
}
|
|
got := out[0]
|
|
if got.NodeName != "transcode" {
|
|
t.Errorf("kept NodeName %q, want transcode (freshest)", got.NodeName)
|
|
}
|
|
if got.UserID != 42 {
|
|
t.Errorf("UserID = %d, want 42 (recovered from the proxy record)", got.UserID)
|
|
}
|
|
if got.ProfileID != "p1" || got.MediaFileID != 7 {
|
|
t.Errorf("ownership not fully recovered: ProfileID=%q MediaFileID=%d", got.ProfileID, got.MediaFileID)
|
|
}
|
|
}
|
|
|
|
func TestToLiveStreamCarriesRouteAndClient(t *testing.T) {
|
|
// The normalized view must surface route (native vs jellycompat) and client
|
|
// identity so the monitor is first-class, not just session id + method.
|
|
info := nodesessions.SessionInfo{
|
|
SessionID: "s1",
|
|
AuthUserID: 9,
|
|
Type: "transcode",
|
|
Route: "jellycompat",
|
|
ClientIP: "203.0.113.7",
|
|
ClientName: "Infuse",
|
|
Position: 61.5,
|
|
HWAccel: "vaapi",
|
|
}
|
|
got := toLiveStream(info)
|
|
if got.Route != "jellycompat" || got.ClientIP != "203.0.113.7" || got.ClientName != "Infuse" {
|
|
t.Fatalf("route/client not carried: %+v", got)
|
|
}
|
|
if got.Position != 61.5 || got.HWAccel != "vaapi" {
|
|
t.Fatalf("position/hwaccel not carried: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestMergeStreamsBackfillsAttribution(t *testing.T) {
|
|
// A fresher-but-thinner record (e.g. an ownerless transcode-node start record)
|
|
// must not drop route/client that a staler record for the same session carries.
|
|
in := []LiveStream{
|
|
{SessionID: "s", NodeName: "proxy", UserID: 5, Route: "native", ClientName: "SiloTV", ClientIP: "10.0.0.9", LastServedAt: time.Unix(100, 0)},
|
|
{SessionID: "s", NodeName: "transcode", UserID: 0, LastServedAt: time.Unix(200, 0)},
|
|
}
|
|
out := mergeStreams(in)
|
|
if len(out) != 1 {
|
|
t.Fatalf("merge len = %d, want 1", len(out))
|
|
}
|
|
got := out[0]
|
|
if got.NodeName != "transcode" {
|
|
t.Errorf("kept NodeName %q, want transcode (freshest)", got.NodeName)
|
|
}
|
|
if got.UserID != 5 || got.Route != "native" || got.ClientName != "SiloTV" || got.ClientIP != "10.0.0.9" {
|
|
t.Errorf("attribution not backfilled from staler record: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestMergeStreamsKeepsLargestObservedByteTotal(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
older, newer int64
|
|
want int64
|
|
}{
|
|
{name: "freshest has zero", older: 8192, newer: 0, want: 8192},
|
|
{name: "stale has zero", older: 0, newer: 8192, want: 8192},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
out := mergeStreams([]LiveStream{
|
|
{SessionID: "s", BytesServed: tc.older, LastServedAt: time.Unix(100, 0)},
|
|
{SessionID: "s", BytesServed: tc.newer, LastServedAt: time.Unix(200, 0)},
|
|
})
|
|
if len(out) != 1 || out[0].BytesServed != tc.want {
|
|
t.Fatalf("merge = %+v, want bytes %d", out, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFuncSourceNilFn(t *testing.T) {
|
|
snap, err := NewFuncSource(nil).Snapshot(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Snapshot: %v", err)
|
|
}
|
|
if len(snap.Streams) != 0 {
|
|
t.Errorf("nil fn Streams len = %d, want 0", len(snap.Streams))
|
|
}
|
|
}
|
|
|
|
func TestRedisSourceNilClient(t *testing.T) {
|
|
snap, err := NewRedisSource(nil).Snapshot(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Snapshot: %v", err)
|
|
}
|
|
if len(snap.Streams) != 0 {
|
|
t.Errorf("nil client Streams len = %d, want 0", len(snap.Streams))
|
|
}
|
|
}
|
|
|
|
// TestDedupeSessionInfos mirrors the mergeStreams rules on the raw SessionInfo
|
|
// shape used by the admin session list: one row per session id, freshest copy
|
|
// wins, a resolved owner and missing attribution are carried across the merge
|
|
// so the operator view matches the enforcer's picture (no double-counted
|
|
// streams, no user-0 rows when any copy knows the owner).
|
|
func TestDedupeSessionInfos(t *testing.T) {
|
|
newer := time.Now().UTC().Format(time.RFC3339)
|
|
older := time.Now().Add(-time.Minute).UTC().Format(time.RFC3339)
|
|
infos := []nodesessions.SessionInfo{
|
|
{SessionID: "s1", NodeName: "central", AuthUserID: 7, ProfileID: "p1", MediaFileID: 42,
|
|
Route: "native", ClientName: "SiloTV", Position: 130, LastServedAt: older},
|
|
// The same stream, seen from the edge serving it: freshest but ownerless.
|
|
{SessionID: "s1", NodeName: "edge-1", LastServedAt: newer, BytesServed: 9000},
|
|
{SessionID: "s2", NodeName: "edge-1", AuthUserID: 8, LastServedAt: newer},
|
|
}
|
|
|
|
out := DedupeSessionInfos(infos)
|
|
if len(out) != 2 {
|
|
t.Fatalf("dedupe len = %d, want 2", len(out))
|
|
}
|
|
byID := make(map[string]nodesessions.SessionInfo, len(out))
|
|
for _, info := range out {
|
|
byID[info.SessionID] = info
|
|
}
|
|
s1, ok := byID["s1"]
|
|
if !ok {
|
|
t.Fatalf("s1 missing from dedupe output")
|
|
}
|
|
if s1.NodeName != "edge-1" || s1.BytesServed != 9000 {
|
|
t.Errorf("s1 freshest copy not kept: %+v", s1)
|
|
}
|
|
if s1.AuthUserID != 7 || s1.ProfileID != "p1" || s1.MediaFileID != 42 {
|
|
t.Errorf("s1 owner not carried across merge: %+v", s1)
|
|
}
|
|
if s1.Route != "native" || s1.ClientName != "SiloTV" || s1.Position != 130 {
|
|
t.Errorf("s1 attribution not backfilled: %+v", s1)
|
|
}
|
|
if s2 := byID["s2"]; s2.AuthUserID != 8 {
|
|
t.Errorf("s2 mangled by dedupe: %+v", s2)
|
|
}
|
|
}
|
|
|
|
func TestDedupeSessionInfosKeepsLargestObservedByteTotal(t *testing.T) {
|
|
older := time.Now().Add(-time.Minute).UTC().Format(time.RFC3339Nano)
|
|
newer := time.Now().UTC().Format(time.RFC3339Nano)
|
|
for _, tc := range []struct {
|
|
name string
|
|
older, newer int64
|
|
want int64
|
|
}{
|
|
{name: "freshest has zero", older: 4096, newer: 0, want: 4096},
|
|
{name: "stale has zero", older: 0, newer: 4096, want: 4096},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
out := DedupeSessionInfos([]nodesessions.SessionInfo{
|
|
{SessionID: "s", BytesServed: tc.older, LastServedAt: older},
|
|
{SessionID: "s", BytesServed: tc.newer, LastServedAt: newer},
|
|
})
|
|
if len(out) != 1 || out[0].BytesServed != tc.want {
|
|
t.Fatalf("dedupe = %+v, want bytes %d", out, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|