Release v4.8: fix rare crash from endpoint thrash on a dual-address peer (#16)

The same singer hit a rare crash: their transmitter (COMP3) was reachable at two
addresses at once — the VPN address 10.8.0.1 they chose and that machine's wireless
192.168.3.245 — and the discovery-driven endpoint-follow ping-ponged the connection
between the two (the log shows four moves in 58 ms) right where the process died with
no shutdown line, no managed exception, no dialog: a hard crash from the receiver
audio-session teardown/rebuild churn the thrash caused.

Fix: the follow loop now never moves off an endpoint that's still answering heartbeats,
only follows once the current one has been unreachable for a sustained grace period
(6 s), only to an address that is itself answering, and never more than once per cooldown
(15 s) — so it can't thrash, and a peer reached on a working address stays put (honours
the singer's "just stay on 10.8.0.1"). New endpointUnreachableSinceUtc + lastEndpointMoveUtc
state; genuine DHCP/network moves are still followed a few seconds later.

Also: a global crash handler (Program.WriteCrashReport on AppDomain.UnhandledException +
TaskScheduler.UnobservedTaskException) writes a crash-*.txt into the logs folder, so a
future "RemSound just vanished" report leaves a stack behind. Removed a stale doc comment
left over from the v4.7 adoption removal. Manual gains a "RemSound closed unexpectedly"
troubleshooting entry. Version 4.8; About + RELEASE_NOTES updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-06-27 22:57:49 +01:00
co-authored by Claude Opus 4.8
parent 64d5cb5a86
commit 0df2a576fc
7 changed files with 119 additions and 57 deletions
+20
View File
@@ -20,6 +20,26 @@ internal sealed class AboutDialog : Form
/// updates" path.</summary>
private const string ReleaseNotes =
"""
RemSound v4.8
A rare crash fixed, and crash reports for next
time.
If a peer was reachable at two addresses at once
for example over a VPN and the local network at
the same time RemSound could rapidly flip the
connection between the two, and in a fast enough
flip it could close unexpectedly. It now settles
on whichever address is working and stays there,
so that flipping (and the crash it could cause)
is gone.
And if RemSound ever does close unexpectedly, it
now writes a small crash file into your logs
folder. There's nothing for you to do with it,
but it means a problem that used to leave no
trace can now be sent in and pinned down.
RemSound v4.7
Sound now comes back on its own after a reboot.
+49 -33
View File
@@ -382,6 +382,17 @@ public sealed class MainForm : Form
// discovery has temporarily lost sight of them ("Foo (192.168.1.5) — offline").
private readonly Dictionary<Guid, string> selectedPeerLabels = [];
// Anti-thrash state for the discovery-driven endpoint follow (see the peer-rebuild loop). A peer
// reachable at two addresses at once (a VPN address AND a LAN address, say) announces from both,
// and discovery reports whichever it heard last; following that blindly made the tracked endpoint
// ping-pong between the two, and a fast ping-pong tore the receiver's audio session down and back
// up quickly enough to crash the app (#16). A follow now needs the current endpoint to have been
// unreachable for a sustained spell and can't fire more than once per cooldown. Keyed by peer id.
private readonly Dictionary<Guid, DateTime> endpointUnreachableSinceUtc = [];
private readonly Dictionary<Guid, DateTime> lastEndpointMoveUtc = [];
private static readonly TimeSpan EndpointMoveUnreachableGrace = TimeSpan.FromSeconds(6);
private static readonly TimeSpan EndpointMoveCooldown = TimeSpan.FromSeconds(15);
private readonly Dictionary<CheckedListBox, int> lastFocusedListIndices = [];
private readonly System.Windows.Forms.Timer statusTimer = new() { Interval = 1000 };
@@ -5260,30 +5271,48 @@ public sealed class MainForm : Form
knownPeers.Clear();
foreach (var peer in byEndpoint.Values) knownPeers[peer.InstanceId] = peer;
// If a selected peer's announced address changed (DHCP renewal, network switch),
// update the cached endpoint so the sender follows the new IP — BUT only when the
// endpoint we're currently using has actually stopped working.
//
// Why the guard: a peer reachable at two addresses at once — e.g. a VPN address AND a
// LAN address — announces itself from both, and discovery reports whichever it heard
// last. Blindly following that made the tracked endpoint ping-pong between the two
// every couple of seconds. Because this one endpoint feeds the audio sender, the
// heartbeat, AND the receiver's allow-list, the ping-pong meant a chunk of audio was
// aimed at — or accepted only from — an address that doesn't actually reach the peer,
// heard as heavy crackle (Tech Singer's Win7-over-VPN report, 2026-05-31). Keeping the
// endpoint pinned while it's still passing heartbeats stops the thrash. A genuine move
// (DHCP renewal, Wi-Fi switch) makes the old endpoint go unreachable first, at which
// point the guard lets the move through.
// If a selected peer's announced address changed (DHCP renewal, network switch), follow it to
// the new address — but conservatively. A peer reachable at two addresses at once (e.g. a VPN
// address AND a LAN address) announces from both, and discovery reports whichever it heard
// last. Following that blindly made the tracked endpoint ping-pong between the two; because
// this one endpoint feeds the audio sender, the heartbeat AND the receiver's allow-list, the
// churn was heard as crackle (Tech Singer's Win7-over-VPN report, 2026-05-31) and, when it
// thrashed fast enough, tore the receiver's audio session down and back up quickly enough to
// crash the app (#16, same singer). So: never move off an address that's still answering
// heartbeats; only follow once the current one has been unreachable for a sustained spell,
// only TO an address that is itself answering, and never more than once per cooldown. Net
// effect — a peer you reach on a working address stays put; a genuine move (the old address
// really went away) is still followed a few seconds later.
var nowUtc = DateTime.UtcNow;
foreach (var (id, oldEndpoint) in selectedPeerEndpoints.ToList())
{
if (!knownPeers.TryGetValue(id, out var peer)) continue;
var newEndpoint = new IPEndPoint(peer.Address, peer.AudioPort);
if (!newEndpoint.Equals(oldEndpoint) && !IsEndpointHeartbeatHealthy(oldEndpoint))
{
selectedPeerEndpoints[id] = newEndpoint;
logFile.Event($"peer {peer.Name} endpoint moved {oldEndpoint} -> {newEndpoint} (old endpoint not healthy)");
}
selectedPeerLabels[id] = peer.Name;
var newEndpoint = new IPEndPoint(peer.Address, peer.AudioPort);
// Same address, or the one we're on is still healthy: nothing to do — and reset the
// unreachable-since clock so a brief future blip starts counting from zero.
if (newEndpoint.Equals(oldEndpoint) || IsEndpointHeartbeatHealthy(oldEndpoint))
{
endpointUnreachableSinceUtc.Remove(id);
continue;
}
// The current endpoint isn't answering. Start (or read) its unreachable-since clock, and
// don't act on the very first unhealthy tick — wait out the grace period.
if (!endpointUnreachableSinceUtc.TryGetValue(id, out var downSince))
{
endpointUnreachableSinceUtc[id] = nowUtc;
continue;
}
if (nowUtc - downSince < EndpointMoveUnreachableGrace) continue; // not down long enough yet
if (!IsEndpointHeartbeatHealthy(newEndpoint)) continue; // don't chase a dead address
if (lastEndpointMoveUtc.TryGetValue(id, out var lastMove)
&& nowUtc - lastMove < EndpointMoveCooldown) continue; // anti-thrash cooldown
selectedPeerEndpoints[id] = newEndpoint;
lastEndpointMoveUtc[id] = nowUtc;
endpointUnreachableSinceUtc.Remove(id);
logFile.Event($"peer {peer.Name} endpoint moved {oldEndpoint} -> {newEndpoint} (old endpoint unreachable {(int)(nowUtc - downSince).TotalSeconds}s)");
}
// Endpoints may have moved (DHCP/announcement-update path above) or selections may have
@@ -5349,19 +5378,6 @@ public sealed class MainForm : Form
return false;
}
/// <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>
/// <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
+31
View File
@@ -12,6 +12,27 @@ internal static class Program
// window. volatile for cross-thread visibility; RestoreFromTray marshals to the UI thread.
private static volatile MainForm? activeMainForm;
// Writes an otherwise-fatal exception to a timestamped crash file in the logs folder, so a
// "RemSound just disappeared, no dialog" report (#16) leaves a stack behind to diagnose instead
// of nothing. Best-effort and self-contained — a crash handler must never throw.
private static void WriteCrashReport(string source, Exception? ex)
{
try
{
var dir = AppConfig.LogsDirectory;
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, $"crash-{DateTime.Now:yyyyMMdd-HHmmss-fff}.txt");
var report =
$"RemSound crash report{Environment.NewLine}" +
$"Version: {typeof(Program).Assembly.GetName().Version}{Environment.NewLine}" +
$"Time: {DateTime.Now:O}{Environment.NewLine}" +
$"Source: {source}{Environment.NewLine}{Environment.NewLine}" +
(ex?.ToString() ?? "(no exception object)");
File.WriteAllText(path, report);
}
catch { /* a crash handler must never throw */ }
}
[STAThread]
private static void Main(string[] args)
{
@@ -25,6 +46,16 @@ internal static class Program
return;
}
// Capture otherwise-fatal background-thread exceptions to a crash file, so a "RemSound just
// vanished with no dialog" report (#16) leaves a stack behind instead of nothing. Best-effort.
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
WriteCrashReport("AppDomain.UnhandledException", e.ExceptionObject as Exception);
TaskScheduler.UnobservedTaskException += (_, e) =>
{
WriteCrashReport("TaskScheduler.UnobservedTaskException", e.Exception);
e.SetObserved();
};
// --config-dir <folder> (test / portable isolation): redirect ALL user state - config,
// profiles, logs, cue sounds - to an explicit folder for THIS process only. Applied first,
// before the layout migration and sound consolidation below read or write the default
+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>4.7</Version>
<Version>4.8</Version>
</PropertyGroup>
<ItemGroup>