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:
co-authored by
Claude Opus 4.8
parent
a7742fe2ec
commit
4fb490b2d5
@@ -30,6 +30,15 @@ public sealed class ServiceSendHost : IDisposable
|
|||||||
private bool running; // the engine is actively sending
|
private bool running; // the engine is actively sending
|
||||||
private bool disposed;
|
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
|
/// <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>
|
/// are picked up). Returns null if none is configured.</param>
|
||||||
/// <param name="log">Optional diagnostic sink.</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)
|
internal void RunLoopWithToken(CancellationToken ct, string tokenName, int pollMs, int resumeSettleMs)
|
||||||
=> RunLoopCore(ct, () => InteractivePresence.IsInteractiveAppRunning(tokenName), pollMs, 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 appWasPresent = true; // force an initial evaluation
|
||||||
var absentSince = Environment.TickCount64;
|
var absentSince = Environment.TickCount64;
|
||||||
long healthBaseline = -1; // packet count at the last health check (-1 = not yet baselined)
|
var triedThisAbsence = false;
|
||||||
var lastHealthTick = Environment.TickCount64;
|
|
||||||
while (!ct.IsCancellationRequested)
|
while (!ct.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
var appPresent = isAppPresent();
|
var appPresent = isAppPresent();
|
||||||
if (appPresent)
|
if (appPresent)
|
||||||
{
|
{
|
||||||
|
wantSending = false;
|
||||||
if (IsSending) Suspend();
|
if (IsSending) Suspend();
|
||||||
absentSince = long.MaxValue;
|
absentSince = long.MaxValue;
|
||||||
healthBaseline = -1;
|
triedThisAbsence = false;
|
||||||
}
|
}
|
||||||
else
|
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 (Environment.TickCount64 - absentSince >= resumeSettleMs)
|
||||||
{
|
{
|
||||||
if (!IsSending)
|
wantSending = true;
|
||||||
{
|
// One start attempt per absence. If capture isn't ready yet (audio stack still coming
|
||||||
// Keep trying to (re)start every poll until it succeeds — covers a boot where the
|
// up at boot, device absent), the device-change watcher re-opens it the moment a device
|
||||||
// audio stack / a device isn't ready yet, and a device that returns later.
|
// appears — no per-tick retry loop that would keep churning in the background.
|
||||||
Resume();
|
if (!triedThisAbsence && !IsSending) { Resume(); triedThisAbsence = true; }
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
appWasPresent = appPresent;
|
appWasPresent = appPresent;
|
||||||
ct.WaitHandle.WaitOne(pollMs);
|
ct.WaitHandle.WaitOne(pollMs);
|
||||||
}
|
}
|
||||||
|
wantSending = false;
|
||||||
Suspend();
|
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
|
// 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+.
|
// touches ASIO (the service can't). Applications mode needs Windows 10 19041+.
|
||||||
internal static List<CaptureSourceSpec> BuildSendSpecs(Profile p)
|
internal static List<CaptureSourceSpec> BuildSendSpecs(Profile p)
|
||||||
@@ -254,6 +265,8 @@ public sealed class ServiceSendHost : IDisposable
|
|||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
disposed = true;
|
disposed = true;
|
||||||
}
|
}
|
||||||
|
wantSending = false;
|
||||||
|
try { deviceNotifier?.Dispose(); } catch { } deviceNotifier = null;
|
||||||
try { sender.Stop(); } catch { }
|
try { sender.Stop(); } catch { }
|
||||||
try { sender.Dispose(); } catch { }
|
try { sender.Dispose(); } catch { }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user