|
| 1 | +package relay |
| 2 | + |
| 3 | +import ( |
| 4 | + "sync" |
| 5 | + "testing" |
| 6 | + "time" |
| 7 | +) |
| 8 | + |
| 9 | +// newTestStream builds a Stream of the same shape that GetOrCreateStream |
| 10 | +// produces, but without needing the global Relay wired up. |
| 11 | +func newTestStream(ogg bool) *Stream { |
| 12 | + return &Stream{ |
| 13 | + MountName: "/test", |
| 14 | + listeners: make(map[string]chan struct{}), |
| 15 | + Buffer: NewCircularBuffer(64 * 1024), |
| 16 | + Started: time.Now(), |
| 17 | + ContentType: "audio/ogg", |
| 18 | + IsOggStream: ogg, |
| 19 | + Enabled: true, |
| 20 | + PageOffsets: make([]int64, 32), |
| 21 | + mu: sync.RWMutex{}, |
| 22 | + } |
| 23 | +} |
| 24 | + |
| 25 | +// fakeRelay lets Broadcast update its atomic counters without dragging the |
| 26 | +// HistoryManager / database into a unit test. |
| 27 | +func fakeRelay() *Relay { return &Relay{} } |
| 28 | + |
| 29 | +// TestSubscribeSkipsBurstWhenSourceIdle verifies bug A's fix: if the source |
| 30 | +// hasn't written audio for >2s, a new subscriber must start at Head and not |
| 31 | +// be bursted with stale buffer contents. |
| 32 | +func TestSubscribeSkipsBurstWhenSourceIdle(t *testing.T) { |
| 33 | + s := newTestStream(false) |
| 34 | + r := fakeRelay() |
| 35 | + |
| 36 | + // Pretend a source was active in the past and left audio in the buffer. |
| 37 | + s.Broadcast(make([]byte, 8192), r) |
| 38 | + s.mu.Lock() |
| 39 | + s.LastDataReceived = time.Now().Add(-10 * time.Second) |
| 40 | + s.mu.Unlock() |
| 41 | + |
| 42 | + head := s.Buffer.SnapshotHead() |
| 43 | + start, _ := s.Subscribe("listener-1", 4096) |
| 44 | + if start != head { |
| 45 | + t.Fatalf("idle-source subscribe should start at Head=%d, got %d (would replay %d stale bytes)", |
| 46 | + head, start, head-start) |
| 47 | + } |
| 48 | +} |
| 49 | + |
| 50 | +// TestSubscribeBurstsWhenSourceFresh verifies the burst still works on an |
| 51 | +// active stream — the freshness gate must not break the normal "instant |
| 52 | +// playback" path. |
| 53 | +func TestSubscribeBurstsWhenSourceFresh(t *testing.T) { |
| 54 | + s := newTestStream(false) |
| 55 | + r := fakeRelay() |
| 56 | + |
| 57 | + // LastDataReceived is set by Broadcast itself to time.Now(). |
| 58 | + s.Broadcast(make([]byte, 8192), r) |
| 59 | + |
| 60 | + head := s.Buffer.SnapshotHead() |
| 61 | + start, _ := s.Subscribe("listener-1", 4096) |
| 62 | + if start >= head { |
| 63 | + t.Fatalf("fresh-source subscribe should burst, start=%d head=%d", start, head) |
| 64 | + } |
| 65 | + if head-start != 4096 { |
| 66 | + t.Fatalf("expected 4096-byte burst, got %d", head-start) |
| 67 | + } |
| 68 | +} |
| 69 | + |
| 70 | +// TestSubscribeNeverWalksBackPastFreshHead verifies bug E's fix: even if the |
| 71 | +// Ogg page-alignment fallback would otherwise pick an offset older than the |
| 72 | +// freshness boundary, Subscribe must clamp it. |
| 73 | +func TestSubscribeNeverWalksBackPastFreshHead(t *testing.T) { |
| 74 | + s := newTestStream(true) |
| 75 | + r := fakeRelay() |
| 76 | + |
| 77 | + // Stale audio from a previous session. |
| 78 | + s.Broadcast(make([]byte, 16*1024), r) |
| 79 | + |
| 80 | + // New session starts — FreshHead advances to the current Head, all old |
| 81 | + // page offsets must be invalidated. |
| 82 | + s.BeginSession() |
| 83 | + freshHead := s.Buffer.SnapshotHead() |
| 84 | + |
| 85 | + // Subscribe before any new audio has arrived. LastDataReceived is still |
| 86 | + // from the old session, so the >2s freshness gate alone won't trip; we |
| 87 | + // rely on FreshHead. |
| 88 | + s.mu.Lock() |
| 89 | + s.LastDataReceived = time.Now() // pretend source is "live" |
| 90 | + s.mu.Unlock() |
| 91 | + |
| 92 | + start, _ := s.Subscribe("listener-1", 8192) |
| 93 | + if start < freshHead { |
| 94 | + t.Fatalf("subscribe returned start=%d which is BEFORE FreshHead=%d (would replay stale audio)", |
| 95 | + start, freshHead) |
| 96 | + } |
| 97 | +} |
| 98 | + |
| 99 | +// TestAlignToOggPageRejectsFalseMagic verifies bug B's fix: bare "OggS" |
| 100 | +// bytes inside a payload are not treated as page boundaries — only the |
| 101 | +// magic followed by a 0 version byte counts. |
| 102 | +func TestAlignToOggPageRejectsFalseMagic(t *testing.T) { |
| 103 | + buf := NewCircularBuffer(4096) |
| 104 | + |
| 105 | + // False positive: "OggS" followed by version byte 0xFF (could be Opus |
| 106 | + // payload data). Must NOT be picked as a page boundary. |
| 107 | + false1 := []byte{0x00, 0x11, 'O', 'g', 'g', 'S', 0xFF, 0x22, 0x33, 0x44, 0x55, 0x66} |
| 108 | + buf.Write(false1) |
| 109 | + |
| 110 | + // Real page: "OggS" followed by version byte 0x00. |
| 111 | + realHeaderOffset := buf.SnapshotHead() + 4 |
| 112 | + real := []byte{0xAA, 0xBB, 0xCC, 0xDD, 'O', 'g', 'g', 'S', 0x00, 0x00, 0x00, 0x00} |
| 113 | + buf.Write(real) |
| 114 | + |
| 115 | + got := buf.AlignToOggPage(0) |
| 116 | + if got != realHeaderOffset { |
| 117 | + t.Fatalf("AlignToOggPage should have skipped the version!=0 false positive and "+ |
| 118 | + "returned the real page at offset %d, got %d", realHeaderOffset, got) |
| 119 | + } |
| 120 | +} |
| 121 | + |
| 122 | +// TestBroadcastIgnoresFalseOggMagic verifies the same validation inside the |
| 123 | +// Broadcast page-tracking path: a false-positive "OggS" in payload bytes |
| 124 | +// must not poison PageOffsets. |
| 125 | +func TestBroadcastIgnoresFalseOggMagic(t *testing.T) { |
| 126 | + s := newTestStream(true) |
| 127 | + r := fakeRelay() |
| 128 | + |
| 129 | + // All-zeros payload with a "OggS" sequence followed by 0xFF (not a real |
| 130 | + // Ogg version). No tracked page should be recorded. |
| 131 | + payload := make([]byte, 64) |
| 132 | + payload[10] = 'O' |
| 133 | + payload[11] = 'g' |
| 134 | + payload[12] = 'g' |
| 135 | + payload[13] = 'S' |
| 136 | + payload[14] = 0xFF |
| 137 | + s.Broadcast(payload, r) |
| 138 | + |
| 139 | + for i, po := range s.PageOffsets { |
| 140 | + if po != 0 { |
| 141 | + t.Fatalf("PageOffsets[%d]=%d — a false-positive OggS was tracked", i, po) |
| 142 | + } |
| 143 | + } |
| 144 | + |
| 145 | + // Now a real page (version byte == 0). Must be tracked. |
| 146 | + real := make([]byte, 32) |
| 147 | + real[5] = 'O' |
| 148 | + real[6] = 'g' |
| 149 | + real[7] = 'g' |
| 150 | + real[8] = 'S' |
| 151 | + real[9] = 0x00 |
| 152 | + expected := s.Buffer.SnapshotHead() + 5 |
| 153 | + s.Broadcast(real, r) |
| 154 | + |
| 155 | + if s.LastPageOffset != expected { |
| 156 | + t.Fatalf("LastPageOffset=%d, expected %d", s.LastPageOffset, expected) |
| 157 | + } |
| 158 | +} |
| 159 | + |
| 160 | +// TestBeginSessionWipesOggState verifies bug C's fix: after a producer |
| 161 | +// restart, stale Ogg headers/page tracking must not survive. |
| 162 | +func TestBeginSessionWipesOggState(t *testing.T) { |
| 163 | + s := newTestStream(true) |
| 164 | + |
| 165 | + s.SetOggHead([]byte{1, 2, 3, 4}, 100) |
| 166 | + s.mu.Lock() |
| 167 | + s.LastPageOffset = 999 |
| 168 | + s.PageOffsets[0] = 999 |
| 169 | + s.PageIndex = 7 |
| 170 | + s.mu.Unlock() |
| 171 | + |
| 172 | + s.BeginSession() |
| 173 | + |
| 174 | + if got := s.GetOggHead(); got != nil { |
| 175 | + t.Fatalf("OggHead should be nil after BeginSession, got %v", got) |
| 176 | + } |
| 177 | + s.mu.RLock() |
| 178 | + defer s.mu.RUnlock() |
| 179 | + if s.LastPageOffset != 0 { |
| 180 | + t.Fatalf("LastPageOffset should be reset, got %d", s.LastPageOffset) |
| 181 | + } |
| 182 | + if s.PageOffsets[0] != 0 { |
| 183 | + t.Fatalf("PageOffsets[0] should be reset, got %d", s.PageOffsets[0]) |
| 184 | + } |
| 185 | + if s.PageIndex != 0 { |
| 186 | + t.Fatalf("PageIndex should be reset, got %d", s.PageIndex) |
| 187 | + } |
| 188 | + head := s.Buffer.SnapshotHead() |
| 189 | + if s.FreshHead != head { |
| 190 | + t.Fatalf("FreshHead=%d should equal current Head=%d after BeginSession", s.FreshHead, head) |
| 191 | + } |
| 192 | +} |
0 commit comments