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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user