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:
Erik 2026-07-24 11:49:16 +02:00
parent 60bc313917
commit 1e9031e7b7
5 changed files with 777 additions and 19 deletions

View file

@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
namespace AcDream.App.Audio;
/// <summary>
/// Pure LRU/byte-budget bookkeeping for <see cref="OpenAlAudioEngine"/>'s
/// native AL buffer cache (<c>_bufferByWaveId</c>).
/// </summary>
/// <remarks>
/// Deliberately has no AL/Silk dependency. <see cref="OpenAlAudioEngine"/>
/// supplies an "is this buffer id still attached to a live source"
/// predicate — native AL per-source state is the only thing that actually
/// determines whether <c>alDeleteBuffers</c> would fail, so the engine
/// queries it live via <c>AL.GetSourceProperty</c> rather than this class
/// tracking a second, easily-stale copy. This class only decides WHICH
/// resident buffer is the least-recently-used *evictable* one and keeps
/// the running byte total, which keeps that decision unit-testable without
/// a live OpenAL device.
/// </remarks>
internal sealed class AlBufferBudgetTracker
{
private sealed class Entry
{
public required uint WaveId { get; init; }
public required uint BufferId { get; init; }
public required long Bytes { get; init; }
public long LastUseTick { get; set; }
}
private readonly Dictionary<uint, Entry> _byWaveId = new();
private long _tick;
public AlBufferBudgetTracker(long maxBytes)
{
ArgumentOutOfRangeException.ThrowIfLessThan(maxBytes, 1);
MaxBytes = maxBytes;
}
public long MaxBytes { get; }
public long ResidentBytes { get; private set; }
public int Count => _byWaveId.Count;
public bool TryGetBufferId(uint waveId, out uint bufferId)
{
if (_byWaveId.TryGetValue(waveId, out var entry))
{
bufferId = entry.BufferId;
return true;
}
bufferId = 0;
return false;
}
/// <summary>
/// Record a freshly-allocated, freshly-uploaded buffer as resident and
/// most-recently-used.
/// </summary>
public void RecordCreated(uint waveId, uint bufferId, long bytes)
{
long charged = Math.Max(1L, bytes);
_byWaveId[waveId] = new Entry
{
WaveId = waveId,
BufferId = bufferId,
Bytes = charged,
LastUseTick = ++_tick,
};
ResidentBytes += charged;
}
/// <summary>
/// Bump an already-resident buffer's LRU order — call on every replay
/// (cache hit), not just on first creation.
/// </summary>
public void Touch(uint waveId)
{
if (_byWaveId.TryGetValue(waveId, out var entry))
entry.LastUseTick = ++_tick;
}
/// <summary>
/// Evict the single least-recently-used resident entry whose buffer id
/// is NOT reported as protected by <paramref name="isProtected"/>
/// (still attached to a live AL source, or otherwise off-limits for
/// this call — e.g. a buffer just created and not yet handed to the
/// caller). Returns false when every resident entry is currently
/// protected, meaning the caller should stop evicting and accept a
/// transient budget overage rather than loop forever.
/// </summary>
public bool TryEvictOldestUnprotected(
Func<uint, bool> isProtected,
out uint evictedWaveId,
out uint evictedBufferId)
{
ArgumentNullException.ThrowIfNull(isProtected);
Entry? victim = null;
foreach (Entry candidate in _byWaveId.Values)
{
if (isProtected(candidate.BufferId))
continue;
if (victim is null || candidate.LastUseTick < victim.LastUseTick)
victim = candidate;
}
if (victim is null)
{
evictedWaveId = 0;
evictedBufferId = 0;
return false;
}
_byWaveId.Remove(victim.WaveId);
ResidentBytes -= victim.Bytes;
evictedWaveId = victim.WaveId;
evictedBufferId = victim.BufferId;
return true;
}
}

View file

