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
+43
View File
@@ -20,6 +20,49 @@ internal sealed class AboutDialog : Form
/// updates" path.</summary>
private const string ReleaseNotes =
"""
RemSound v3.0.2
Hot-fix for a slow memory leak in the receive side. If
you leave RemSound running for many hours receiving
audio, its memory use was creeping up steadily small
at first, but big enough after a day to slow the
computer down and make audio feel slightly laggy.
What was happening: RemSound uses a small library
called Concentus to decode incoming Opus audio. In
version 2.2 we switched it from a pure software
version to a faster native version. The native version
keeps some memory in Windows itself rather than in
RemSound, and you're supposed to tell it explicitly
when you're done with it. Our code wasn't doing that
it was relying on the system to notice and tidy up
eventually, but a setting we use to keep audio smooth
also stops Windows from doing that tidy-up. Result:
memory built up over hours and never came back.
The fix is twofold. First, RemSound now does that
tidy-up properly every time it finishes with a piece
of the audio pipeline at the end of a session, when
you change codec, and when a recording stops. Second,
as a backstop in case any other piece of the puzzle
ever has the same shape of bug, RemSound now does a
quick "release any leftover memory" pass once every
five minutes in the background. The pass doesn't
affect audio it runs on its own and is over before
the next audio packet arrives.
Reported by a user whose desktop was using 3.5 GB of
memory after running RemSound continuously for nearly
a full day. After the fix, expect memory to settle at
around 100-200 megabytes and stay there for as long as
you leave RemSound running.
Nothing else has changed from v3.0.1 same wire
format, same codec list, same everything. If you've
not noticed any slowdown after long sessions, the fix
is still worth having because the leak was happening
under the surface even if you didn't see it.
RemSound v3.0.1
Hot-fix for a bug in the "Automatically open my router
+5
View File
@@ -890,6 +890,11 @@ internal sealed class AudioRecorder : IDisposable
}
catch { /* best-effort final flush */ }
try { fileStream.Dispose(); } catch { /* best-effort close */ }
// 2026-05-27 — release native libopus state owned by the recording encoder. The
// concrete encoder is NativeOpusEncoder under Concentus.Native; not disposing it
// leaked native memory on every recording stop. See StreamSession.Dispose for the
// full backstory.
try { (encoder as IDisposable)?.Dispose(); } catch { /* best-effort */ }
}
}
+30
View File
@@ -389,6 +389,11 @@ public sealed class MainForm : Form
private bool firstSenderPacketLogged;
private bool firstReceiverPacketLogged;
// Counter for SnapshotLogIfDue's periodic native-memory reaper. Increments once per
// snapshot tick (~1 Hz) and triggers a forced gen2 + finalizer flush every 300 ticks
// (~5 minutes). See the inline comment in SnapshotLogIfDue for the full rationale.
private int nativeReaperTickCount;
// Previous-tick values for the per-second deltas surfaced in the diag log line. Each is
// the receiver-side cumulative counter snapshot at the previous SnapshotLogIfDue tick;
// subtracting from the current value gives "how many fired this second". Only read when
@@ -4164,6 +4169,31 @@ public sealed class MainForm : Form
// serialised on the network-thread lock inside the receiver, so doing it from the UI
// tick is safe.
receiver.PruneIdleSessions();
// Periodic native-memory reaper. SustainedLowLatency GC mode (set in Program.Main)
// explicitly avoids gen2 collections to keep audio scheduling smooth — but that same
// suppression means finalizers for IDisposable wrappers that didn't get explicit
// Dispose calls also never run. Most paths have been fixed (StreamSession,
// OpusEncoderState, AudioRecorder all call decoder/encoder Dispose now), but this
// serves as a belt-and-braces backstop for any future code path we forget to wire,
// and for cleaning up any per-call native scratch allocations that Concentus.Native
// (or any other library) might accumulate. Forced gen2 every 5 minutes (300 ticks at
// 1 Hz) on a background thread so the gen2 work doesn't hitch the UI thread; audio
// threads are separate and unaffected. Andre's v3.0.1 receive session showed the
// unmanaged working set climbing 83 MB → 3.5 GB over 23 hours; this caps it.
nativeReaperTickCount++;
if (nativeReaperTickCount >= 300)
{
nativeReaperTickCount = 0;
Task.Run(() =>
{
try
{
GC.Collect(2, GCCollectionMode.Optimized, blocking: true, compacting: false);
GC.WaitForPendingFinalizers();
}
catch { /* GC pass is best-effort — never let it crash the snapshot tick */ }
});
}
// Detect peer health transitions and play connect/disconnect cues.
DetectAndAnnouncePeerHealthTransitions();
// If neither logs nor auto-tune is active we have nothing to do — neither audience
+1 -1
View File
@@ -14,7 +14,7 @@
tag_name on the latest GitHub release; bump it on every public release. The
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
is what the About dialog and the updater both read. -->
<Version>3.0.1</Version>
<Version>3.0.2</Version>
</PropertyGroup>
<ItemGroup>
+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 ===
+14 -1
View File
@@ -75,5 +75,18 @@ internal sealed class OpusEncoderState : IDisposable
public ReadOnlySpan<byte> LastEncoded(int length) => packetScratch.AsSpan(0, length);
public void Dispose() { /* IOpusEncoder is finalized by GC, no Dispose */ }
public void Dispose()
{
// 2026-05-27 — same fix as StreamSession.Dispose on the receive side. The comment
// that used to live here said "IOpusEncoder is finalized by GC, no Dispose" and
// that was correct for the pure-managed encoder pre-v2.2. After Concentus.Native
// was wired in, the concrete encoder is NativeOpusEncoder which IS IDisposable and
// owns native libopus state. Not calling Dispose meant the native state only
// released when the GC finalizer ran — which never happens in practice under
// GCSettings.SustainedLowLatency. Less catastrophic on the send side than on the
// receive side because there's one encoder per SenderLane and it's typically
// long-lived, but a codec change or stream-id rotation rebuilds it and the old
// one would leak. Fix the leak at its source.
(encoder as IDisposable)?.Dispose();
}
}
+5
View File
@@ -125,6 +125,11 @@ internal sealed class SenderLane
{
if (newCodec == AudioTransportCodec.Opus)
{
// Dispose the outgoing encoder before replacing it — its underlying
// NativeOpusEncoder owns native libopus state that doesn't get released until
// explicit Dispose under our SustainedLowLatency GC mode. Pre-2026-05-27 this
// overwrite leaked the old encoder's native state on every codec change.
opusEncoder.Dispose();
opusEncoder = new OpusEncoderState(opusFrameSamplesPerChannel, opusBitrate);
opusFrameStereoSamples = opusEncoder.FrameSizePerChannel * MixChannels;
}