From 07f29a82164e844628e72db4dd18e068af944128 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 23 Jun 2026 22:49:12 -0400 Subject: [PATCH] Reconcile eviction comment wording; add handleSourceEnded cascade test (Phase 21.1) The inclusive <= bound is correct; comments now say 'at or behind'. New test drives eviction through the real onended trigger with a live mid-array source pinning the frontier. --- .../Interop/audio/PlaybackScheduler.test.ts | 72 +++++++++++++++++++ .../Interop/audio/PlaybackScheduler.ts | 4 +- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/DeepDrftPublic/Interop/audio/PlaybackScheduler.test.ts b/DeepDrftPublic/Interop/audio/PlaybackScheduler.test.ts index 8b87803..ec75d0e 100644 --- a/DeepDrftPublic/Interop/audio/PlaybackScheduler.test.ts +++ b/DeepDrftPublic/Interop/audio/PlaybackScheduler.test.ts @@ -253,6 +253,78 @@ test('eviction does not drop buffers under live sources or past the schedule cur assertEqual(dropped, 0, 'no eviction while front sources are live'); }); +// handleSourceEnded cascade: eviction fires from the real production trigger (onended), not +// via a direct evictPlayedBuffers() call. Confirms the anchor/index invariants hold end-to-end +// through the scheduler's own event handling while playback is still active with a live source. +// +// Setup: 0.3s buffers so the 500ms lookahead window fits exactly two sources after +// playFromPosition(0). Buffer 0 ends at ~0.31s, buffer 1 ends at ~0.61s — both are scheduled. +// Clock is then advanced to t=0.6 so buffer 0's end (0.31) < evictBefore (0.6) while the live +// source on buffer 1 pins firstLiveIndex=1, blocking further eviction. This is the mid-array +// pinning scenario that later waves (21.2/21.3) build on. +test('eviction via handleSourceEnded: position exact, live bufferIndex decremented, frontier respected', () => { + const cm = new FakeContextManager(); + const s = makeScheduler(cm); + // Retain nothing behind the playhead — evict aggressively so the cascade fires. + s.setBackRetainSeconds(0); + + // Eight 0.3s buffers. scheduleBuffersFrom with lookaheadTarget=0.5s at t=0: + // after buf 0: nextScheduleTime≈0.31, lookahead=0.31 < 0.5 → continues + // after buf 1: nextScheduleTime≈0.61, lookahead=0.61 > 0.5 → breaks + // → exactly two sources are live after playFromPosition. + for (let i = 0; i < 8; i++) s.addBuffer(buf(0.3)); + + cm.now = 0; + s.playFromPosition(0); + + // Reach inside to see which sources were scheduled and what bufferIndex they hold. + const priv = s as unknown as { + scheduledSources: Array<{ source: FakeSource; bufferIndex: number; startTime: number; endTime: number }>; + nextBufferIndex: number; + }; + + // Confirm two sources are live — the setup guarantee. + if (priv.scheduledSources.length < 2) { + throw new Error(`Expected ≥2 scheduled sources after playFromPosition, got ${priv.scheduledSources.length}`); + } + + // Identify the first and second scheduled sources by bufferIndex order. + const sorted = [...priv.scheduledSources].sort((a, b) => a.bufferIndex - b.bufferIndex); + const firstScheduled = sorted[0]; // bufferIndex 0 + const secondScheduled = sorted[1]; // bufferIndex 1 + const secondBufferIndexBefore = secondScheduled.bufferIndex; // must be 1 + + // Record the second FakeSource so we can assert it was not stopped by eviction. + const secondFakeSource = secondScheduled.source as unknown as FakeSource; + + // Advance clock to 0.6s. Buffer 0 ends at ~0.31s → evictBefore=0.6, end=0.31 ≤ 0.6 → + // droppable. Buffer 1 ends at ~0.61s → its live source pins firstLiveIndex=1 → NOT dropped. + cm.now = 0.6; + + // Confirm playback is still active before firing the cascade. + assertEqual(s.isActive(), true, 'isActive must be true before cascade'); + + // Fire the cascade via the production trigger: stop the first source, which calls onended, + // which calls handleSourceEnded, which calls evictPlayedBuffers internally. + (firstScheduled.source as unknown as FakeSource).stop(); + + // (a) Absolute position must remain exactly 0.6. + assertClose(s.getCurrentPosition(), 0.6, 'position after handleSourceEnded cascade'); + + // (b) The second live source's bufferIndex must have been decremented by 1 (the one evicted + // front buffer), shifting it from absolute index 1 to absolute index 0. + const expectedSecondIndex = secondBufferIndexBefore - 1; + assertEqual(secondScheduled.bufferIndex, expectedSecondIndex, 'live source bufferIndex decremented'); + + // (c) Eviction stopped at firstLiveIndex=1, not nextBufferIndex — the second buffer was + // NOT dropped. Verify the second source was not stopped (it remained live throughout). + assertEqual(secondFakeSource.stopped, false, 'live second source not stopped by eviction'); + // And the scheduler still has buffers (the array was not wiped past the frontier). + if (s.getBufferCount() === 0) { + throw new Error('eviction wiped all buffers — should have stopped at firstLiveIndex'); + } +}); + // --- run ------------------------------------------------------------------------------------- if (failures.length > 0) { console.error(failures.join('\n')); diff --git a/DeepDrftPublic/Interop/audio/PlaybackScheduler.ts b/DeepDrftPublic/Interop/audio/PlaybackScheduler.ts index 0dfc6ad..975dc45 100644 --- a/DeepDrftPublic/Interop/audio/PlaybackScheduler.ts +++ b/DeepDrftPublic/Interop/audio/PlaybackScheduler.ts @@ -136,7 +136,7 @@ export class PlaybackScheduler { * Drop already-played buffers from the front of the array, reclaiming their decoded float * memory, and advance the time anchor so all position/index bookkeeping stays exact. * - * Eviction frontier: any buffer whose absolute END time is older than + * Eviction frontier: any buffer whose absolute END time is at or older than * (currentPosition - backRetainSeconds) is droppable. We evict a contiguous run from the * front only — buffers are appended in playback order, so the front is always the oldest. * @@ -179,7 +179,7 @@ export class PlaybackScheduler { let accumulatedEnd = this.playbackOffset; for (let i = 0; i < maxEvictable; i++) { accumulatedEnd += this.buffers[i].duration; - // Drop only buffers whose END is strictly behind the retain frontier. + // Drop buffers whose END is at or behind the retain frontier (inclusive bound). if (accumulatedEnd <= evictBefore) { evictCount = i + 1; } else {