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:
co-authored by
Claude Opus 4.8
parent
4d49d5554e
commit
cd9760d7d5
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user