acdream/tests/AcDream.App.Tests/Audio/AlBufferBudgetTrackerTests.cs
Erik 1e9031e7b7 fix(audio): bound decoded-wave and AL-buffer caches (2026-07-24 audit review)
Two audit-verified caches grew monotonically for process lifetime, which is
fatal for the 30-bot long-uptime headless-fleet goal:

- DatSoundCache._waves (Core) memoized every decoded PCM WaveData forever
  in a bare ConcurrentDictionary — no LRU, no byte budget.
- OpenAlAudioEngine._bufferByWaveId (App) retained a native OpenAL buffer
  copy of the same PCM per wave id until engine disposal — a second,
  independent unbounded cache.

DatSoundCache now bounds payload residency with a 32 MiB byte-budget LRU
(decoded PCM waves run ~100-500 KB each, so this holds a comfortable
working set). Missing/unsupported-format waves are memoized separately in
an unbounded-but-cheap negative-result set (bounded by the finite Wave dat
id space) so they can never compete with or get evicted alongside payload
entries. Concurrent first-touch decodes of the same wave id are deduped
via a shared Lazy<T> so racing callers don't pay for WaveDecoder.Decode
twice. AcDream.Core cannot reference AcDream.Content (code-structure rule
2), so the LRU is a local reimplementation mirroring
BoundedDatObjectCache/DecodedTextureCache's shape rather than a shared
dependency.

OpenAlAudioEngine._bufferByWaveId now bounds native buffer residency with
a 48 MiB byte-budget LRU (AlBufferBudgetTracker), evicting least-recently-
used buffers once oversized. alDeleteBuffers fails on a buffer still
attached to a source, so eviction queries live AL per-source state
(GetSourceInteger.Buffer) rather than tracking a second, easily-stale
copy — several call sites (Play3DWave, PlayUiWave) set a source's buffer
directly. The buffer EnsureBuffer just created is explicitly protected
from its own eviction pass, since the caller hasn't attached it to a
source yet at that point. Evicted waves simply replay through
DatSoundCache -> EnsureBuffer on next use, identical to a first play.

Verified before implementing: AudioHookSink is the only GetWave caller
(single per-frame render-thread path per AnimationHookRouter's own
threading doc), and PcmBytes is read only at DatSoundCache.Admit (byte
accounting) and EnsureBuffer's first-upload branch — confirmed dead after
AL upload on the steady-state replay path, so bounding either cache
independently is correctness-safe; a cold replay after both evict simply
falls back to a full re-decode + re-upload, identical to a first play.

AlBufferBudgetTracker's eviction/budget decision is extracted as pure
logic (no AL dependency) specifically so it's unit-testable: the existing
OpenAlResourceLifetimeTests fake exposes a null AL, which short-circuits
every native buffer call before it runs, so the engine's actual AL wiring
isn't testable headless.

Tests: 9 new DatSoundCacheTests (Core.Tests) covering eviction order, byte
accounting, negative-result memoization, oversize-single-entry handling,
and concurrent-access smoke tests; 10 new AlBufferBudgetTrackerTests
(App.Tests) covering the pure LRU/budget/protection logic. Full suite:
3214/2 skip (Core.Tests), 3471/3 skip (App.Tests) plus one pre-existing,
unrelated failure (LandblockBuildOriginTests.FarLoad_..., reproduces
identically with these changes stashed out — landblock streaming, not
audio).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 76c880d35bcf30b50e3f7b4cb8635dc9ab9e3ec7)
2026-07-24 11:49:57 +02:00

152 lines
5.2 KiB
C#

