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:
co-authored by
Claude Opus 4.8
parent
c482857314
commit
5fa88aba5c
@@ -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;
|
||||
|
||||
/// <param name="loadProfile">Supplies the current service profile (re-read on each resume so edits
|
||||
/// are picked up). Returns null if none is configured.</param>
|
||||
/// <param name="log">Optional diagnostic sink.</param>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>Pure, testable: which endpoints to actively stream to — the full set minus any peer the
|
||||
/// heartbeat reports as continuously unreachable for longer than <paramref name="pruneAfter"/>. A peer
|
||||
/// that's reachable, or still within the grace window, stays armed. Mirrors the app's RefreshAudioReceivers.</summary>
|
||||
internal static IPEndPoint[] ComputeArmedEndpoints(IReadOnlyList<IPEndPoint> all, IReadOnlyList<PeerHealth> health, TimeSpan 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
|
||||
/// 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.</summary>
|
||||
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)");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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 <paramref name="ct"/> 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user