Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 45 additions & 21 deletions js/hang/src/container/consumer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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();
});
Expand Down
19 changes: 14 additions & 5 deletions js/hang/src/container/consumer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (empty || contiguous || skipHole || ended !== undefined) {
if ((skipHole || ended !== undefined) && !contiguous && !empty) this.#markPlayhead();
if (!contiguous) this.#gap = true;
Expand Down
Loading