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
122
src/AcDream.App/Audio/AlBufferBudgetTracker.cs
Normal file
122
src/AcDream.App/Audio/AlBufferBudgetTracker.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue