v5.2: stability and polish — deep-audit bug fixes + install-flow fixes

Verified findings from a multi-dimension code audit, plus the two install-flow bugs:
- Fix Opus encoder use-after-free on a codec/rate change while streaming (guard swap vs encode).
- Fix "both" single-file recording dropping audio + drifting (drain both directions in lockstep).
- Fix broken clip counter, UPnP teardown on exit, auto-update-restart foreground grant, and a
  malformed-Opus-format packet orphaning a playout session forever.
- Post-install relaunch now respects start-minimised; uninstall is path-aware so it won't clear a
  different copy's run-at-startup.
- Perf/hygiene: cache AppConfig off UI hot paths, fold per-peer EQ+gain into one pass, deterministic
  disposal (tray menu, timers, COM shortcut, Process handles, process meter), ring-buffer overflow
  guard, receiver session-lock fix, remote-control allow-list moved onto the UI thread.
- Remove dead code (two IsAsioBackend, SessionPlayout.Reset, IsSameEndpoint, RemSoundUpdater
  IDisposable); several stale-doc fixes.

Deferred (not in this release): drift-estimator tweak, peer-discovery pruning, uninstall retry-loop,
encryption nonce. Wire format unchanged (interops v3.3-v5.1). Version -> 5.2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-09 20:54:53 +01:00
co-authored by Claude Opus 4.8
parent 7038fef67c
commit 2fb9274a95
28 changed files with 408 additions and 198 deletions
+23 -5
View File
@@ -176,8 +176,6 @@ public sealed class AudioReceiver : IDisposable
if (wasRunning) multiOutput.Start();
}
public bool IsAsioBackend => multiOutput is CompositeRenderBackend;
/// <summary>Sets the Buffer-smoothness knob (1 = aggressive — clicks the buffer back
/// to target on any drift, holds the user's latency tightly; 10 = smooth — no clicks
/// but the queue can creep up under jitter or sustained clock drift). Knob drives a
@@ -691,8 +689,14 @@ public sealed class AudioReceiver : IDisposable
Interlocked.Exchange(ref bytesReceived, 0);
Interlocked.Exchange(ref packetsDropped, 0);
// Tear down any sessions left over from a previous Start (in case Stop wasn't called).
DisposeAllSessionsLocked();
// Tear down any sessions left over from a previous Start (in case Stop wasn't called). Under
// sessionsLock to match the method's "Locked" contract and its other two callers — the counter
// getters / prune run on the App's snapshot-tick thread and touch `sessions` under this lock, so
// clearing it bare would be an unsynchronised mutation of the non-thread-safe dictionary.
lock (sessionsLock)
{
DisposeAllSessionsLocked();
}
playoutEngine.ResetAll();
listener.Start(udpPort);
@@ -1073,7 +1077,21 @@ public sealed class AudioReceiver : IDisposable
isFormatChange = true;
}
newSession = new StreamSession(remote, streamId, format, sp, diagnostics, _ => sp.NoteFramesQueued(playoutEngine.TargetLatencyMs), decryptor);
try
{
newSession = new StreamSession(remote, streamId, format, sp, diagnostics, _ => sp.NoteFramesQueued(playoutEngine.TargetLatencyMs), decryptor);
}
catch
{
// The StreamSession ctor failed — most realistically because a corrupt/hostile Format
// announced an Opus sample rate/channel count the decoder rejects. We already registered
// the SessionPlayout in PlayoutEngine via GetOrCreateSession above; nothing was added to
// `sessions`, so PruneIdleSessions would never reap it and it would linger forever, summed
// on every render callback and poisoning the auto-tune's underrun stats. Reap it here,
// then let the exception propagate so the packet handler still logs it.
playoutEngine.RemoveSession(remote, streamId);
throw;
}
sessions[key] = newSession;
// Same-lane streamId rotation: drop other sessions from this peer that share the
+6 -3
View File
@@ -10,9 +10,12 @@ namespace RemSound.Receiver;
/// Hands raw packets (byte buffer + length + remote endpoint) up to a callback supplied by the
/// owner — has no idea what's inside the packets.
///
/// Allocation-free in steady state: one fixed receive buffer reused across calls,
/// <see cref="Socket.ReceiveFrom"/> with <see cref="SocketAddress"/> avoids the per-call
/// IPEndPoint boxing that <see cref="UdpClient.ReceiveAsync"/> incurred.
/// The receive buffer is a single fixed array reused across calls. NOTE: the current
/// <see cref="Socket.ReceiveFrom(byte[], int, int, SocketFlags, ref EndPoint)"/> overload still
/// allocates a SocketAddress + a fresh IPEndPoint per datagram — tiny (tens of bytes) and dwarfed
/// by decode/mix work, but not literally allocation-free. A future optimisation could switch to the
/// Span/SocketAddress overload with a cached SocketAddress and only materialise an IPEndPoint when a
/// new session actually opens.
/// </summary>
internal sealed class NetworkListener : IDisposable
{
+30 -9
View File
@@ -110,20 +110,41 @@ public sealed class PeerDspChain
int n = left.Length;
if (n > 0)
{
for (int f = 0; f < frames; f++)
// Fold the post-EQ gain into the EQ loop's final store, so a peer with both EQ and non-unity
// gain walks the block ONCE on the render thread instead of twice (branch hoisted out of the
// per-frame loop). n==0 + gain-only keeps its own single pass below.
if (hasGain)
{
float sl = output[2 * f];
float sr = output[2 * f + 1];
for (int b = 0; b < n; b++)
for (int f = 0; f < frames; f++)
{
sl = left[b].Transform(sl);
sr = right[b].Transform(sr);
float sl = output[2 * f];
float sr = output[2 * f + 1];
for (int b = 0; b < n; b++)
{
sl = left[b].Transform(sl);
sr = right[b].Transform(sr);
}
output[2 * f] = sl * gainL;
output[2 * f + 1] = sr * gainR;
}
}
else
{
for (int f = 0; f < frames; f++)
{
float sl = output[2 * f];
float sr = output[2 * f + 1];
for (int b = 0; b < n; b++)
{
sl = left[b].Transform(sl);
sr = right[b].Transform(sr);
}
output[2 * f] = sl;
output[2 * f + 1] = sr;
}
output[2 * f] = sl;
output[2 * f + 1] = sr;
}
}
if (hasGain)
else if (hasGain)
{
for (int f = 0; f < frames; f++)
{
+9 -9
View File
@@ -657,15 +657,15 @@ internal sealed class PlayoutEngine : IWaveProvider
/// <summary>
/// Render-side audio pull. Iterates every session regardless of lane tag and sums them
/// into one mixed bus. This is what every render backend (WasapiOnly, AsioOnly, classic
/// Both via the tee, and BothIndependent via the tee) reads from, so a user can pick any
/// output device for any received audio — independently of which capture technology the
/// sender used. Per-lane latency targets are still honoured: each session reads its own
/// route's TargetMs / MaxMs via <see cref="LatencyFor"/>, so the WASAPI-captured stream
/// can buffer at one latency and the ASIO-captured stream at another within the same
/// output mix. The lane-specific <see cref="WasapiLaneOutput"/> / <see cref="AsioLaneOutput"/>
/// surfaces are kept around for future per-route routing options but are not used by the
/// default render path (see <c>CompositeRenderBackend</c>). 2026-05-11 revision: previous
/// into one mixed bus. This is what the WasapiOnly / AsioOnly render path reads from, so a
/// user can pick any output device for any received audio — independently of which capture
/// technology the sender used. Per-lane latency targets are still honoured: each session
/// reads its own route's TargetMs / MaxMs via <see cref="LatencyFor"/>, so the WASAPI-captured
/// stream can buffer at one latency and the ASIO-captured stream at another within the same
/// output mix. In BothIndependent mode this all-sessions pull is NOT used: each lane reads its
/// own filtered surface (<see cref="WasapiLaneOutput"/> / <see cref="AsioLaneOutput"/>) directly
/// — see <c>CompositeRenderBackend</c> — so those surfaces are ACTIVE render sources, not
/// future-only. 2026-05-11 revision: previous
/// implementation filtered by route, which made it impossible to route a WASAPI-captured
/// stream onto an ASIO output (and vice versa) in BothIndependent mode — that broke a
/// long-standing cross-backend send/receive flow.
-30
View File
@@ -435,36 +435,6 @@ internal sealed class SessionPlayout : IDisposable
drainRequested = true;
}
/// <summary>Reset the buffer and per-session state. Used at start/stop. Arming will rebuild
/// from the next packets that arrive.</summary>
public void Reset()
{
playout.Reset();
playbackArmed = false;
largestWriteMs = 0;
inUnderrunConcealment = false;
consecutiveEmptyReads = 0;
lastConcealSampleL = 0f;
lastConcealSampleR = 0f;
filteredErrorFrames = 0;
prevDriftSampleTicks = 0;
trimGlideTargetMs = 0;
prevTrimGlideTicks = 0;
// Phase-4 drift resampler state. Reset counters and window state. Reset() on the
// resampler clears its internal filter delay line so a fresh session doesn't
// inherit phase from a prior one. SetRates back to 1:1 — we'll re-measure drift
// from scratch.
bytesWrittenForDriftEst = 0;
bytesReadOutputForDriftEst = 0;
resamplerWindowStartTicks = 0;
resamplerWindowStartBytesWritten = 0;
resamplerWindowStartBytesOutput = 0;
smoothedRateRatio = 1.0;
resamplerActivelyTracking = false;
driftResampler.Reset();
driftResampler.SetRates(MixSampleRate, MixSampleRate);
}
public void Dispose()
{
// AudioRingBuffer is managed; nothing to free explicitly. Method present for symmetry
-2
View File
@@ -114,8 +114,6 @@ internal sealed class StreamSession : IDisposable
&& Format.Channels == format.Channels
&& Format.FrameSamplesPerChannel == format.FrameSamplesPerChannel;
public bool IsSameEndpoint(IPEndPoint endpoint) => Endpoint.Equals(endpoint);
public bool HandleAudioPayload(uint sequence, ReadOnlySpan<byte> payload)
{
diagnostics.RecordPacketArrived();