Fix the real per-app capture bug: async completion was never delivered

Root cause (proven with a focused probe, not guessed): per-app process-loopback
capture has NEVER worked on any machine. ActivateAudioInterfaceAsync returned S_OK
but the completion callback was never delivered, so activation always timed out
after ~3s and the app captured nothing. The earlier "InvalidCastException /
E_NOINTERFACE" was the same defect wearing a different mask (the out-param cast
throwing at the synchronous return), and the old lifecycle self-test passed green
only because it checked for an exception a hair before the timeout surfaced.

Two things were wrong, both in the completion path:
1. IActivateAudioInterfaceCompletionHandler.ActivateCompleted declared its operation
   parameter as the typed IActivateAudioInterfaceAsyncOperation. The CLR
   QueryInterface'd that operation as the call was delivered; the QI returns
   E_NOINTERFACE, and it failed INSIDE the interop stub -- before our method body --
   so the whole callback was silently dropped. Fix: take the operation as a raw
   IntPtr so the stub marshals nothing and the method actually runs.
2. Reading the result by casting the operation pointer to the managed interface hit
   the same failing QI. Fix: call GetActivateResult through the vtable directly
   (slot 3), since the pointer already IS that interface per the API contract.

Verified on real hardware via a new hidden --probe-apploopback diagnostic verb:
process-loopback now activates in ~2ms and captures real audio from a specific app
(18 MB from Chrome). Lifecycle self-test now activates in <1ms (was hanging ~2s)
and asserts activation is both clean AND prompt -- the timing check is what finally
caught this; the old test could not.

Also tried and discarded (kept out of the tree): a CoWaitForMultipleObjects
dispatch wait, an IAgileObject agility marker, and a retry loop -- none were needed
once the param/vtable fix landed. Removed to avoid confusion.

Gate: 39/39.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-17 11:45:41 +01:00
co-authored by Claude Opus 4.8
parent 4d49d5554e
commit cd9760d7d5
4 changed files with 134 additions and 20 deletions
+11 -1
View File
@@ -335,6 +335,7 @@ internal static class SelfTest
var pid = Process.GetCurrentProcess().Id;
var cycles = 0;
var disposeTimes = new List<long>();
for (var i = 0; i < 3; i++)
{
var capture = new RemSound.Sender.ProcessLoopbackCapture(pid);
@@ -344,7 +345,10 @@ internal static class SelfTest
capture.RecordingStopped += (_, e) => stopError = e.Exception;
capture.StartRecording();
Thread.Sleep(150); // let activation + the capture loop run and then be torn down
var sw = Stopwatch.StartNew();
capture.Dispose(); // teardown while the capture thread is live — the crash scenario
sw.Stop();
disposeTimes.Add(sw.ElapsedMilliseconds);
// Activation MUST have succeeded. This is the regression guard for the E_NOINTERFACE cast on
// IActivateAudioInterfaceAsyncOperation that silently killed every per-app capture: it was
@@ -356,7 +360,13 @@ internal static class SelfTest
return $"process-loopback activation failed: {stopError.GetType().Name}: {stopError.Message}";
cycles++;
}
return $"ran {cycles} start/stop/dispose cycles on pid {pid}; activation clean, no crash";
// Activation must also be FAST. A dispose that takes ~2s means the capture thread was still stuck
// waiting on activation 150ms after start (StopRecording's thread-join times out at 2s) — per-app
// capture "working" but starting seconds late is still broken from the user's chair, and this is
// exactly how the completion-never-arrives regression looks when a retry happens to save it.
var worst = disposeTimes.Max();
Check(worst < 1000, $"activation too slow — a dispose took {worst}ms, meaning the capture thread was still activating long after start (dispose times: {string.Join(", ", disposeTimes)}ms)");
return $"ran {cycles} start/stop/dispose cycles on pid {pid}; activation clean + prompt (dispose {string.Join("/", disposeTimes)}ms), no crash";
}
/// <summary>Soak test for runtime lifecycle transitions — the class of bug that hard-crashed when Ed