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
@@ -0,0 +1,66 @@
|
||||
using System.Diagnostics;
|
||||
using RemSound.Sender;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>Focused diagnostic probe for the process-loopback activation hang (per-app send). Run via
|
||||
/// `RemSound.exe --probe-apploopback [pid]`. Traces the whole activation path (hr, callback delivery,
|
||||
/// timing) for the given pid — or the FIRST process named in the REMSOUND_PROBE_APP env var, else self.
|
||||
/// Prints to the console; not part of the gate. Temporary investigation aid.</summary>
|
||||
internal static class ProbeAppLoopback
|
||||
{
|
||||
public static int Run(string[] args)
|
||||
{
|
||||
var outPath = Path.Combine(Path.GetTempPath(), "remsound-apploopback-probe.txt");
|
||||
var sw0 = new StreamWriter(outPath, append: false) { AutoFlush = true };
|
||||
void Line(string m) => sw0.WriteLine(m);
|
||||
ProcessLoopbackCapture.Diagnostic = m => Line($" [{DateTime.Now:HH:mm:ss.fff}] {m}");
|
||||
|
||||
int pid;
|
||||
var explicitPid = args.FirstOrDefault(a => int.TryParse(a, out _));
|
||||
if (explicitPid is not null) pid = int.Parse(explicitPid);
|
||||
else
|
||||
{
|
||||
var name = Environment.GetEnvironmentVariable("REMSOUND_PROBE_APP");
|
||||
var proc = string.IsNullOrWhiteSpace(name)
|
||||
? null
|
||||
: Process.GetProcessesByName(name).FirstOrDefault();
|
||||
pid = proc?.Id ?? Environment.ProcessId;
|
||||
}
|
||||
|
||||
Line($"process-loopback probe: target pid={pid} "
|
||||
+ $"({(pid == Environment.ProcessId ? "SELF (silent)" : SafeName(pid))}), IsSupported={ProcessLoopbackCapture.IsSupported}");
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
long bytes = 0;
|
||||
Exception? stopError = null;
|
||||
var stopped = new ManualResetEventSlim(false);
|
||||
var capture = new ProcessLoopbackCapture(pid);
|
||||
capture.DataAvailable += (_, e) => Interlocked.Add(ref bytes, e.BytesRecorded);
|
||||
capture.RecordingStopped += (_, e) => { stopError = e.Exception; stopped.Set(); };
|
||||
|
||||
Line("starting capture...");
|
||||
capture.StartRecording();
|
||||
|
||||
// Give activation up to 5s to complete or fail, then observe ~2s of data flow.
|
||||
var settled = stopped.Wait(5000);
|
||||
var afterActivate = sw.ElapsedMilliseconds;
|
||||
if (!settled) Thread.Sleep(2000); // capture is live — let some audio flow
|
||||
var seen = Interlocked.Read(ref bytes);
|
||||
|
||||
Line($"--- after {afterActivate}ms: stopped={settled}, bytesCaptured={seen}, stopError={stopError?.GetType().Name}: {stopError?.Message}");
|
||||
|
||||
var disposeSw = Stopwatch.StartNew();
|
||||
capture.Dispose();
|
||||
disposeSw.Stop();
|
||||
Line($"dispose took {disposeSw.ElapsedMilliseconds}ms (~2000ms = capture thread was still stuck in activation)");
|
||||
Line(seen > 0 ? "RESULT: capture DELIVERED audio" : "RESULT: NO audio captured (activation failed)");
|
||||
sw0.Dispose();
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static string SafeName(int pid)
|
||||
{
|
||||
try { return Process.GetProcessById(pid).ProcessName; } catch { return "unknown"; }
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,14 @@ internal static class Program
|
||||
return;
|
||||
}
|
||||
|
||||
// --probe-apploopback [pid]: temporary focused diagnostic for the per-app-send activation hang.
|
||||
// Runs the process-loopback activation with full tracing and exits. Not part of the gate.
|
||||
if (args.Length > 0 && Array.Exists(args, a => string.Equals(a, "--probe-apploopback", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
Environment.ExitCode = ProbeAppLoopback.Run(args);
|
||||
return;
|
||||
}
|
||||
|
||||
// --config-dir <folder> (test / portable isolation): redirect ALL user state - config,
|
||||
// profiles, logs, cue sounds - to an explicit folder for THIS process only. Applied first,
|
||||
// before the layout migration and sound consolidation below read or write the default
|
||||
|
||||
@@ -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