diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index 2b8d089..7bdae8e 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -5888,25 +5888,12 @@ public sealed class MainForm : Form // Carve-out: a peer we are actively RECEIVING audio from stays armed even if its heartbeat // reads Unreachable — that covers an asymmetric path where audio flows but the heartbeat // round-trip doesn't, so a working stream is never cut. - HashSet? dead = null; - if (all.Length > 0 && heartbeatService is { } hb) - { - foreach (var ph in hb.GetAllPeerHealth()) - { - if (ph.State == PeerHealthState.Unreachable - && ph.AgeOfLastPong is { } age - && age > AudioPruneUnreachableAfter - && !receiver.IsAudioFlowingFrom(ph.AudioEndpoint.Address, TimeSpan.FromSeconds(3))) - { - (dead ??= new HashSet(StringComparer.OrdinalIgnoreCase)) - .Add($"{ph.AudioEndpoint.Address}:{ph.AudioEndpoint.Port}"); - } - } - } - - var armed = dead is null - ? all - : all.Where(ep => !dead.Contains($"{ep.Address}:{ep.Port}")).ToArray(); + // The prune itself is the shared Core rule (PeerArming) — same code the service arms with. The + // app's receiving-carve-out rides in as the keepAnyway predicate. + var armed = all.Length > 0 && heartbeatService is { } hb + ? PeerArming.ComputeArmedEndpoints(all, hb.GetAllPeerHealth(), AudioPruneUnreachableAfter, + keepAnyway: addr => receiver.IsAudioFlowingFrom(addr, TimeSpan.FromSeconds(3))) + : all; // There used to be a "never silence EVERY peer" safety net here that re-armed the whole // set when pruning would leave nobody. Removed 2026-06-12: when NO peer is reachable we @@ -5916,7 +5903,7 @@ public sealed class MainForm : Form // after the only receiver was switched off hours earlier). The heartbeat still probes all // peers, so the moment one answers again it is re-armed and audio resumes on its own. - var signature = string.Join("|", armed.Select(ep => $"{ep.Address}:{ep.Port}").OrderBy(s => s, StringComparer.OrdinalIgnoreCase)); + var signature = PeerArming.Signature(armed); if (signature == activeAudioReceiverSignature) return; activeAudioReceiverSignature = signature; sender.SetReceivers(armed); diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index 3eca38e..47e50b9 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -2284,7 +2284,19 @@ internal static class SelfTest Check(ServiceSendHost.ComputeArmedEndpoints(all, new List(), prune).Length == 2, "with no heartbeat data yet, arm the full set"); - return "reachable armed; long-unreachable dropped; grace-window kept; recovery re-arms (issues #8/#15)"; + // The app's receiving carve-out, now shared in PeerArming: a peer that's long-unreachable by + // heartbeat but that we're actively RECEIVING audio from must STAY armed (asymmetric path — + // a working stream is never cut). A false predicate must not save it. + var keptByCarveOut = PeerArming.ComputeArmedEndpoints(all, bDeadLong, prune, keepAnyway: addr => addr.Equals(b.Address)); + Check(keptByCarveOut.Length == 2, "an unreachable peer we're receiving audio from must stay armed (carve-out)"); + var carveOutMiss = PeerArming.ComputeArmedEndpoints(all, bDeadLong, prune, keepAnyway: _ => false); + Check(carveOutMiss.Length == 1, "a false carve-out predicate must not rescue a dead peer"); + + // The shared signature: order-independent, so list-order churn never causes a pointless re-arm. + Check(PeerArming.Signature(new[] { b, a }) == PeerArming.Signature(new[] { a, b }), + "the armed-set signature must be order-independent"); + + return "reachable armed; long-unreachable dropped; grace kept; carve-out honoured; signature order-free"; } /// Issue #23 boot self-heal decision core. Scenario: at the boot lock screen the machine's diff --git a/src/RemSound.App/ServiceSendHost.cs b/src/RemSound.App/ServiceSendHost.cs index 0918439..c0dc6e5 100644 --- a/src/RemSound.App/ServiceSendHost.cs +++ b/src/RemSound.App/ServiceSendHost.cs @@ -417,19 +417,11 @@ public sealed class ServiceSendHost : IDisposable return ApplyProfile(profile); } - /// Pure, testable: which endpoints to actively stream to — the full set minus any peer the - /// heartbeat reports as continuously unreachable for longer than . A peer - /// that's reachable, or still within the grace window, stays armed. Mirrors the app's RefreshAudioReceivers. - internal static IPEndPoint[] ComputeArmedEndpoints(IReadOnlyList all, IReadOnlyList health, TimeSpan pruneAfter) - { - HashSet? dead = null; - foreach (var ph in health) - { - if (ph.State == PeerHealthState.Unreachable && ph.AgeOfLastPong is { } age && age > pruneAfter) - (dead ??= new HashSet(StringComparer.OrdinalIgnoreCase)).Add($"{ph.AudioEndpoint.Address}:{ph.AudioEndpoint.Port}"); - } - return dead is null ? all.ToArray() : all.Where(ep => !dead.Contains($"{ep.Address}:{ep.Port}")).ToArray(); - } + /// Which endpoints to actively stream to. The logic lives in Core (PeerArming) — shared + /// with the app's RefreshAudioReceivers, which adds a receiving-carve-out predicate the send-only + /// service has no use for. Thin wrapper kept for the existing self-test and call site. + internal static IPEndPoint[] ComputeArmedEndpoints(IReadOnlyList all, IReadOnlyList health, TimeSpan pruneAfter) => + PeerArming.ComputeArmedEndpoints(all, health, pruneAfter); /// Re-arm the sender to only the reachable peers, using the heartbeat health. Cheap and /// idempotent — only touches the sender when the armed set actually changes. Called on the service's @@ -440,7 +432,7 @@ public sealed class ServiceSendHost : IDisposable { if (!running || allEndpoints.Length == 0) return; var armed = ComputeArmedEndpoints(allEndpoints, presence.PeerHealthSnapshot(), PruneUnreachableAfter); - var sig = string.Join("|", armed.Select(ep => $"{ep.Address}:{ep.Port}").OrderBy(s => s, StringComparer.OrdinalIgnoreCase)); + var sig = PeerArming.Signature(armed); if (sig == armedSignature) return; armedSignature = sig; sender.SetReceivers(armed); diff --git a/src/RemSound.Core/PeerArming.cs b/src/RemSound.Core/PeerArming.cs new file mode 100644 index 0000000..4ce164b --- /dev/null +++ b/src/RemSound.Core/PeerArming.cs @@ -0,0 +1,40 @@ +using System.Net; + +namespace RemSound.Core; + +/// +/// ONE home for "which selected peers do we actually stream to". The app and the send-only service +/// carried near-identical private copies: prune any peer whose heartbeat has read Unreachable for +/// longer than the grace window (never stream into a dead address — a-singer's issue #8), re-arm the +/// moment it answers again. The app's extra rule — a peer we're actively RECEIVING audio from stays +/// armed even when its heartbeat can't round-trip (asymmetric path; a working stream must never be +/// cut) — is injected as the keepAnyway predicate; the send-only service passes none. +/// +public static class PeerArming +{ + public static IPEndPoint[] ComputeArmedEndpoints( + IReadOnlyList all, + IReadOnlyList health, + TimeSpan pruneAfter, + Func? keepAnyway = null) + { + HashSet? dead = null; + foreach (var ph in health) + { + if (ph.State == PeerHealthState.Unreachable + && ph.AgeOfLastPong is { } age + && age > pruneAfter + && keepAnyway?.Invoke(ph.AudioEndpoint.Address) != true) + { + (dead ??= new HashSet(StringComparer.OrdinalIgnoreCase)) + .Add($"{ph.AudioEndpoint.Address}:{ph.AudioEndpoint.Port}"); + } + } + return dead is null ? all.ToArray() : all.Where(ep => !dead.Contains($"{ep.Address}:{ep.Port}")).ToArray(); + } + + /// Order-independent identity of an armed set. Both sides re-arm the sender only when this + /// changes, so churn in list ORDER never causes a pointless receiver reset. + public static string Signature(IEnumerable endpoints) => + string.Join("|", endpoints.Select(ep => $"{ep.Address}:{ep.Port}").OrderBy(s => s, StringComparer.OrdinalIgnoreCase)); +}