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:
Ednunp
2026-05-18 21:32:22 +01:00
co-authored by Claude Opus 4.7
parent 9ceff8bcc1
commit 918ca6cac0
8 changed files with 232 additions and 47 deletions
+32
View File
@@ -52,6 +52,13 @@ public sealed class HeartbeatService : IDisposable
private readonly object gate = new();
private readonly Dictionary<string, PeerState> peers = new(StringComparer.OrdinalIgnoreCase);
private readonly Stopwatch monotonic = Stopwatch.StartNew();
// Source addresses of recently-received heartbeat Pings, keyed by IP only (the ping's
// source port is the peer's ephemeral / NAT port, never the audio port — same reasoning
// as the pong IP-only match in HandlePacket). MainForm reads this via
// GetUntrackedPingSources to recover from a stale-address situation: a peer we can't
// reach at its resolved (e.g. stale-DNS) address but which is pinging us from its real
// address. See TryAdoptLiveHeartbeatAddress in MainForm. 2026-05-15.
private readonly Dictionary<IPAddress, DateTime> recentPingSources = new();
private CancellationTokenSource? cts;
private Task? sendTask;
@@ -153,6 +160,28 @@ public sealed class HeartbeatService : IDisposable
}
}
/// <summary>
/// Addresses that have sent us a heartbeat Ping within the last <see cref="UnreachableWindow"/>
/// and are NOT currently tracked peers. Used by MainForm's stale-address recovery: when a
/// tracked peer has gone Unreachable but some other address is actively pinging us, that
/// address is very likely the same peer at its real location (DNS handed us a stale IP).
/// Keyed by IP only — heartbeat ping source ports are ephemeral and carry no peer identity.
/// </summary>
public IReadOnlyList<IPAddress> GetUntrackedPingSources()
{
lock (gate)
{
var cutoff = DateTime.UtcNow - UnreachableWindow;
// Prune entries older than the window while we're holding the lock.
foreach (var stale in recentPingSources.Where(kv => kv.Value < cutoff).Select(kv => kv.Key).ToList())
{
recentPingSources.Remove(stale);
}
var trackedAddrs = peers.Values.Select(p => p.AudioEndpoint.Address).ToHashSet();
return recentPingSources.Keys.Where(a => !trackedAddrs.Contains(a)).ToList();
}
}
/// <summary>
/// One-line summary suitable for the snapshot log column or status label.
/// "no peers" / "192.168.1.5: 24ms" / "192.168.1.5: 24ms, 192.168.1.6: unreachable 7s".
@@ -279,6 +308,9 @@ public sealed class HeartbeatService : IDisposable
if (kind == HeartbeatKind.Ping)
{
onDiagnostic?.Invoke($"recv ping from={remote}");
// Record the source so MainForm can spot a peer that's pinging us from an
// address we're not tracking (stale-DNS / DHCP-move recovery).
lock (gate) { recentPingSources[remote.Address] = DateTime.UtcNow; }
// Echo the originator's timestamp back to them as a Pong. Reply target is the
// remote source endpoint (whatever socket the ping came in on, that's where to