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
+25
View File
@@ -20,6 +20,31 @@ internal sealed class AboutDialog : Form
/// updates" path.</summary>
private const string ReleaseNotes =
"""
RemSound v1.6
Three reliability fixes. No wire-format or audio-pipeline
changes v1.5 and v1.6 peers interoperate.
Bug fixes:
* Peer address recovery. If the address you connected
to goes unreachable a peer rebooted onto a new IP,
or a computer name resolved to a stale address
RemSound now follows the peer to the live address it
is heartbeating from, instead of sending audio into
the void. Recovers on its own within a few seconds.
Limited to private-network addresses so a relay can
never be mistaken for a moved peer.
* Fixed a crash that could happen when a peer
reconnected (e.g. after rebooting). The Connectivity
peer list could be read mid-rebuild with a stale
index and bring the app down from the status timer.
* Fixed runaway memory and CPU on a long-running
receiver. Decoder sessions orphaned by peer
reconnects were not being reclaimed over hours they
piled up, each holding a multi-megabyte buffer and
costing render-thread time every callback. They are
now reaped once idle, with a hard cap as a backstop.
RemSound v1.5
Menu reorganisation, multi-peer audio-routing fix, recording
+104 -10
View File
@@ -293,6 +293,9 @@ public sealed class MainForm : Form
private int continuousTuneIntervalSec = 5;
private long lastObservedUnderrunCount;
private HeartbeatService? heartbeatService;
// Last time TryAdoptLiveHeartbeatAddress re-pointed the sender at a peer's live address.
// Gives a fresh endpoint time to prove healthy before another swap can fire (anti-thrash).
private DateTime lastAddressAdoptionUtc = DateTime.MinValue;
// Tracks the most recent PeerHealthState we observed for each peer endpoint, so we can
// detect transitions and play the appropriate cue. Connect: any state → Healthy.
// Disconnect: any state → Unreachable. Stale doesn't fire (it's a transient).
@@ -866,13 +869,25 @@ public sealed class MainForm : Form
// --- Status / health ticker ---
statusTimer.Tick += (_, _) =>
{
UpdateStatus();
SnapshotLogIfDue();
EnsureRequestedAudioRunning();
// Refresh the Connectivity tab's peer lists from the same 1 Hz tick — replaces
// the dialog's old 1.5 s dedicated refresh timer. Each Sync* helper short-circuits
// when its signature is unchanged so NVDA isn't spammed with re-announcements.
SyncAllPeerLists();
// Belt-and-braces: this is a 1 Hz UI tick — a transient WinForms hiccup (e.g. a
// stale-index ItemArray throw during a churny peer-list rebuild) must never take
// the whole app down with a crash dialog. Log and ride it out; the next tick
// recovers. The individual Sync* methods are also hardened (see SafeSelectedItem).
try
{
UpdateStatus();
SnapshotLogIfDue();
EnsureRequestedAudioRunning();
TryAdoptLiveHeartbeatAddress();
// Refresh the Connectivity tab's peer lists from the same 1 Hz tick — replaces
// the dialog's old 1.5 s dedicated refresh timer. Each Sync* helper short-circuits
// when its signature is unchanged so NVDA isn't spammed with re-announcements.
SyncAllPeerLists();
}
catch (Exception ex)
{
AppendLogEntry($"status tick: {ex.GetType().Name}: {ex.Message}");
}
};
// --- Hot-swap device watcher ---
@@ -2307,6 +2322,20 @@ public sealed class MainForm : Form
return $"{(int)span.TotalHours} hour{((int)span.TotalHours == 1 ? "" : "s")} {span.Minutes} minute{(span.Minutes == 1 ? "" : "s")}";
}
/// <summary>
/// Reads <c>list.SelectedItem</c> without the IndexOutOfRangeException WinForms' internal
/// ItemArray throws when <c>SelectedIndex</c> is briefly left pointing past the item array.
/// That happens during churny peer-list rebuilds (peer reboots, rapid reconnects): the
/// 1 Hz Sync* tick read <c>SelectedItem</c> — whose getter blindly does Items[SelectedIndex]
/// — and crashed the whole app from a timer callback. Bounds-check the index ourselves,
/// the same defensive pattern the ItemCheck handlers already use. 2026-05-15.
/// </summary>
private static object? SafeSelectedItem(ListBox list)
{
var i = list.SelectedIndex;
return i >= 0 && i < list.Items.Count ? list.Items[i] : null;
}
private void SyncConnectedList()
{
var desired = new List<(PeerListItem Item, Guid Id)>();
@@ -2332,7 +2361,7 @@ public sealed class MainForm : Form
if (signature != lastConnectedListSignature)
{
lastConnectedListSignature = signature;
var selectedId = connectedPeersList.SelectedItem is PeerListItem si ? si.Peer.InstanceId : Guid.Empty;
var selectedId = SafeSelectedItem(connectedPeersList) is PeerListItem si ? si.Peer.InstanceId : Guid.Empty;
suppressConnectedCheck = true;
try
{
@@ -2408,7 +2437,7 @@ public sealed class MainForm : Form
if (signature == lastDiscoveredListSignature) return;
lastDiscoveredListSignature = signature;
var selectedId = discoveredPeersList.SelectedItem is PeerListItem si ? si.Peer.InstanceId : Guid.Empty;
var selectedId = SafeSelectedItem(discoveredPeersList) is PeerListItem si ? si.Peer.InstanceId : Guid.Empty;
suppressDiscoveredCheck = true;
try
{
@@ -2444,7 +2473,7 @@ public sealed class MainForm : Form
if (signature == lastRememberedListSignature) return;
lastRememberedListSignature = signature;
var selectedEntry = rememberedPeersList.SelectedItem is RememberedPeerItem si ? si.Entry : null;
var selectedEntry = SafeSelectedItem(rememberedPeersList) is RememberedPeerItem si ? si.Entry : null;
suppressRememberedCheck = true;
try
{
@@ -3321,6 +3350,71 @@ public sealed class MainForm : Form
receiver.SetAllowedSenders(SelectedSendEndpoints());
}
/// <summary>
/// Stale-address recovery. When exactly one tracked peer has gone Unreachable (its
/// resolved address — often a stale DNS answer — has no host behind it) and exactly one
/// OTHER address is actively heartbeat-pinging us, that address is almost certainly the
/// same peer at its real location. Re-point the audio sender, heartbeat tracking and the
/// receiver allow-list at the live address.
///
/// Deliberately conservative — it fires only on the unambiguous one-unreachable-and-one-
/// live case, only for private-range (RFC1918) live addresses (so a relay's public source
/// address can never hijack the sender), and with a 10 s cooldown so it can't thrash. The
/// messier multi-peer case is left for the user to sort out by hand. Runs once per second
/// from the status ticker. 2026-05-15.
/// </summary>
private void TryAdoptLiveHeartbeatAddress()
{
if (heartbeatService is null || !connected) return;
// Cooldown: adoption re-points the sender; give a freshly-adopted endpoint time to
// prove healthy (or fail) before another swap can fire.
if (DateTime.UtcNow - lastAddressAdoptionUtc < TimeSpan.FromSeconds(10)) return;
var unreachable = heartbeatService.GetAllPeerHealth()
.Where(h => h.State == PeerHealthState.Unreachable)
.ToList();
if (unreachable.Count != 1) return; // 0 = nothing wrong; 2+ = ambiguous
var liveSources = heartbeatService.GetUntrackedPingSources();
if (liveSources.Count != 1) return; // 0 = no candidate; 2+ = ambiguous
var deadEp = unreachable[0].AudioEndpoint;
var liveAddr = liveSources[0];
if (liveAddr.Equals(deadEp.Address)) return; // same machine — nothing to adopt
if (!IsPrivateLanAddress(liveAddr)) return; // never adopt a public / relay source
// Find the selected-peer entry whose endpoint is the dead one.
var match = selectedPeerEndpoints
.FirstOrDefault(kv => kv.Value.Address.Equals(deadEp.Address) && kv.Value.Port == deadEp.Port);
if (match.Key == Guid.Empty) return;
// Reuse the dead endpoint's port — a peer that moved on the LAN keeps its audio port.
var newEp = new IPEndPoint(liveAddr, deadEp.Port);
selectedPeerEndpoints[match.Key] = newEp;
var label = selectedPeerLabels.GetValueOrDefault(match.Key, deadEp.Address.ToString());
logFile.Event($"heartbeat: adopted live address for \"{label}\": {deadEp} unreachable, peer is pinging from {newEp}");
lastAddressAdoptionUtc = DateTime.UtcNow;
// ApplyAudioRuntime re-points BOTH the audio sender (SetReceivers) and heartbeat
// tracking (SetTrackedPeers); PushAllowedReceiveSenders re-points the receiver
// allow-list; PushDiscoveryUnicastHints feeds the new address to discovery too.
ApplyAudioRuntime();
PushAllowedReceiveSenders();
PushDiscoveryUnicastHints();
}
/// <summary>True if <paramref name="addr"/> is an IPv4 RFC1918 private-range address
/// (10/8, 172.16/12, 192.168/16). Gates stale-address adoption so a relay's public
/// source address can never be mistaken for a peer that moved on the LAN.</summary>
private static bool IsPrivateLanAddress(IPAddress addr)
{
if (addr.AddressFamily != AddressFamily.InterNetwork) return false;
var b = addr.GetAddressBytes();
return b[0] == 10
|| (b[0] == 172 && b[1] >= 16 && b[1] <= 31)
|| (b[0] == 192 && b[1] == 168);
}
/// <summary>
/// Wipes the rolling max-gap window and pushes <see cref="lastSourceChangeUtc"/> forward,
/// so the next continuous auto-tune tick has nothing to react to. Called whenever a user
+1 -1
View File
@@ -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>1.5.0</Version>
<Version>1.6.0</Version>
</PropertyGroup>
<ItemGroup>