Review sweep: fix real bugs found across the service + per-app + settings changes

Parallel code review of this session's changes surfaced several real bugs; fixed the
substantive ones (judgment/cleanup calls held for Ed):

- HIGH Clearing "remembered peers" was resurrected on the next launch: the per-profile
  -> global migration re-ran every startup and re-unioned the profile file's stale copy.
  Added a one-time AppConfig.RememberedPeersMigrated marker so migration runs once and a
  cleared list stays cleared. Self-test pins the clear-then-reload scenario.
- MED PushModeWasapiBackend.Start rethrew on a device-open failure; nothing up the stack
  wraps it, so device churn (a push-eligible single WASAPI source unplugged mid-open)
  could crash the app. Now logs and stays stopped like MixingEngine/ASIO; the device
  watcher / self-heal re-open when a device returns.
- MED Service self-heal: (a) the re-open "no send sources" path left PerformanceMode ON
  and presence up while streaming nothing - now releases cleanly; (b) the 3-attempt
  ladder never refunded, so 3 hiccups over a days-long stint meant permanent silence -
  now refunds when real audio is heard, and resets on a device hot-plug.
- MED ApplyProfile resolved peers (DNS) and enumerated devices INSIDE the gate lock -
  a boot-time DNS hang as SYSTEM stalled Suspend()/yield/self-heal. Moved outside the lock.
- LOW AudioSessionStartWatcher leaked the AudioSessionManager on every Rehook (the WASAPI
  handle-leak fingerprint) - now disposed. New lifecycle self-test.
- LOW stale docstrings (send-all master toggle; ServiceUpdate in-place scheme; Profile
  .SendAllApplications "neither reads nor writes").

Gate: 42/42 (added peers-migration + session-watcher-lifecycle tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-17 15:49:01 +01:00
co-authored by Claude Opus 4.8
parent 4b2baa58b5
commit 984bcd042e
10 changed files with 143 additions and 44 deletions
+28 -4
View File
@@ -137,7 +137,9 @@ public sealed class ServiceSendHost : IDisposable
// silence keepalive feeding back, which is why the peak matters.
var peak = sender.TakeMaxSenderPreEncodePeak();
if (peak > pulsePeakMax) pulsePeakMax = peak;
if (peak >= SilentPeak) { everHeardAudio = true; deafSinceTick = 0; }
// Real audio proves the capture works — refund the self-heal ladder so a LATER hiccup (hours or
// days into an always-on stream) gets fresh re-open attempts instead of finding the budget spent.
if (peak >= SilentPeak) { everHeardAudio = true; deafSinceTick = 0; reopenAttempts = 0; }
pulseFramesSent += sender.TakeSenderAudioFramesSent();
// What is the DEVICE playing? The endpoint's own meter, independent of our capture stream.
@@ -256,7 +258,20 @@ public sealed class ServiceSendHost : IDisposable
{
sender.Stop();
var specs = BuildSendSpecs(profile); // re-resolve (the default device may have moved)
if (specs.Count == 0) { log?.Invoke("service: re-open found no send sources — capture left stopped"); running = false; return; }
if (specs.Count == 0)
{
// The source went away (e.g. the only loopback device was unplugged). Release the whole
// send stack — presence, meter readers, the session watcher AND the perf-mode overrides
// — instead of sitting "running" with High priority / EcoQoS-off held while streaming
// nothing. The device-change watcher re-opens (via ApplyProfile) when a device returns.
log?.Invoke("service: re-open found no send sources — releasing until a device returns");
try { presence.Stop(); } catch { }
SwapMeterDevices(Array.Empty<CaptureSourceSpec>());
try { sessionKick?.Dispose(); } catch { } sessionKick = null;
try { PerformanceMode.Apply(false, msg => log?.Invoke($"service: {msg}")); } catch { }
running = false;
return;
}
sender.Configure(specs);
sender.Start();
SwapMeterDevices(specs);
@@ -297,11 +312,16 @@ public sealed class ServiceSendHost : IDisposable
/// false (and stays stopped) if the profile has nothing to send or no reachable peers.</summary>
public bool ApplyProfile(Profile profile)
{
// Resolve sources + peer addresses OUTSIDE the lock. BuildEndpoints does DNS (Dns.GetHostAddresses)
// and BuildSendSpecs enumerates devices — either can block for seconds at boot as SYSTEM before the
// network/audio stack is fully up. Doing that while holding `gate` would stall Suspend() (yielding
// to the interactive app), the RunLoop tick and the self-heal for the whole timeout.
if (disposed) return false;
var specs = BuildSendSpecs(profile);
var endpoints = BuildEndpoints(profile);
lock (gate)
{
if (disposed) return false;
var specs = BuildSendSpecs(profile);
var endpoints = BuildEndpoints(profile);
if (specs.Count == 0) { log?.Invoke("service: profile has no WASAPI send sources — nothing to stream"); return false; }
if (endpoints.Count == 0) { log?.Invoke("service: profile has no reachable peers — nothing to stream to"); return false; }
@@ -536,6 +556,10 @@ public sealed class ServiceSendHost : IDisposable
}
var profile = loadProfile();
if (profile is null) return;
// A device hot-plug re-plumbs the audio graph much like a power resume or a fresh boot — refill the
// self-heal ladder so a brand-new device that comes up momentarily deaf still gets its re-opens.
everHeardAudio = false;
reopenAttempts = 0;
Suspend();
ApplyProfile(profile);
}