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