Bump to v3.0.2: fix slow native-memory leak on the receive side

Bug: Andre reported audio latency feeling laggier after long sessions
on his Win10 desktop receiving Opus from his laptop. His 23-hour log
showed working set climbing from 83 MB at startup to 3.5 GB at the
end, with the managed heap staying tiny (~5-7 MB) the whole time.
CPU climbed alongside (from steady-state ~7% mid-session to peaks of
~60% by the end) and audio threads ended up doing ~4x the work they
did at the start. Andre's perception of latency drift was the CPU
pressure showing up in audio scheduling, not the buffer itself
growing (bufAvg stayed roughly stable at 25-28 ms).

Root cause: Concentus.Native (introduced in v2.2 / shipped in v3.0)
returns concrete NativeOpusDecoder / NativeOpusEncoder objects that
implement IDisposable and own native libopus state. Three call sites
were taking the IOpusDecoder / IOpusEncoder interface reference and
never calling Dispose:

  * StreamSession.Dispose — comment literally said "IOpusDecoder has
    no Dispose; nothing else to free", which was correct for the
    pure-managed Concentus.OpusDecoder pre-v2.2 but stopped being
    correct the moment we added the native binding
  * OpusEncoderState.Dispose — same misleading comment, same bug
  * SenderLane.OnCodecChanged — overwrote the existing encoder field
    without disposing the old instance on codec change

Compounding factor: Program.Main sets GCSettings.LatencyMode =
GCLatencyMode.SustainedLowLatency to keep audio scheduling smooth
(it suppresses gen2 collections). That's correct for the hot path
but it ALSO suppresses the finalizer pass that would have released
the leaked native handles as a backstop. Because the managed heap
stayed tiny, the GC never saw enough pressure to force a gen2 pass
on its own, and the native state piled up indefinitely. Multi-output
receive multiplied the per-output growth.

Fix is in two parts:

1. Call (... as IDisposable)?.Dispose() at every release point —
   StreamSession.Dispose, OpusEncoderState.Dispose,
   SenderLane.OnCodecChanged before overwrite, AudioRecorder's
   Concentus.Oggfile-backed OpusOggFileWriter.Dispose. The
   as-IDisposable cast handles both the native and the pure-managed
   path transparently (managed-only IOpusDecoder isn't IDisposable;
   the as-cast yields null and the null-conditional is a no-op).

2. Periodic native-memory reaper in MainForm.SnapshotLogIfDue — once
   every 300 snapshot ticks (~5 min), run
   GC.Collect(2, Optimized, blocking, !compacting) +
   WaitForPendingFinalizers on a background Task.Run so the gen2
   work doesn't hitch the UI thread. Audio threads are separate and
   unaffected. Serves as belt-and-braces for any future code path we
   forget to wire and for cleaning up any per-call native scratch
   the underlying library might accumulate that isn't owned by a
   single .NET wrapper.

Expected behaviour after fix: working set settles around 100-200 MB
on a typical receive session and holds roughly flat for as long as
the app stays running. CPU stays at its early-session baseline
across multi-hour sessions. Andre's "latency drift" symptom should
disappear.

Wire format unchanged; same codec list, same UI, same defaults.
v3.0.2 talks to other v3.0.x peers exactly as v3.0 / v3.0.1 do.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-05-27 23:16:51 +01:00
co-authored by Claude Opus 4.7
parent d904bafe73
commit 8aa8d0c3bd
8 changed files with 135 additions and 16 deletions
+16 -1
View File
@@ -179,7 +179,22 @@ internal sealed class StreamSession : IDisposable
}
}
public void Dispose() { /* IOpusDecoder has no Dispose; nothing else to free */ }
public void Dispose()
{
// 2026-05-27 — the comment that used to live here said "IOpusDecoder has no Dispose"
// and that was true for the pure-managed Concentus.OpusDecoder we used pre-v2.2.
// After Concentus.Native was wired in (v2.2 / shipped in v3.0), the concrete decoder
// returned by OpusCodecFactory.CreateDecoder is the native-backed NativeOpusDecoder,
// which IS IDisposable and owns native libopus state. Not calling Dispose here meant
// the native state only released when the GC eventually finalized the wrapper —
// which never happened in practice because we set GCSettings.SustainedLowLatency
// (see Program.Main). Andre's 23-hour receive session showed the resulting working-
// set climb (83 MB → 3.5 GB). The cast-to-IDisposable handles both the native and
// the pure-managed path transparently — if the concrete type doesn't implement
// IDisposable, the as-cast yields null and the null-conditional is a no-op.
(opusDecoder as IDisposable)?.Dispose();
opusDecoder = null;
}
// === PCM ===