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));
|
||||
}
|
||||
}
|
||||
237
tests/AcDream.Core.Tests/Audio/DatSoundCacheTests.cs
Normal file
237
tests/AcDream.Core.Tests/Audio/DatSoundCacheTests.cs
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
using System.Collections.Concurrent;
|
||||
using AcDream.Core.Audio;
|
||||
using AcDream.Core.Content;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Lib.IO;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the payload-LRU eviction added to bound <c>DatSoundCache</c>'s
|
||||
/// decoded-wave residency (verified-unbounded audit, 2026-07-24): eviction
|
||||
/// order, byte accounting, negative-result (missing/unsupported) handling,
|
||||
/// and a concurrency smoke test.
|
||||
/// </summary>
|
||||
public sealed class DatSoundCacheTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetWave_DecodesAndReturnsPcmWave()
|
||||
{
|
||||
var dats = new FakeDatObjectSource();
|
||||
dats.AddWave(1, MakePcmWave(1, dataBytes: 100));
|
||||
var cache = new DatSoundCache(dats, maxWaveBytes: 10_000);
|
||||
|
||||
WaveData? wave = cache.GetWave(1);
|
||||
|
||||
Assert.NotNull(wave);
|
||||
Assert.Equal(100, wave!.PcmBytes.Length);
|
||||
Assert.Equal(1, cache.CachedWaveCount);
|
||||
Assert.Equal(100, cache.ResidentWaveBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetWave_MissingWave_ReturnsNullAndMemoizesNegativeWithoutRepeatedLookup()
|
||||
{
|
||||
var dats = new FakeDatObjectSource(); // no wave registered for id 7
|
||||
var cache = new DatSoundCache(dats, maxWaveBytes: 10_000);
|
||||
|
||||
Assert.Null(cache.GetWave(7));
|
||||
Assert.Null(cache.GetWave(7));
|
||||
Assert.Null(cache.GetWave(7));
|
||||
|
||||
// The negative-result memo short-circuits GetWave before it ever
|
||||
// reaches the dat lookup a second time.
|
||||
Assert.Equal(1, dats.GetCallCount(7));
|
||||
Assert.Equal(0, cache.CachedWaveCount);
|
||||
Assert.Equal(0, cache.ResidentWaveBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetWave_UnsupportedFormat_ReturnsNullAndDoesNotConsumeByteBudget()
|
||||
{
|
||||
var dats = new FakeDatObjectSource();
|
||||
dats.AddWave(2, MakeMp3Wave(2)); // WaveDecoder.Decode returns null for MP3
|
||||
var cache = new DatSoundCache(dats, maxWaveBytes: 10_000);
|
||||
|
||||
Assert.Null(cache.GetWave(2));
|
||||
Assert.Equal(0, cache.CachedWaveCount);
|
||||
Assert.Equal(0, cache.ResidentWaveBytes);
|
||||
|
||||
// Second call hits the negative memo, not a fresh decode of the dat entry.
|
||||
Assert.Null(cache.GetWave(2));
|
||||
Assert.Equal(1, dats.GetCallCount(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetWave_CacheHit_ReturnsSameInstanceWithoutRedecoding()
|
||||
{
|
||||
var dats = new FakeDatObjectSource();
|
||||
dats.AddWave(1, MakePcmWave(1, dataBytes: 100));
|
||||
var cache = new DatSoundCache(dats, maxWaveBytes: 10_000);
|
||||
|
||||
WaveData? first = cache.GetWave(1);
|
||||
WaveData? second = cache.GetWave(1);
|
||||
|
||||
Assert.Same(first, second);
|
||||
Assert.Equal(1, dats.GetCallCount(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetWave_EvictsLeastRecentlyUsedWhenOverBudget()
|
||||
{
|
||||
var dats = new FakeDatObjectSource();
|
||||
dats.AddWave(1, MakePcmWave(1, dataBytes: 150)); // A
|
||||
dats.AddWave(2, MakePcmWave(2, dataBytes: 150)); // B
|
||||
dats.AddWave(3, MakePcmWave(3, dataBytes: 150)); // C
|
||||
var cache = new DatSoundCache(dats, maxWaveBytes: 300);
|
||||
|
||||
WaveData? a1 = cache.GetWave(1);
|
||||
WaveData? b1 = cache.GetWave(2);
|
||||
Assert.Equal(2, cache.CachedWaveCount);
|
||||
Assert.Equal(300, cache.ResidentWaveBytes); // exactly at budget, no eviction yet
|
||||
|
||||
// Touch A so B becomes the least-recently-used entry.
|
||||
Assert.Same(a1, cache.GetWave(1));
|
||||
|
||||
// Admitting C (150) would push residency to 450 > 300 — B must go.
|
||||
WaveData? c1 = cache.GetWave(3);
|
||||
Assert.Equal(2, cache.CachedWaveCount);
|
||||
Assert.Equal(300, cache.ResidentWaveBytes);
|
||||
|
||||
// A and C are still resident (no re-decode); B was evicted (re-decodes on next use).
|
||||
Assert.Same(a1, cache.GetWave(1));
|
||||
Assert.Equal(1, dats.GetCallCount(1));
|
||||
Assert.Same(c1, cache.GetWave(3));
|
||||
Assert.Equal(1, dats.GetCallCount(3));
|
||||
|
||||
WaveData? b2 = cache.GetWave(2);
|
||||
Assert.NotNull(b2);
|
||||
Assert.NotSame(b1, b2); // evicted entries decode fresh on next request
|
||||
Assert.Equal(2, dats.GetCallCount(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetWave_OversizeSingleWave_IsServedButNotRetained()
|
||||
{
|
||||
var dats = new FakeDatObjectSource();
|
||||
dats.AddWave(9, MakePcmWave(9, dataBytes: 500));
|
||||
var cache = new DatSoundCache(dats, maxWaveBytes: 100); // budget smaller than the wave itself
|
||||
|
||||
WaveData? first = cache.GetWave(9);
|
||||
Assert.NotNull(first);
|
||||
Assert.Equal(0, cache.CachedWaveCount);
|
||||
Assert.Equal(0, cache.ResidentWaveBytes);
|
||||
|
||||
WaveData? second = cache.GetWave(9);
|
||||
Assert.NotNull(second);
|
||||
Assert.NotSame(first, second); // not retained, so this re-decoded
|
||||
Assert.Equal(2, dats.GetCallCount(9));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetWave_ConcurrentSameId_PublishesOneCanonicalWaveAndDecodesOnce()
|
||||
{
|
||||
var dats = new FakeDatObjectSource();
|
||||
dats.AddWave(1, MakePcmWave(1, dataBytes: 1000));
|
||||
var cache = new DatSoundCache(dats, maxWaveBytes: 10_000);
|
||||
|
||||
var results = new ConcurrentBag<WaveData?>();
|
||||
Parallel.For(0, 200, _ => results.Add(cache.GetWave(1)));
|
||||
|
||||
WaveData?[] all = results.ToArray();
|
||||
Assert.All(all, Assert.NotNull);
|
||||
WaveData canonical = all[0]!;
|
||||
Assert.All(all, value => Assert.Same(canonical, value));
|
||||
Assert.Equal(1, dats.GetCallCount(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetWave_ConcurrentMissingId_NeverThrowsAndAllReturnNull()
|
||||
{
|
||||
var dats = new FakeDatObjectSource(); // id 5 never registered
|
||||
var cache = new DatSoundCache(dats, maxWaveBytes: 10_000);
|
||||
|
||||
var results = new ConcurrentBag<WaveData?>();
|
||||
Parallel.For(0, 200, _ => results.Add(cache.GetWave(5)));
|
||||
|
||||
Assert.All(results, Assert.Null);
|
||||
Assert.Equal(0, cache.CachedWaveCount);
|
||||
}
|
||||
|
||||
// ── Test fixtures ────────────────────────────────────────────────────
|
||||
|
||||
private static byte[] MakePcmHeader(ushort channels = 1, uint rate = 22050, ushort bits = 16)
|
||||
{
|
||||
byte[] h = new byte[18];
|
||||
h[0] = 0x01; h[1] = 0x00; // fmtTag PCM
|
||||
h[2] = (byte)channels; h[3] = (byte)(channels >> 8);
|
||||
h[4] = (byte)rate; h[5] = (byte)(rate >> 8);
|
||||
h[6] = (byte)(rate >> 16); h[7] = (byte)(rate >> 24);
|
||||
uint avg = rate * channels * (uint)(bits / 8);
|
||||
h[8] = (byte)avg; h[9] = (byte)(avg >> 8);
|
||||
h[10] = (byte)(avg >> 16); h[11] = (byte)(avg >> 24);
|
||||
ushort blockAlign = (ushort)(channels * (bits / 8));
|
||||
h[12] = (byte)blockAlign; h[13] = (byte)(blockAlign >> 8);
|
||||
h[14] = (byte)bits; h[15] = (byte)(bits >> 8);
|
||||
return h;
|
||||
}
|
||||
|
||||
private static Wave MakePcmWave(uint id, int dataBytes) => new()
|
||||
{
|
||||
Id = id,
|
||||
Header = MakePcmHeader(),
|
||||
Data = new byte[dataBytes],
|
||||
};
|
||||
|
||||
private static Wave MakeMp3Wave(uint id) => new()
|
||||
{
|
||||
Id = id,
|
||||
Header = new byte[18] { 0x55, 0x00, 0x02, 0x00, 0x44, 0xAC, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
|
||||
Data = new byte[1024],
|
||||
};
|
||||
|
||||
private sealed class FakeDatObjectSource : IDatObjectSource
|
||||
{
|
||||
private readonly Dictionary<uint, Wave> _waves = new();
|
||||
private readonly Dictionary<uint, int> _callCounts = new();
|
||||
private readonly object _gate = new();
|
||||
|
||||
public void AddWave(uint id, Wave wave) => _waves[id] = wave;
|
||||
|
||||
public int GetCallCount(uint id)
|
||||
{
|
||||
lock (_gate)
|
||||
return _callCounts.TryGetValue(id, out int count) ? count : 0;
|
||||
}
|
||||
|
||||
[return: MaybeNull]
|
||||
public T Get<T>(uint fileId) where T : IDBObj
|
||||
{
|
||||
if (typeof(T) == typeof(Wave))
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_callCounts.TryGetValue(fileId, out int count);
|
||||
_callCounts[fileId] = count + 1;
|
||||
}
|
||||
|
||||
return _waves.TryGetValue(fileId, out var wave) ? (T)(object)wave : default;
|
||||
}
|
||||
return default;
|
||||
}
|
||||
|
||||
public bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj
|
||||
{
|
||||
T? result = Get<T>(fileId);
|
||||
if (result is null)
|
||||
{
|
||||
value = default!;
|
||||
return false;
|
||||
}
|
||||
value = result;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue