From 5fa88aba5c4963f4af6d13c5b781312e2eff37a8 Mon Sep 17 00:00:00 2001 From: Ednunp <29843396+Ednunp@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:33:54 +0100 Subject: [PATCH] Service: reachability-gated sending (issues #8/#15), matching the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/RemSound.App/SelfTest.cs | 38 +++++++++++++++++ src/RemSound.App/ServiceNetworkPresence.cs | 4 ++ src/RemSound.App/ServiceSendHost.cs | 48 ++++++++++++++++++++++ 3 files changed, 90 insertions(+) diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index d59279b..8a04fdc 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -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; } } + /// 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. + 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 + { + 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 + { + 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 + { + 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(), 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, diff --git a/src/RemSound.App/ServiceNetworkPresence.cs b/src/RemSound.App/ServiceNetworkPresence.cs index 62475a8..88b6511 100644 --- a/src/RemSound.App/ServiceNetworkPresence.cs +++ b/src/RemSound.App/ServiceNetworkPresence.cs @@ -49,6 +49,10 @@ internal sealed class ServiceNetworkPresence : IDisposable /// Test seam: is the well-known-port listener actually bound? internal bool ListenerBound => receiver.IsListenerRunning; + /// Current per-peer heartbeat health (reachable / stale / unreachable + age), so the host can + /// gate the audio send on reachability — the same signal the interactive app uses. Empty when down. + public IReadOnlyList PeerHealthSnapshot() => heartbeat?.GetAllPeerHealth() ?? Array.Empty(); + /// Bring the service up as a discoverable, reachable, send-only peer on , /// tracking (the profile's peers) for heartbeat/pairing and unicast /// announcements. Idempotent: re-applies cleanly if already up. Never throws. diff --git a/src/RemSound.App/ServiceSendHost.cs b/src/RemSound.App/ServiceSendHost.cs index 9875ae7..35d5bdf 100644 --- a/src/RemSound.App/ServiceSendHost.cs +++ b/src/RemSound.App/ServiceSendHost.cs @@ -43,6 +43,14 @@ public sealed class ServiceSendHost : IDisposable private volatile bool wantSending; // true while the app is absent and we intend to stream private long lastDeviceChangeTick; + // Reachability-gated sending (issues #8 / #15): stream ONLY to peers the heartbeat can reach, and drop + // any that stay unreachable — never blast audio into a dead address forever. The heartbeat keeps + // probing the FULL set, so a peer that comes back is re-armed on the next refresh. Mirrors the app's + // RefreshAudioReceivers. Send-only, so there's no "actively receiving" carve-out. + private static readonly TimeSpan PruneUnreachableAfter = TimeSpan.FromSeconds(30); + private IPEndPoint[] allEndpoints = []; + private string? armedSignature; + /// Supplies the current service profile (re-read on each resume so edits /// are picked up). Returns null if none is configured. /// Optional diagnostic sink. @@ -94,6 +102,10 @@ public sealed class ServiceSendHost : IDisposable sender.ConfigureCodec(profile.Codec, MainForm.EffectiveOpusFrameSamples(profile.Codec, profile.OpusFrameSamplesPerChannel, profile.SendRate)); sender.SetSendRate(profile.SendRate); sender.SetTightLatency(profile.TightLatencyMode); + // Arm the full set to begin with (nothing is known-dead yet); RefreshSendArming then prunes any + // peer the heartbeat can't reach and re-arms it when it recovers. + allEndpoints = endpoints.ToArray(); + armedSignature = null; sender.SetReceivers(endpoints); sender.Configure(specs); sender.Start(); @@ -130,6 +142,39 @@ 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(); + } + + /// 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 + /// existing poll tick while streaming, so there's no extra timer. + private void RefreshSendArming() + { + lock (gate) + { + 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)); + if (sig == armedSignature) return; + armedSignature = sig; + sender.SetReceivers(armed); + log?.Invoke(armed.Length == 0 + ? $"service: 0 reachable peers — holding audio (heartbeat still probing {allEndpoints.Length})" + : $"service: streaming to {armed.Length}/{allEndpoints.Length} reachable peer(s)"); + } + } + /// The service's main loop: watch the interactive-presence token and hand the send back and /// forth. Starts sending immediately if no app is present. A short settle delay before resuming /// stops rapid app open/close from thrashing the engine. Returns when is @@ -174,6 +219,9 @@ public sealed class ServiceSendHost : IDisposable if (!triedThisAbsence && !IsSending) { Resume(); triedThisAbsence = true; } } } + // While streaming, re-arm to only the reachable peers (drop dead ones, pick up recovered ones). + // Piggybacks this existing tick — no extra timer, no background pile-up. + if (IsSending) RefreshSendArming(); appWasPresent = appPresent; ct.WaitHandle.WaitOne(pollMs); }