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
+21
-13
@@ -1,32 +1,40 @@
|
||||
# RemSound v3.0.1
|
||||
# RemSound v3.0.2
|
||||
|
||||
Hot-fix for a bug in the **"Automatically open my router for incoming connections (UPnP)"** tickbox in Preferences.
|
||||
Hot-fix for a slow memory leak on the receiving side. If you leave RemSound running for many hours receiving audio, its memory use was creeping up over time. Small at first, big enough after a day to slow the computer down and make audio feel slightly laggy.
|
||||
|
||||
## What was broken
|
||||
## What was happening
|
||||
|
||||
On some network setups — machines with several network adapters, a VPN connected, or a router that doesn't answer the way RemSound's UPnP library expects — ticking the UPnP box could freeze RemSound's window. Audio kept flowing (so peers stayed connected), but you couldn't open the window again, the system-tray hotkey stopped responding, and the only way out was to end the RemSound process from Task Manager.
|
||||
RemSound uses a small library called Concentus to decode incoming Opus audio. In version 2.2 we switched from a pure software version of that library to a faster native one. 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.
|
||||
|
||||
## Why it happened
|
||||
So memory built up steadily over hours and never came back. A user left RemSound running for nearly a full day receiving audio and his desktop ended up using 3.5 gigabytes of memory before he restarted it. That was getting in the way of everything else on the computer and was almost certainly the cause of a feeling that audio latency was "drifting" over a long session — at that point the CPU was working hard enough that audio scheduling wasn't as tight as it should be.
|
||||
|
||||
RemSound was running the router-discovery step on the same thread that draws the window, so when discovery couldn't get a quick answer from the router it blocked the window until it finished — which on some networks meant forever. v3.0.1 moves that work off to a background thread, so the window stays responsive while RemSound looks for the router. The live status label in Preferences still updates as discovery progresses — that was already on the right thread.
|
||||
## What's fixed
|
||||
|
||||
The same fix is applied to the three places UPnP can kick off: at app startup (when you have UPnP ticked from a previous session), when you tick the box in Preferences, and after a sleep/resume (when RemSound re-pokes the router in case the mapping was dropped).
|
||||
Two things. First, the part of RemSound that uses Concentus now properly releases its memory at the right moments — when a session ends, when you change codec, and when a recording stops. That stops the leak at its source.
|
||||
|
||||
## No other changes
|
||||
Second, as a safety net in case anything else in RemSound or any other library we use ever has the same kind of bug, RemSound now does a quick "release any leftover memory" pass once every five minutes. The pass runs in the background, doesn't affect audio in any way, and finishes long before the next audio packet arrives.
|
||||
|
||||
Same wire format as v3.0, same codec list, same everything else. If you were already running v3.0 happily without UPnP turned on, this update fixes a problem you may not have hit — install it at your leisure.
|
||||
## What to expect
|
||||
|
||||
After updating, your memory use should settle at around 100-200 megabytes and stay there as long as you leave RemSound running. You can leave it running overnight, or for days, and the memory should hold roughly flat instead of climbing.
|
||||
|
||||
If you noticed audio feeling slightly laggier after long sessions and had been working around it by restarting RemSound, that workaround should no longer be needed.
|
||||
|
||||
## Nothing else has changed
|
||||
|
||||
Same wire format as v3.0 and v3.0.1, same codec list, same everything else. v3.0.2 talks to other v3.0.x machines exactly as before. 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.
|
||||
|
||||
## Install
|
||||
|
||||
1. Download `RemSound-v3.0.1.zip` from this release.
|
||||
1. Download `RemSound-v3.0.2.zip` from this release.
|
||||
2. Close RemSound.
|
||||
3. Extract the zip **over your existing RemSound folder**, overwriting program files when prompted. The zip is program files only — it will not touch your profiles, settings or recordings.
|
||||
3. Extract the zip over your existing RemSound folder, overwriting program files when prompted. The zip is program files only — it will not touch your profiles, settings or recordings.
|
||||
4. Run `RemSound.exe`. Press F1 for the user manual.
|
||||
|
||||
Requires the .NET 10 Desktop Runtime. If it's missing, Windows offers to fetch it on first launch.
|
||||
|
||||
## Upgrading
|
||||
|
||||
**v1.9, v2.0, v2.1, v3.0:** Help → Check for updates works — it will fetch and install v3.0.1 automatically. If you've ticked "Check for updates on startup" and "Silently install updates", v3.0.1 installs itself shortly after launch with a brief notice; RemSound then reopens on whichever profile you were running (new in v3.0).
|
||||
**v1.9, v2.0, v2.1, v3.0, v3.0.1:** Help → Check for updates works — it will fetch and install v3.0.2 automatically. If you've ticked "Check for updates on startup" and "Silently install updates", v3.0.2 installs itself shortly after launch and RemSound reopens on whichever profile you were running (a feature added in v3.0).
|
||||
|
||||
**v1.8 and earlier:** the auto-updater in those versions has a fault that prevents it from installing updates, so Check for updates will download v3.0.1 but not apply it. Install v3.0.1 by hand using the steps above — just this once. From the build you install onward, updates are automatic.
|
||||
**v1.8 and earlier:** the auto-updater in those versions has a fault that prevents it from installing updates, so Check for updates will download v3.0.2 but not apply it. Install v3.0.2 by hand using the steps above — just this once. From the build you install onward, updates are automatic.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user