Service: use the main app's event-driven device watcher, not a poll

Local checkpoint - NOT for public release. Ed: don't add continuous background checks
(they've piled up before) - reuse the disconnect/reconnect mechanism the main app uses.

Replaced the 5s packet-flow health poll with AudioDeviceChangeNotifier - the SAME
event-driven watcher the main window uses. It fires only when a device is added/removed/
changes state or the default changes (nothing polls, nothing accumulates - it's a single
registered COM callback, disposed with the host). While the service intends to send, that
event re-opens capture, covering: the audio stack finishing coming up at boot, a device
plugged/unplugged, and the audio service restarting. Debounced (a hot-plug fires a burst).

Also dropped the per-tick Resume retry: now one start attempt per app-absence, then the
device watcher drives any re-open. The only remaining periodic thing is the tiny 1s
presence-token check for the app-yield (a mutex probe - allocates nothing, accumulates
nothing). Gate 25/25.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-12 22:04:26 +01:00
co-authored by Claude Opus 4.8
parent a7742fe2ec
commit 4fb490b2d5
+45 -32
View File
@@ -30,6 +30,15 @@ public sealed class ServiceSendHost : IDisposable
private bool running; // the engine is actively sending
private bool disposed;
// Event-driven device-set watcher (the same mechanism the main window uses): it fires ONLY when a
// device is added/removed/changes state or the default changes — no background polling, nothing that
// builds up. While the service should be sending, that's our cue to (re)open capture: it covers the
// audio stack finishing coming up at boot, a device being plugged/unplugged, and the audio service
// restarting. Debounced because one hot-plug fires several notifications in quick succession.
private AudioDeviceChangeNotifier? deviceNotifier;
private volatile bool wantSending; // true while the app is absent and we intend to stream
private long lastDeviceChangeTick;
/// <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>
@@ -112,61 +121,63 @@ public sealed class ServiceSendHost : IDisposable
internal void RunLoopWithToken(CancellationToken ct, string tokenName, int pollMs, int resumeSettleMs)
=> RunLoopCore(ct, () => InteractivePresence.IsInteractiveAppRunning(tokenName), pollMs, resumeSettleMs);
private void RunLoopCore(CancellationToken ct, Func<bool> isAppPresent, int pollMs, int resumeSettleMs, int healthMs = 5000)
private void RunLoopCore(CancellationToken ct, Func<bool> isAppPresent, int pollMs, int resumeSettleMs)
{
// Register the event-driven device watcher for the life of the loop. Its callback re-opens
// capture when the device set changes, so we never poll for device readiness.
try { deviceNotifier ??= new AudioDeviceChangeNotifier(OnDeviceSetChanged); }
catch (Exception ex) { log?.Invoke($"service: device-change watcher unavailable ({ex.GetType().Name}) — relying on app-transition re-opens"); }
var appWasPresent = true; // force an initial evaluation
var absentSince = Environment.TickCount64;
long healthBaseline = -1; // packet count at the last health check (-1 = not yet baselined)
var lastHealthTick = Environment.TickCount64;
var triedThisAbsence = false;
while (!ct.IsCancellationRequested)
{
var appPresent = isAppPresent();
if (appPresent)
{
wantSending = false;
if (IsSending) Suspend();
absentSince = long.MaxValue;
healthBaseline = -1;
triedThisAbsence = false;
}
else
{
if (appWasPresent) absentSince = Environment.TickCount64; // app just left — start the settle timer
if (appWasPresent) { absentSince = Environment.TickCount64; triedThisAbsence = false; } // app just left
if (Environment.TickCount64 - absentSince >= resumeSettleMs)
{
if (!IsSending)
{
// Keep trying to (re)start every poll until it succeeds — covers a boot where the
// audio stack / a device isn't ready yet, and a device that returns later.
Resume();
healthBaseline = -1;
lastHealthTick = Environment.TickCount64;
}
else if (Environment.TickCount64 - lastHealthTick >= healthMs)
{
// Self-heal: we THINK we're sending, but if no packets have flowed since the last
// check then no capture is actually running (devices weren't ready when we started,
// a device dropped, or the audio service restarted). Re-open the capture.
lastHealthTick = Environment.TickCount64;
var packets = sender.PacketsSent;
if (healthBaseline >= 0 && packets == healthBaseline)
{
log?.Invoke("service: no audio flowing while sending — re-opening capture");
Suspend();
Resume();
healthBaseline = -1;
}
else
{
healthBaseline = packets;
}
}
wantSending = true;
// One start attempt per absence. If capture isn't ready yet (audio stack still coming
// up at boot, device absent), the device-change watcher re-opens it the moment a device
// appears — no per-tick retry loop that would keep churning in the background.
if (!triedThisAbsence && !IsSending) { Resume(); triedThisAbsence = true; }
}
}
appWasPresent = appPresent;
ct.WaitHandle.WaitOne(pollMs);
}
wantSending = false;
Suspend();
}
/// <summary>Device-set change callback (COM thread). While we intend to send, (re)open capture — this
/// is the event that fires when the audio stack finishes coming up at boot, a device is plugged or
/// unplugged, or the audio service restarts. Debounced: a single hot-plug fires several notifications.</summary>
private void OnDeviceSetChanged()
{
if (!wantSending || disposed) return;
var now = Environment.TickCount64;
lock (gate)
{
if (now - lastDeviceChangeTick < 750) return; // coalesce the burst
lastDeviceChangeTick = now;
}
var profile = loadProfile();
if (profile is null) return;
Suspend();
ApplyProfile(profile);
}
// WASAPI-only send specs from a profile. Mirrors the app's applications-vs-devices logic but never
// touches ASIO (the service can't). Applications mode needs Windows 10 19041+.
internal static List<CaptureSourceSpec> BuildSendSpecs(Profile p)
@@ -254,6 +265,8 @@ public sealed class ServiceSendHost : IDisposable
if (disposed) return;
disposed = true;
}
wantSending = false;
try { deviceNotifier?.Dispose(); } catch { } deviceNotifier = null;
try { sender.Stop(); } catch { }
try { sender.Dispose(); } catch { }
}