Service: wire sender diagnostics into the service log + capture-health watch (issue #23)

Issue #23's log proved the service can be perfectly healthy on the network
(discoverable, heartbeats, peers armed) while its WASAPI loopback capture is
open but STARVED — zero buffers ever delivered on the boot lock screen, so
peers connect and hear nothing. And the log was blind below 'streaming N
sources' because the service never wired the audio engine's diagnostics in.

- sender.Diagnostic now feeds the service log: capture opens/failures, backend
  switches, the silence keepalive result, composite mode. The keepalive already
  runs on every loopback device (SilentRenderKeepAlive via MixingEngine); on the
  next boot repro the log will show whether it started or was refused in
  session 0 pre-login — the deciding fact.
- New capture-health watch on the existing 1s tick: logs 'first capture
  callback received' once audio genuinely flows, and an explicit 'ZERO audio
  callbacks after 10s' line naming the starved-loopback fault instead of the
  log just going quiet.

Gate 37/37.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-16 09:56:39 +01:00
co-authored by Claude Opus 4.8
parent 18b079ba3a
commit 92ad77ff52
+38 -3
View File
@@ -59,6 +59,33 @@ public sealed class ServiceSendHost : IDisposable
this.loadProfile = loadProfile;
this.log = log;
presence = new ServiceNetworkPresence(sender, log);
// Wire the audio engine's own diagnostics into the service log — capture opens/failures, backend
// switches, the silence keepalive, composite mode. Without this the service log is blind below
// "streaming N sources" (issue #23 taught us that the hard way).
sender.Diagnostic = msg => log?.Invoke($"sender: {msg}");
}
// Capture-health watch (issue #23): the lock-screen bug is "capture open but ZERO buffers ever arrive".
// Log the first callback when audio genuinely flows, and an explicit zero-callbacks line after 10s so
// the log names the fault instead of just going quiet.
private long captureWatchStartTick;
private bool loggedFirstCallback;
private bool loggedZeroCallbacks;
private void WatchCaptureHealth()
{
if (loggedFirstCallback) return;
if (sender.CaptureCallbacks > 0)
{
loggedFirstCallback = true;
log?.Invoke($"service: first capture callback received — audio is flowing ({sender.CaptureBytes} bytes so far)");
return;
}
if (!loggedZeroCallbacks && Environment.TickCount64 - captureWatchStartTick > 10_000)
{
loggedZeroCallbacks = true;
log?.Invoke("service: capture is OPEN but has delivered ZERO audio callbacks after 10s — the audio engine is not feeding the loopback; peers hear nothing (issue #23 signature)");
}
}
/// <summary>Convenience factory for the real service: loads the profile from the machine-wide
@@ -109,6 +136,9 @@ public sealed class ServiceSendHost : IDisposable
sender.SetReceivers(endpoints);
sender.Configure(specs);
sender.Start();
captureWatchStartTick = Environment.TickCount64;
loggedFirstCallback = false;
loggedZeroCallbacks = false;
// Come up on the network too, so the peers can discover and connect to us — not just receive a
// blind push. Same well-known audio port and the same components the interactive app uses.
presence.Start(RemPacket.DefaultPort, endpoints);
@@ -219,9 +249,14 @@ 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();
// While streaming, re-arm to only the reachable peers (drop dead ones, pick up recovered ones)
// and watch capture health (issue #23: open-but-starved loopback). Piggybacks this existing
// tick — no extra timer, no background pile-up.
if (IsSending)
{
RefreshSendArming();
WatchCaptureHealth();
}
appWasPresent = appPresent;
ct.WaitHandle.WaitOne(pollMs);
}