using AcDream.App.Audio;
namespace AcDream.App.Tests.Audio;
/// <summary>
/// Pure LRU/byte-budget decision logic backing
/// <see cref="OpenAlAudioEngine"/>'s bounded AL buffer cache (verified-
/// unbounded audit, 2026-07-24). The engine's actual AL calls
/// (<c>alGenBuffers</c>, <c>alDeleteBuffers</c>, source binding) aren't
/// unit-testable headless — <c>OpenAlResourceLifetimeTests</c>' fake
/// <c>IOpenAlResourceApi</c> deliberately exposes a null <c>AL</c>, which
/// short-circuits every native buffer call before it runs. This suite
/// covers the pure eviction/budget decision that was extracted specifically
/// so it stays testable without a live OpenAL device.
/// </summary>
public sealed class AlBufferBudgetTrackerTests
{
[Fact]
public void RecordCreated_TracksResidentBytesAndCount()
{
var tracker = new AlBufferBudgetTracker(maxBytes: 1000);
tracker.RecordCreated(waveId: 1, bufferId: 101, bytes: 200);
tracker.RecordCreated(waveId: 2, bufferId: 102, bytes: 300);
Assert.Equal(500, tracker.ResidentBytes);
Assert.Equal(2, tracker.Count);
Assert.True(tracker.TryGetBufferId(1, out uint buf1));
Assert.Equal(101u, buf1);
Assert.True(tracker.TryGetBufferId(2, out uint buf2));
Assert.Equal(102u, buf2);
}
[Fact]
public void TryGetBufferId_UnknownWaveId_ReturnsFalse()
{
var tracker = new AlBufferBudgetTracker(maxBytes: 1000);
Assert.False(tracker.TryGetBufferId(42, out uint bufferId));
Assert.Equal(0u, bufferId);
}
[Fact]
public void TryEvictOldestUnprotected_PicksLeastRecentlyUsedEntry()
{
var tracker = new AlBufferBudgetTracker(maxBytes: 1000);
tracker.RecordCreated(1, 101, 100); // oldest
tracker.RecordCreated(2, 102, 100);
tracker.RecordCreated(3, 103, 100); // newest
bool evicted = tracker.TryEvictOldestUnprotected(
static _ => false, out uint waveId, out uint bufferId);
Assert.True(evicted);
Assert.Equal(1u, waveId);
Assert.Equal(101u, bufferId);
Assert.Equal(200, tracker.ResidentBytes);
Assert.Equal(2, tracker.Count);
Assert.False(tracker.TryGetBufferId(1, out _));
}
[Fact]
public void Touch_MovesEntryToMostRecentlyUsed()
{
var tracker = new AlBufferBudgetTracker(maxBytes: 1000);
tracker.RecordCreated(1, 101, 100);
tracker.RecordCreated(2, 102, 100);
tracker.RecordCreated(3, 103, 100);
// Without a touch, wave 1 (buffer 101) is oldest and would be evicted.
tracker.Touch(1);
bool evicted = tracker.TryEvictOldestUnprotected(
static _ => false, out uint waveId, out _);
Assert.True(evicted);
Assert.Equal(2u, waveId); // 2 is now the least-recently-used, not 1
}
[Fact]
public void Touch_UnknownWaveId_IsNoOp()
{
var tracker = new AlBufferBudgetTracker(maxBytes: 1000);
tracker.RecordCreated(1, 101, 100);
tracker.Touch(999); // never recorded — must not throw or create an entry
Assert.Equal(1, tracker.Count);
}
[Fact]
public void TryEvictOldestUnprotected_SkipsProtectedEntriesAndPicksNextOldest()
{
var tracker = new AlBufferBudgetTracker(maxBytes: 1000);
tracker.RecordCreated(1, 101, 100); // oldest, but protected (attached to a live source)
tracker.RecordCreated(2, 102, 100); // next-oldest, unprotected — should be picked
tracker.RecordCreated(3, 103, 100); // newest
bool evicted = tracker.TryEvictOldestUnprotected(
bufferId => bufferId == 101, out uint waveId, out uint bufferIdOut);
Assert.True(evicted);
Assert.Equal(2u, waveId);
Assert.Equal(102u, bufferIdOut);
Assert.True(tracker.TryGetBufferId(1, out _)); // still resident — protected
}
[Fact]
public void TryEvictOldestUnprotected_EverythingProtected_ReturnsFalseAndChangesNothing()
{
var tracker = new AlBufferBudgetTracker(maxBytes: 1000);
tracker.RecordCreated(1, 101, 100);
tracker.RecordCreated(2, 102, 100);
bool evicted = tracker.TryEvictOldestUnprotected(
static _ => true, out uint waveId, out uint bufferId);
Assert.False(evicted);
Assert.Equal(0u, waveId);
Assert.Equal(0u, bufferId);
Assert.Equal(200, tracker.ResidentBytes);
Assert.Equal(2, tracker.Count);
}
[Fact]
public void TryEvictOldestUnprotected_EmptyTracker_ReturnsFalse()
{
var tracker = new AlBufferBudgetTracker(maxBytes: 1000);
bool evicted = tracker.TryEvictOldestUnprotected(
static _ => false, out _, out _);
Assert.False(evicted);
}
[Fact]
public void RecordCreated_ZeroOrNegativeBytes_IsChargedAtLeastOneByte()
{
var tracker = new AlBufferBudgetTracker(maxBytes: 1000);
tracker.RecordCreated(1, 101, bytes: 0);
Assert.Equal(1, tracker.ResidentBytes);
}
[Fact]
public void Constructor_RejectsNonPositiveBudget()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new AlBufferBudgetTracker(0));
Assert.Throws<ArgumentOutOfRangeException>(() => new AlBufferBudgetTracker(-1));
}
}