Service: reachability-gated sending (issues #8/#15), matching the app

Checked the service's connection handling against the networking issues that
shaped the main app. The service reused the low-level protocol components
(discovery, heartbeat, listener, sender), so it inherits their behaviour — but
it was MISSING the app's higher-level connection management from
MainForm.RefreshAudioReceivers: it called SetReceivers once with ALL configured
peers and blind-sent forever, even into dead addresses. That's exactly issue #8
(streaming into a peer that's gone) and it ignored issue #15 (retry/recover).

Fix: the service now streams ONLY to peers the heartbeat can reach, drops any
that stay unreachable past a 30s grace window, and re-arms them the moment they
recover — the same logic (and 30s threshold) as the app. Runs on the service's
existing 1s poll tick (no new timer, no background pile-up). Send-only, so no
"actively receiving" carve-out.

- ServiceNetworkPresence.PeerHealthSnapshot() exposes the heartbeat health.
- ServiceSendHost.ComputeArmedEndpoints (pure) + RefreshSendArming, wired into
  RunLoopCore.
- Self-test "Service reachability-gated sending": reachable armed, long-
  unreachable dropped, grace-window kept, no-data arms all. Gate 35/35.

Coverage of the other networking issues: multi-homed LAN+VPN (#18) is a
receiver-side allow-list fix — N/A to a send-only service, and its sender-side
support (announcing on all interfaces) is inherited from PeerDiscoveryService.
Forced/locked IPs (#17/#7): the service resolves peers literally and never
follows names, so it's inherently "locked" (what #17 asked for). Device
recovery (#5): already built. NOT built: discovery-based name-following (the
app can chase a peer whose IP changes); the service stays on its configured
addresses by design — flagged for Ed to decide if the service needs it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-15 08:33:54 +01:00
co-authored by Claude Opus 4.8
parent c482857314
commit 5fa88aba5c
3 changed files with 90 additions and 0 deletions
+38
View File
@@ -67,6 +67,7 @@ internal static class SelfTest
RunStep(results, "Service profile isolation (location + hidden from pickers)", ServiceProfileIsolation);
RunStep(results, "Service send host (headless stream + yield)", ServiceSendHostStream);
RunStep(results, "Service network presence (reachable + shell teardown)", ServiceNetworkPresenceReachable);
RunStep(results, "Service reachability-gated sending (drop dead peers, re-arm recovered)", ServiceReachabilityGating);
RunStep(results, "Service registration args", ServiceRegistrationArgs);
RunStep(results, "Recording engine (all formats + source gate + mono)", RecordingEngine);
RunStep(results, "Recording split tracks (per-peer + own)", RecordingSplitTracks);
@@ -1475,6 +1476,43 @@ internal static class SelfTest
finally { RemSound.Sender.ProcessLoopbackCapture.ForceSupportedForTest = prev; }
}
/// <summary>The service must only stream to peers the heartbeat can reach and drop long-unreachable
/// ones (never blast audio into a dead address — issue #8), re-arming a peer the moment it recovers
/// (issue #15) — the same behaviour as the app's RefreshAudioReceivers. Tests the pure arming logic.</summary>
private static string? ServiceReachabilityGating()
{
var a = new IPEndPoint(IPAddress.Parse("10.0.0.1"), 47830);
var b = new IPEndPoint(IPAddress.Parse("10.0.0.2"), 47830);
var all = new[] { a, b };
var prune = TimeSpan.FromSeconds(30);
var bothHealthy = new List<PeerHealth>
{
new(a, PeerHealthState.Healthy, 10, TimeSpan.FromSeconds(1)),
new(b, PeerHealthState.Healthy, 12, TimeSpan.FromSeconds(1)),
};
Check(ServiceSendHost.ComputeArmedEndpoints(all, bothHealthy, prune).Length == 2, "both reachable peers must be armed");
var bDeadLong = new List<PeerHealth>
{
new(a, PeerHealthState.Healthy, 10, TimeSpan.FromSeconds(1)),
new(b, PeerHealthState.Unreachable, null, TimeSpan.FromSeconds(60)),
};
var armed = ServiceSendHost.ComputeArmedEndpoints(all, bDeadLong, prune);
Check(armed.Length == 1 && armed[0].Equals(a), "a peer unreachable past the grace window must be dropped (never stream into a dead address)");
var bDeadGrace = new List<PeerHealth>
{
new(a, PeerHealthState.Healthy, 10, TimeSpan.FromSeconds(1)),
new(b, PeerHealthState.Unreachable, null, TimeSpan.FromSeconds(10)),
};
Check(ServiceSendHost.ComputeArmedEndpoints(all, bDeadGrace, prune).Length == 2, "a briefly-unreachable peer stays armed during the grace window");
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)";
}
private static int FreeUdpPort()
{
using var s = new System.Net.Sockets.Socket(System.Net.Sockets.AddressFamily.InterNetwork,