Peer extraction stage 1: arming logic shared in Core (PeerArming)

The 'which selected peers do we actually stream to' rule lived as near-identical
private copies in MainForm.RefreshAudioReceivers and ServiceSendHost (prune peers
unreachable past the grace window - issue #8 - re-arm on recovery, change-detect via
an ordered signature). Now ONE Core home: PeerArming.ComputeArmedEndpoints with the
app's actively-receiving carve-out injected as a predicate (asymmetric-path streams
are never cut), plus the shared order-independent Signature. Both sides delegate;
behaviour identical by construction. Reachability test extended: carve-out honoured,
false predicate doesn't rescue a dead peer, signature order-free. Gate 61/61.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-26 22:48:41 +01:00
co-authored by Claude Fable 5
parent e3bf28a414
commit 1b7fb47cd3
4 changed files with 66 additions and 35 deletions
+7 -20
View File
@@ -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 // 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 // reads Unreachable — that covers an asymmetric path where audio flows but the heartbeat
// round-trip doesn't, so a working stream is never cut. // round-trip doesn't, so a working stream is never cut.
HashSet<string>? dead = null; // The prune itself is the shared Core rule (PeerArming) — same code the service arms with. The
if (all.Length > 0 && heartbeatService is { } hb) // app's receiving-carve-out rides in as the keepAnyway predicate.
{ var armed = all.Length > 0 && heartbeatService is { } hb
foreach (var ph in hb.GetAllPeerHealth()) ? PeerArming.ComputeArmedEndpoints(all, hb.GetAllPeerHealth(), AudioPruneUnreachableAfter,
{ keepAnyway: addr => receiver.IsAudioFlowingFrom(addr, TimeSpan.FromSeconds(3)))
if (ph.State == PeerHealthState.Unreachable : all;
&& ph.AgeOfLastPong is { } age
&& age > AudioPruneUnreachableAfter
&& !receiver.IsAudioFlowingFrom(ph.AudioEndpoint.Address, TimeSpan.FromSeconds(3)))
{
(dead ??= new HashSet<string>(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();
// There used to be a "never silence EVERY peer" safety net here that re-armed the whole // 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 // 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 // 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. // 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; if (signature == activeAudioReceiverSignature) return;
activeAudioReceiverSignature = signature; activeAudioReceiverSignature = signature;
sender.SetReceivers(armed); sender.SetReceivers(armed);
+13 -1
View File
@@ -2284,7 +2284,19 @@ internal static class SelfTest
Check(ServiceSendHost.ComputeArmedEndpoints(all, new List<PeerHealth>(), prune).Length == 2, "with no heartbeat data yet, arm the full set"); Check(ServiceSendHost.ComputeArmedEndpoints(all, new List<PeerHealth>(), 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";
} }
/// <summary>Issue #23 boot self-heal decision core. Scenario: at the boot lock screen the machine's /// <summary>Issue #23 boot self-heal decision core. Scenario: at the boot lock screen the machine's
+6 -14
View File
@@ -417,19 +417,11 @@ public sealed class ServiceSendHost : IDisposable
return ApplyProfile(profile); return ApplyProfile(profile);
} }
/// <summary>Pure, testable: which endpoints to actively stream to — the full set minus any peer the /// <summary>Which endpoints to actively stream to. The logic lives in Core (PeerArming) — shared
/// heartbeat reports as continuously unreachable for longer than <paramref name="pruneAfter"/>. A peer /// with the app's RefreshAudioReceivers, which adds a receiving-carve-out predicate the send-only
/// that's reachable, or still within the grace window, stays armed. Mirrors the app's RefreshAudioReceivers.</summary> /// service has no use for. Thin wrapper kept for the existing self-test and call site.</summary>
internal static IPEndPoint[] ComputeArmedEndpoints(IReadOnlyList<IPEndPoint> all, IReadOnlyList<PeerHealth> health, TimeSpan pruneAfter) internal static IPEndPoint[] ComputeArmedEndpoints(IReadOnlyList<IPEndPoint> all, IReadOnlyList<PeerHealth> health, TimeSpan pruneAfter) =>
{ PeerArming.ComputeArmedEndpoints(all, health, pruneAfter);
HashSet<string>? dead = null;
foreach (var ph in health)
{
if (ph.State == PeerHealthState.Unreachable && ph.AgeOfLastPong is { } age && age > pruneAfter)
(dead ??= new HashSet<string>(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();
}
/// <summary>Re-arm the sender to only the reachable peers, using the heartbeat health. Cheap and /// <summary>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 /// 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; if (!running || allEndpoints.Length == 0) return;
var armed = ComputeArmedEndpoints(allEndpoints, presence.PeerHealthSnapshot(), PruneUnreachableAfter); 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; if (sig == armedSignature) return;
armedSignature = sig; armedSignature = sig;
sender.SetReceivers(armed); sender.SetReceivers(armed);
+40
View File
@@ -0,0 +1,40 @@
using System.Net;
namespace RemSound.Core;
/// <summary>
/// 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 <c>keepAnyway</c> predicate; the send-only service passes none.
/// </summary>
public static class PeerArming
{
public static IPEndPoint[] ComputeArmedEndpoints(
IReadOnlyList<IPEndPoint> all,
IReadOnlyList<PeerHealth> health,
TimeSpan pruneAfter,
Func<IPAddress, bool>? keepAnyway = null)
{
HashSet<string>? 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<string>(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();
}
/// <summary>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.</summary>
public static string Signature(IEnumerable<IPEndPoint> endpoints) =>
string.Join("|", endpoints.Select(ep => $"{ep.Address}:{ep.Port}").OrderBy(s => s, StringComparer.OrdinalIgnoreCase));
}