21.3 review fixes: guard superseded-seek failures; restore post-recovery retry

C6/AC8: IsStillActiveSeek() predicate guards all three SeekBeyondBuffer
failure exits, so a superseded seek never recovers over a newer seek's
state. AC6: empty scheduler routes to seekBeyondBuffer so a same-target
retry (seek or play) refetches instead of no-oping.
This commit is contained in:
daniel-c-harvey
2026-06-23 23:55:28 -04:00
parent af4cb186f3
commit b93881cd66
4 changed files with 102 additions and 15 deletions
@@ -65,18 +65,26 @@ function assertTrue(cond: boolean, msg?: string): void {
class FakeScheduler {
private offset: number;
private total: number;
// hasBuffers reflects whether the scheduler holds decoded audio. Starts true when total > 0
// (a populated window), set to false by clearForSeek() (recovery drains the buffers).
private _hasBuffers: boolean;
public clearedForSeek = false;
public stoppedAllSources = false;
public offsetSetTo: number | null = null;
constructor(offset: number, total: number) { this.offset = offset; this.total = total; }
constructor(offset: number, total: number) {
this.offset = offset;
this.total = total;
this._hasBuffers = total > 0;
}
getPlaybackOffset(): number { return this.offset; }
getTotalDuration(): number { return this.total; }
hasBuffers(): boolean { return this._hasBuffers; }
stopAllSources(): void { this.stoppedAllSources = true; }
// seekWithinBuffer calls playFromPosition only when wasPlaying; isPlaying is false in these
// unit constructions, so it is never invoked — present for completeness.
playFromPosition(_position: number): void { /* no-op */ }
clearForSeek(): void { this.clearedForSeek = true; }
clearForSeek(): void { this.clearedForSeek = true; this._hasBuffers = false; }
setPlaybackOffset(o: number): void { this.offset = o; this.offsetSetTo = o; }
}
@@ -243,6 +251,43 @@ test('recoverFromFailedRefill halts the scheduler and leaves a paused-but-loaded
assertEqual(priv(player).streamingStarted, false, 'streaming flagged not-started for a clean retry');
});
// --- AC6 retry contract: same-target seek after recovery refetches -------------------------
// After recoverFromFailedRefill the scheduler is empty (clearForSeek was called). A seek to
// the SAME position (seekPosition == playbackOffset) must route to seekBeyondBuffer — not
// seekWithinBuffer, which would be a silent no-op against the degenerate [P,P] empty window.
test('same-target seek after recovery routes to seekBeyondBuffer (AC6 retry)', () => {
const player = makePlayer();
const wav = new FakeStreamDecoder(true, 1000);
// Start with a populated window [30, 60), then simulate recovery at position 15:
// clearForSeek empties the scheduler; setPlaybackOffset anchors it to 15.
const scheduler = new FakeScheduler(30, 30);
arm(player, { scheduler, duration: 120, streamDecoder: wav });
// Drive recovery state manually (the same state recoverFromFailedRefill leaves).
player.recoverFromFailedRefill(15);
// At this point: scheduler.hasBuffers() == false, playbackOffset == 15, totalDuration == 0.
// A seek to 15 (the recovery anchor) must refetch, not silently resolve from the empty window.
const result = player.seek(15);
assertEqual(result.success, true, 'seek succeeds after recovery');
assertEqual(result.seekBeyondBuffer, true, 'same-target seek after recovery signals a refetch (AC6 retry)');
assertEqual(wav.requestedOffsetFor, 15, 'WAV resolver used for the retry offset');
});
// AC4 not regressed: a seek within a POPULATED retained window still resolves from buffer.
// This is the same test as the existing AC4 test but named explicitly to confirm the
// hasBuffers() guard does not affect the populated case.
test('seek within populated retained window still resolves in-buffer — AC4 not regressed', () => {
const player = makePlayer();
// Populated window [30, 60) — hasBuffers() starts true (total=30 > 0).
const scheduler = new FakeScheduler(30, 30);
arm(player, { scheduler, duration: 120, streamDecoder: new FakeStreamDecoder(true, 1000) });
const result = player.seek(45); // inside [30, 60)
assertEqual(result.success, true, 'seek succeeds');
assertEqual(result.seekBeyondBuffer ?? false, false, 'populated in-window seek does NOT signal a refetch');
assertEqual(scheduler.clearedForSeek, false, 'scheduler not cleared for an in-buffer seek (no refetch)');
});
// --- run -------------------------------------------------------------------------------------
if (failures.length > 0) {
console.error(failures.join('\n'));
+15 -8
View File
@@ -440,21 +440,28 @@ export class AudioPlayer {
const bufferEnd = this.scheduler.getTotalDuration() + bufferStart;
// The window-miss test for BOTH directions, and the 21.3 refill trigger for backward seeks.
// Position must be within [bufferStart, bufferEnd] to resolve from the retained buffers:
// - position >= bufferStart : UC3 — seek back within the retained back-window. Served from
// buffer with NO network refetch. (The lower bound is load-bearing: after eviction or a
// prior seek-beyond-buffer, bufferStart > 0, and a target below it would otherwise produce
// a negative bufferRelativePosition in seekWithinBuffer, silently clamping to position 0.)
// Position must be within [bufferStart, bufferEnd] AND the scheduler must hold buffers to
// resolve from the retained window:
// - position >= bufferStart AND hasBuffers : UC3 — seek back within the retained back-window.
// Served from buffer with NO network refetch. (The lower bound is load-bearing: after
// eviction or a prior seek-beyond-buffer, bufferStart > 0, and a target below it would
// otherwise produce a negative bufferRelativePosition in seekWithinBuffer, silently clamping
// to position 0.)
// - position < bufferStart : UC4 — seek back PAST the retained tail (the window was evicted).
// Falls through to seekBeyondBuffer, which is the existing Range path run toward an EARLIER
// offset. This is the 21.3 window-miss refill: "a seek the listener didn't initiate" reuses
// the same per-path resolver + reinit a forward seek-beyond-buffer uses, no new mechanism.
// - position > bufferEnd : UC2/UC5 — forward seek beyond buffer, unchanged.
if (position >= bufferStart && position <= bufferEnd) {
// - !hasBuffers (degenerate [P,P] window post-recovery): the window check above would
// spuriously route ANY target to seekWithinBuffer (bufferStart==bufferEnd==seekPosition
// after recoverFromFailedRefill). Force seekBeyondBuffer so a same-target retry actually
// refetches (AC6 retry contract). The !hasBuffers guard only fires in the degenerate case —
// a populated retained window has buffers and is unaffected (AC4 not regressed).
if (position >= bufferStart && position <= bufferEnd && this.scheduler.hasBuffers()) {
return this.seekWithinBuffer(position);
} else {
// Seeking outside the retained window - signal C# to fetch a new stream from the resolved
// offset (earlier for a back-past-window refill, later for a forward seek).
// Seeking outside the retained window, or to any position in an empty scheduler —
// signal C# to fetch a new stream from the resolved offset.
return this.seekBeyondBuffer(position);
}
}