Bump to v1.6.0: peer-address recovery, reconnect crash, long-run memory/CPU leak
Three reliability fixes. Wire format and audio pipeline unchanged from v1.4 / v1.5 — all interoperate. Peer address recovery: * When a tracked peer goes Unreachable (its resolved address — often a stale DNS / Pi-hole record, or a peer that rebooted onto a new IP) but the same peer is still heartbeat-pinging us from a different address, RemSound now adopts the live address instead of transmitting to a dead one. HeartbeatService records untracked ping sources; MainForm's TryAdoptLiveHeartbeatAddress (1 Hz) re-points the sender, heartbeat tracking and receiver allow-list. Conservative: fires only on the unambiguous one-unreachable-and-one-source case, private-range (RFC1918) addresses only so a relay can't hijack the sender, 10 s cooldown. Reconnect crash: * Fixed IndexOutOfRangeException in MainForm.SyncConnectedList. A churny peer-list rebuild (peer reboot) left SelectedIndex pointing past the rebuilt item array; the 1 Hz status timer read SelectedItem and crashed the app. New SafeSelectedItem bounds-checks the index; applied to all three timer-driven sync methods. The status tick is also wrapped in try/catch so a transient WinForms hiccup logs instead of crashing. Long-run memory / CPU leak: * A receiver left running for hours grew to gigabytes and climbing CPU. Decoder sessions orphaned by peer reconnects were not reaped — every reconnect mints a fresh (endpoint, streamId) key, and PruneIdleSessions silently skipped sessions whose PlayoutEngine lookup missed. Rewrote it to reap on each session's own LastWriteUtc (no cross-dictionary lookup), added a hard MaxLiveSessions cap as a backstop, and a "stream sessions live: N" diagnostic line. Bounds both memory and render-thread CPU. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
9ceff8bcc1
commit
918ca6cac0
@@ -44,6 +44,14 @@ public sealed class AudioReceiver : IDisposable
|
||||
/// though the mix output is unaffected).</summary>
|
||||
public static readonly TimeSpan SessionIdleTimeout = TimeSpan.FromSeconds(4);
|
||||
|
||||
/// <summary>Hard ceiling on concurrently-tracked stream sessions. A backstop for the idle
|
||||
/// prune: even if reconnect churn somehow outpaces the idle sweep, the session table — and
|
||||
/// the multi-MB playout ring each entry owns — can never grow without bound. Far above any
|
||||
/// legitimate scenario (the lobby relay caps peers at 10, and a peer emits at most one
|
||||
/// stream per render lane). When exceeded, <see cref="PruneIdleSessions"/> evicts the
|
||||
/// idlest sessions down to this cap. 2026-05-15.</summary>
|
||||
public const int MaxLiveSessions = 32;
|
||||
|
||||
private readonly Stopwatch uptime = new();
|
||||
private readonly ReceiverDiagnostics diagnostics = new();
|
||||
private readonly PlayoutEngine playoutEngine;
|
||||
@@ -57,6 +65,10 @@ public sealed class AudioReceiver : IDisposable
|
||||
// independent audio mode). For single-lane modes the sender emits one streamId so
|
||||
// the dict still has one entry per peer, identical to the pre-refactor behaviour.
|
||||
private readonly Dictionary<(IPEndPoint Endpoint, ushort StreamId), StreamSession> sessions = new();
|
||||
// Last live-session count emitted to the diagnostic sink. Lets PruneIdleSessions log
|
||||
// only when the count actually changes, so unbounded growth is visible in the log
|
||||
// without spamming it or needing a Task Manager screenshot to notice.
|
||||
private int lastLoggedSessionCount = -1;
|
||||
|
||||
/// <summary>When false (the default), a Format packet arriving with a NEW streamId from
|
||||
/// a peer that already has a session under a DIFFERENT streamId triggers immediate
|
||||
@@ -591,30 +603,54 @@ public sealed class AudioReceiver : IDisposable
|
||||
public void PruneIdleSessions()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
List<(IPEndPoint Endpoint, ushort StreamId)>? toRemove = null;
|
||||
lock (sessionsLock)
|
||||
{
|
||||
var toRemove = new HashSet<(IPEndPoint Endpoint, ushort StreamId)>();
|
||||
|
||||
// 1) Idle sweep — reap sessions with no decoded write within SessionIdleTimeout.
|
||||
// Reaped on the session's OWN last-write time. The previous implementation
|
||||
// cross-referenced PlayoutEngine.ActiveSessions by (endpoint, streamId) and
|
||||
// silently skipped — leaking the session forever — whenever that lookup missed.
|
||||
// A reconnecting peer never reuses an old key: a sender reboot rerolls the
|
||||
// streamId AND rebinds to a fresh ephemeral source port, so the old session is
|
||||
// always an orphan the lookup-based prune could strand. Reading the session's
|
||||
// own LastWriteUtc removes the lookup, the race, and the leak. 2026-05-15.
|
||||
foreach (var (key, session) in sessions)
|
||||
{
|
||||
// Match SessionPlayout by full key so two streams from the same peer don't
|
||||
// share a single SessionPlayout entry. ActiveSessions iteration is small
|
||||
// (one per active stream).
|
||||
var sp = playoutEngine.ActiveSessions.FirstOrDefault(x =>
|
||||
x.Endpoint.Equals(key.Endpoint) && x.StreamId == key.StreamId);
|
||||
if (sp is null) continue;
|
||||
if (now - sp.LastWriteUtc <= SessionIdleTimeout) continue;
|
||||
toRemove ??= [];
|
||||
toRemove.Add(key);
|
||||
if (now - session.LastWriteUtc > SessionIdleTimeout) toRemove.Add(key);
|
||||
}
|
||||
if (toRemove is not null)
|
||||
|
||||
// 2) Hard-cap backstop. If more than MaxLiveSessions would still remain after the
|
||||
// idle sweep, evict the idlest extras. Guarantees the session table can never
|
||||
// grow without bound whatever churn the idle sweep can't keep up with.
|
||||
var survivors = sessions.Count - toRemove.Count;
|
||||
if (survivors > MaxLiveSessions)
|
||||
{
|
||||
foreach (var key in toRemove)
|
||||
foreach (var key in sessions
|
||||
.Where(kv => !toRemove.Contains(kv.Key))
|
||||
.OrderBy(kv => kv.Value.LastWriteUtc)
|
||||
.Take(survivors - MaxLiveSessions)
|
||||
.Select(kv => kv.Key))
|
||||
{
|
||||
if (sessions.Remove(key, out var session)) session.Dispose();
|
||||
playoutEngine.RemoveSession(key.Endpoint, key.StreamId);
|
||||
diagnosticSink?.Invoke($"stream session pruned (idle): {key.Endpoint} stream={key.StreamId}");
|
||||
toRemove.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Apply removals — drop the StreamSession and its paired SessionPlayout together.
|
||||
foreach (var key in toRemove)
|
||||
{
|
||||
if (sessions.Remove(key, out var session)) session.Dispose();
|
||||
playoutEngine.RemoveSession(key.Endpoint, key.StreamId);
|
||||
diagnosticSink?.Invoke($"stream session pruned: {key.Endpoint} stream={key.StreamId}");
|
||||
}
|
||||
|
||||
// Surface the live-session count whenever it changes, so any accumulation is
|
||||
// visible in the log without summing open/prune events.
|
||||
if (sessions.Count != lastLoggedSessionCount)
|
||||
{
|
||||
lastLoggedSessionCount = sessions.Count;
|
||||
diagnosticSink?.Invoke($"stream sessions live: {sessions.Count}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,13 @@ internal sealed class StreamSession : IDisposable
|
||||
public AudioFormatInfo Format { get; }
|
||||
public AudioTransportCodec Codec => (AudioTransportCodec)Format.Codec;
|
||||
|
||||
/// <summary>UTC timestamp of the most recent decoded-audio write into this session's
|
||||
/// playout buffer. <see cref="AudioReceiver.PruneIdleSessions"/> reaps on this directly,
|
||||
/// rather than a cross-dictionary lookup into PlayoutEngine that could miss and strand
|
||||
/// the session forever — a reconnecting peer never reuses its old (endpoint, streamId)
|
||||
/// key, so its previous session is always an orphan that must be reaped by idle age.</summary>
|
||||
public DateTime LastWriteUtc => sessionPlayout.LastWriteUtc;
|
||||
|
||||
/// <summary>For PCM streams: number of incoming packets the assembler rejected outright.</summary>
|
||||
public long PcmFrameRejections => pcmAssembler.RejectionCount;
|
||||
/// <summary>For PCM streams: number of partially-assembled frames discarded mid-flight.</summary>
|
||||
|
||||
Reference in New Issue
Block a user