diff --git a/js/hang/src/container/consumer.test.ts b/js/hang/src/container/consumer.test.ts index 69c8400a96..180c814f0a 100644 --- a/js/hang/src/container/consumer.test.ts +++ b/js/hang/src/container/consumer.test.ts @@ -1275,10 +1275,10 @@ test("Consumer delivers a contiguous group after one that completed out of order consumer.close(); }); -// While the cursor sits below every buffered group (a real PTS gap it is waiting out), the delivery -// head still has to run the max age check on each frame. If only the head is receiving frames, -// that check is the only thing left that can break the stall. -test("Consumer age-skips a waited-out gap when only the head receives frames (CMAF)", async () => { +// A resubscribe can replay a stale group, lose the group after it, then carry on live. Once the live +// head reaches past where presentation left off by more than the budget, whatever is missing would +// arrive too old to play, so the head is the group to play next and must not be dropped as "slow". +test("Consumer plays the head once a waited-out gap exceeds the budget (CMAF)", async () => { const track = new Track.Producer("test"); const consumer = new Consumer(replay(track), { format: new CmafFormat(TEST_INIT), @@ -1293,35 +1293,59 @@ test("Consumer age-skips a waited-out gap when only the head receives frames (CM a.close(); expect((await consumer.next())?.frame).toBeUndefined(); // #active falls back to the 1001 phantom - // B (seq 2000) starts at 90_000 ticks (1_000_000µs), far past A's end: a real gap, so the cursor - // stays on the phantom and B is never promoted. + // The reader is parked on the missing 1001, as a decoder is while it waits for the next group. + const pending = consumer.next(); + + // B (seq 2000) starts at 90_000 ticks (1_000_000µs), a second past A's end: the missing group + // could only ever arrive far beyond the 100ms budget, so B plays from its first frame. const b = new Group.Producer(2000); track.writeGroup(b); b.writeFrame({ payload: encodeCmafFrame(0x02, 90_000, 1), timestamp: Time.Timestamp.now() }); - await settle(); - // C (seq 3000) lands just behind B, inside the 100ms budget, then goes silent for the rest of - // the test. So C's frames can't be what re-runs the max age check. - const c = new Group.Producer(3000); - track.writeGroup(c); - c.writeFrame({ payload: encodeCmafFrame(0x03, 93_000, 2), timestamp: Time.Timestamp.now() }); - await settle(); + const result = await Promise.race([pending, settle(300).then(() => "timeout" as const)]); + expect(result).not.toBe("timeout"); + const delivered = result as { frame?: Frame; continuous?: boolean } | undefined; + expect(delivered?.frame?.payload).toEqual(new Uint8Array([0x02])); + // The missing group's span was never delivered, so downstream must not bridge it. + expect(delivered?.continuous).toBe(false); + + consumer.close(); +}); + +// A gap still inside the budget is waited out: the missing group may yet arrive in time. +test("Consumer waits on a gap within the budget, then plays the head once it exceeds it (CMAF)", async () => { + const track = new Track.Producer("test"); + const consumer = new Consumer(replay(track), { + format: new CmafFormat(TEST_INIT), + maxAge: 100 as Time.Milli, + }); + + // A (seq 1000): one frame, ends at 3000 ticks (33_333µs). + const a = new Group.Producer(1000); + track.writeGroup(a); + a.writeFrame({ payload: encodeCmafFrame(0x01, 0, 0), timestamp: Time.Timestamp.now() }); + expect((await consumer.next())?.frame?.payload).toEqual(new Uint8Array([0x01])); + a.close(); + expect((await consumer.next())?.frame).toBeUndefined(); + + // B (seq 2000) starts at 6000 ticks (66_667µs): a gap, but only 33ms past A's end. + const b = new Group.Producer(2000); + track.writeGroup(b); + b.writeFrame({ payload: encodeCmafFrame(0x02, 6000, 1), timestamp: Time.Timestamp.now() }); const pending = consumer.next(); + const early = await Promise.race([pending, settle(50).then(() => "waiting" as const)]); + expect(early).toBe("waiting"); - // B alone grows past the budget (90_000 -> 108_000 ticks, a 200ms span). - for (let i = 1; i <= 6; i++) { - b.writeFrame({ payload: encodeCmafFrame(0x02, 90_000 + i * 3000, 3 + i), timestamp: Time.Timestamp.now() }); + // B alone grows past the budget (up to 12_000 ticks, 100ms past A's end and beyond). + for (let i = 1; i <= 3; i++) { + b.writeFrame({ payload: encodeCmafFrame(0x02, 6000 + i * 3000, 1 + i), timestamp: Time.Timestamp.now() }); await settle(10); } - // The max age check drops B as the oldest and delivery resumes at C. const result = await Promise.race([pending, settle(300).then(() => "timeout" as const)]); expect(result).not.toBe("timeout"); - const delivered = result as { frame?: Frame; continuous?: boolean } | undefined; - expect(delivered?.frame?.payload).toEqual(new Uint8Array([0x03])); - // B's content was thrown away, so downstream must not treat the span as delivered. - expect(delivered?.continuous).toBe(false); + expect((result as { frame?: Frame } | undefined)?.frame?.payload).toEqual(new Uint8Array([0x02])); consumer.close(); }); diff --git a/js/hang/src/container/consumer.ts b/js/hang/src/container/consumer.ts index 6540d02445..912ada6fc7 100644 --- a/js/hang/src/container/consumer.ts +++ b/js/hang/src/container/consumer.ts @@ -465,16 +465,25 @@ export class Consumer { // non-sequential) next group has since arrived -- promote #active to the first real // group so delivery resumes instead of stalling on a nonexistent sequence. // Promote #active to the first buffered group when it continues the timeline we left off at, - // when a completed empty group can be walked (empty groups mean nothing), or when a - // zero-budget hole is already proven. After track termination no missing group can arrive, - // so drain across any remaining gap. Otherwise wait: #checkMaxAge skips once the budget - // is spent, and #tryDurationSkip once the duration covers it. + // when a completed empty group can be walked (empty groups mean nothing), or when the hole + // is proven: the head already reaches past where presentation left off by more than the + // max age, so anything still missing in between would arrive too old to play. Proving it + // here matters: #checkMaxAge would instead drop the head, the very group to play next. + // After track termination no missing group can arrive, so drain across any remaining gap. + // Otherwise wait: #checkMaxAge skips once the budget is spent, and #tryDurationSkip once + // the duration covers it. if (this.#active !== undefined && this.#groups.length > 0) { const head = this.#groups[0]; if (head.consumer.sequence > this.#active) { const contiguous = ptsContiguous(this.#presentedEnd, head.frames.at(0)?.timestamp); const empty = head.empty && head.consumer.done; - const skipHole = this.#maxAge.peek() === 0 && head.frames.length > 0; + const maxAge = Moq.Time.Micro.fromMilli(this.#maxAge.peek()); + const skipHole = + head.frames.length > 0 && + (maxAge === 0 || + (this.#presentedEnd !== undefined && + head.latest !== undefined && + head.latest - this.#presentedEnd > maxAge)); if (empty || contiguous || skipHole || ended !== undefined) { if ((skipHole || ended !== undefined) && !contiguous && !empty) this.#markPlayhead(); if (!contiguous) this.#gap = true;