@ -31,7 +31,12 @@ namespace AcDream.App.Audio;
/// </description></item>
/// <item><description>
/// PCM buffer cache keyed by Wave dat id so the same footstep isn't
/// re-uploaded to the GL-equivalent AL buffers on every hit.
/// re-uploaded to the GL-equivalent AL buffers on every hit. Bounded
/// by a byte budget (<see cref="DefaultBufferByteBudget"/>) enforced
/// with LRU eviction — see <see cref="EvictBuffersOverBudget"/>. A
/// buffer still attached to a live source is never evicted (AL
/// rejects deleting a bound buffer); eviction re-queries live AL
/// source state rather than tracking a second copy of it.
/// </description></item>
/// </list>
/// </para>
@ -76,7 +81,15 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
private readonly uint[] _poolUi = new uint[PoolSizeUi];
// ── Buffer cache (Wave dat id → AL buffer) ───────────────────────────────
// Budget rationale: decoded PCM waves run ~100-500 KB each (same sizing
// as DatSoundCache's payload LRU, which this cache re-uploads from). 48
// MiB gives comfortable headroom for the working set of a play session
// (roughly 100-480 resident buffers) while keeping the native AL-side
// copy from growing without bound over a long-uptime process (the
// 30-bot headless-fleet target).
internal const long DefaultBufferByteBudget = 48L * 1024 * 1024; // 48 MiB
private readonly Dictionary<uint, uint> _bufferByWaveId = new();
private readonly AlBufferBudgetTracker _bufferBudget = new(DefaultBufferByteBudget);
// ── Ambient handles (StartAmbient/StopAmbient) ───────────────────────────
private readonly Dictionary<int, uint> _ambientSources = new();
@ -89,6 +102,12 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
public float AmbientVolume{ get; set; } = 0.8f;
public bool IsAvailable => _available;
/// <summary>Estimated bytes currently resident in the AL buffer cache. Diagnostic use only.</summary>
public long ResidentBufferBytes => _bufferBudget.ResidentBytes;
/// <summary>Number of AL buffers currently resident. Diagnostic use only.</summary>
public int ResidentBufferCount => _bufferBudget.Count;
public OpenAlAudioEngine()
: this(new SilkOpenAlResourceApiFactory())
{
@ -335,7 +354,14 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
private uint EnsureBuffer(uint waveId, WaveData wave)
{
if (!_available || _al is null) return 0;
if (_bufferByWaveId.TryGetValue(waveId, out var existing)) return existing;
if (_bufferByWaveId.TryGetValue(waveId, out var existing))
{
// Buffer id 0 is the "unsupported format" negative marker — no
// payload, not tracked by the budget, nothing to touch.
if (existing != 0)
_bufferBudget.Touch(waveId);
return existing;
}
uint buf = _al.GenBuffer();
_resources!.OwnBuffer(buf);
@ -351,9 +377,80 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
_al.BufferData(buf, fmt, p, wave.PcmBytes.Length, wave.SampleRate);
_bufferByWaveId[waveId] = buf;
_bufferBudget.RecordCreated(waveId, buf, wave.PcmBytes.Length);
// The buffer we just created is protected for this call: the
// caller hasn't attached it to a source yet, so live AL state
// would (wrongly) report it as evictable.
EvictBuffersOverBudget(protectedBufferId: buf);
return buf;
}
/// <summary>
/// Evict least-recently-used AL buffers until the resident-byte budget
/// is satisfied again. A buffer still bound to a live source (3D pool,
/// UI pool, or an ambient source) is protected — <c>alDeleteBuffers</c>
/// fails on a buffer that's still attached to a source — so eviction
/// never targets one; nor does it target <paramref name="protectedBufferId"/>,
/// the buffer <see cref="EnsureBuffer"/> just created for this call and
/// hasn't attached to a source yet. If every resident buffer is
/// protected the budget is temporarily exceeded rather than looping
/// forever; the bounded pool sizes (16 + 4 + ambient) cap how large
/// that overage can get. Evicted waves replay through
/// <see cref="EnsureBuffer"/> again on next use — re-upload from
/// <see cref="DatSoundCache"/>, identical to a first play.
/// </summary>
private void EvictBuffersOverBudget(uint protectedBufferId)
{
while (_bufferBudget.ResidentBytes > _bufferBudget.MaxBytes)
{
bool IsProtected(uint bufferId) =>
bufferId == protectedBufferId || IsBufferAttachedToAnySource(bufferId);
if (!_bufferBudget.TryEvictOldestUnprotected(
IsProtected, out uint evictedWaveId, out uint evictedBufferId))
{
break;
}
_bufferByWaveId.Remove(evictedWaveId);
_resources!.ReleaseBuffer(evictedBufferId);
}
}
/// <summary>
/// True when <paramref name="bufferId"/> is currently bound to any pool
/// source. Queried live from AL (<c>AL_BUFFER</c> on each source)
/// rather than tracked locally: AL's per-source state is the only
/// thing that actually determines whether <c>alDeleteBuffers</c> would
/// fail, and several call sites (<see cref="Play3DWave"/>,
/// <see cref="PlayUiWave"/>) set a source's buffer directly, so a
/// second local copy would be one more place to keep in sync.
/// </summary>
private bool IsBufferAttachedToAnySource(uint bufferId)
{
if (_al is null) return false;
for (int i = 0; i < PoolSize3D; i++)
{
if (IsSourceBoundTo(_pool3D[i].SourceId, bufferId)) return true;
}
for (int i = 0; i < PoolSizeUi; i++)
{
if (IsSourceBoundTo(_poolUi[i], bufferId)) return true;
}
foreach (uint sourceId in _ambientSources.Values)
{
if (IsSourceBoundTo(sourceId, bufferId)) return true;
}
return false;
}
private bool IsSourceBoundTo(uint sourceId, uint bufferId)
{
_al!.GetSourceProperty(sourceId, GetSourceInteger.Buffer, out int attached);
return (uint)attached == bufferId;
}
private static BufferFormat PickFormat(WaveData w)
{
return (w.ChannelCount, w.BitsPerSample) switch