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:
co-authored by
Claude Opus 4.7
parent
d904bafe73
commit
8aa8d0c3bd
@@ -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
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user