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)
This commit is contained in:
parent
60bc313917
commit
1e9031e7b7
5 changed files with 777 additions and 19 deletions
152
tests/AcDream.App.Tests/Audio/AlBufferBudgetTrackerTests.cs
Normal file
152
tests/AcDream.App.Tests/Audio/AlBufferBudgetTrackerTests.cs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue