diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index 4fef095..6710cfe 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -6566,6 +6566,10 @@ public sealed class MainForm : Form // to re-tick to get audio back. ApplyAudioRuntime(); ApplyReceiveDevices(); + // The block above force-set the WASAPI send-list visibility for the new mode. Re-assert the + // per-application send view on top so the loopback-outputs list stays hidden (and the app list + // shown) when we're in applications mode — ASIO toggling must not resurrect the wrong list. + ApplySendModeVisibility(); if (wipedSomething) logFile.Event($"audio mode change wiped now-hidden device ticks"); } diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index c806418..6a1bdaf 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -60,6 +60,7 @@ internal static class SelfTest RunStep(results, "Per-peer shaping DSP", PeerShapingDsp); RunStep(results, "Multi-output fan-out (both lanes)", FanOutToBothOutputs); RunStep(results, "Per-application send enumeration", AppSendEnumeration); + RunStep(results, "Per-application capture lifecycle", AppSendCaptureLifecycle); RunStep(results, "v5 settings and shaping round-trip", V5ConfigRoundTrip); RunStep(results, "Profile save and reload", ProfileRoundTrip); RunStep(results, "What's-new update marker", WhatsNewMarkerRoundTrip); @@ -284,6 +285,31 @@ internal static class SelfTest return $"enumerated {apps.Count} app(s); process-loopback supported={supported}"; } + /// Exercises the process-loopback capture's real start → run → teardown cycle several times + /// against our OWN process, on hardware. This is the regression guard for the ASIO-toggle hard crash: + /// a bad COM teardown (releasing objects from the wrong thread / mid-native-call) would take the whole + /// test process down with an access violation, failing the gate. SKIP on Windows too old to support + /// process loopback. + private static string? AppSendCaptureLifecycle() + { + if (!RemSound.Sender.ProcessLoopbackCapture.IsSupported) + return Skip("process loopback needs Windows 10 build 19041+"); + + var pid = Process.GetCurrentProcess().Id; + var cycles = 0; + for (var i = 0; i < 3; i++) + { + var capture = new RemSound.Sender.ProcessLoopbackCapture(pid); + var frames = 0L; + capture.DataAvailable += (_, e) => Interlocked.Add(ref frames, e.BytesRecorded); + capture.StartRecording(); + Thread.Sleep(150); // let activation + the capture loop run and then be torn down + capture.Dispose(); // teardown while the capture thread is live — the crash scenario + cycles++; + } + return $"ran {cycles} start/stop/dispose cycles on pid {pid} with no crash"; + } + /// The v5 machine-wide settings and per-peer shaping survive a JSON save/reload: new /// AppConfig defaults, the named-peers book, the main tab order, per-peer shaping with parametric /// bands, and the new recording default. All in-memory — the real config/profiles aren't touched. diff --git a/src/RemSound.Sender/ProcessLoopbackCapture.cs b/src/RemSound.Sender/ProcessLoopbackCapture.cs index 43a41bd..4ea6f44 100644 --- a/src/RemSound.Sender/ProcessLoopbackCapture.cs +++ b/src/RemSound.Sender/ProcessLoopbackCapture.cs @@ -39,9 +39,11 @@ public sealed class ProcessLoopbackCapture : IWaveIn private readonly bool includeTree; private IAudioClient? audioClient; private IAudioCaptureClient? captureClient; - private EventWaitHandle? bufferReady; + private volatile EventWaitHandle? bufferReady; private Thread? captureThread; private volatile bool running; + private volatile bool stopRequested; + private volatile bool threadExited; public WaveFormat WaveFormat { get; set; } = CaptureFormat; @@ -67,26 +69,38 @@ public sealed class ProcessLoopbackCapture : IWaveIn if (!IsSupported) throw new PlatformNotSupportedException("Process-loopback capture needs Windows 10 build 19041 or newer."); - Activate(); running = true; - captureThread = new Thread(CaptureLoop) + stopRequested = false; + threadExited = false; + // The capture thread owns the ENTIRE COM lifecycle — it activates, runs, and releases every COM + // object itself before exiting. Nothing else ever touches those objects, so they can never be + // released out from under a running native call (the access-violation hard-crash we hit when a + // rebuild disposed the capture mid-GetBuffer). Activation is done on this thread too, so blocking + // on the async-activation callback can't stall the UI thread. + captureThread = new Thread(CaptureThreadMain) { IsBackground = true, Name = $"proc-loopback-{targetPid}", Priority = ThreadPriority.AboveNormal, }; + // WASAPI / ActivateAudioInterfaceAsync want an MTA thread: the completion callback arrives on an + // MTA pool thread and we block waiting for it, so this must not be the STA UI thread. + captureThread.SetApartmentState(ApartmentState.MTA); captureThread.Start(); } public void StopRecording() { - if (!running && captureThread == null) return; - running = false; - bufferReady?.Set(); // wake the loop so it can exit - captureThread?.Join(500); + var t = captureThread; captureThread = null; - try { audioClient?.Stop(); } catch { } - RecordingStopped?.Invoke(this, new StoppedEventArgs()); + running = false; + stopRequested = true; + bufferReady?.Set(); // wake the loop so it exits promptly + // Only WAIT for the thread here; never release COM from this side. If the thread is wedged in a + // native call and doesn't return, we leak it rather than free objects from another thread and + // risk an access violation — a rare leak beats a hard crash. Guard against joining ourselves in + // case a RecordingStopped handler re-enters. + if (t is not null && t != Thread.CurrentThread) t.Join(2000); } private void Activate() @@ -160,39 +174,16 @@ public sealed class ProcessLoopbackCapture : IWaveIn if (startHr != 0) Marshal.ThrowExceptionForHR(startHr); } - private void CaptureLoop() + /// The whole life of one process-loopback capture, start to finish, on a single MTA thread: + /// activate the client, pull audio until asked to stop (or an error), then release every COM object + /// here — never from another thread. RecordingStopped fires exactly once when the thread finishes. + private void CaptureThreadMain() { Exception? failure = null; - var frameBytes = CaptureFormat.BlockAlign; // 8 bytes (2ch * float) try { - while (running) - { - if (bufferReady!.WaitOne(200) == false) continue; - if (!running) break; - - while (true) - { - var hr = captureClient!.GetBuffer(out var dataPtr, out var frames, out var flags, out _, out _); - if (hr != 0) - { - // AUDCLNT_S_BUFFER_EMPTY (0x08890001) — nothing to read this wake. - if ((uint)hr == 0x08890001) break; - Marshal.ThrowExceptionForHR(hr); - } - if (frames == 0) break; - - var byteCount = frames * frameBytes; - var buffer = new byte[byteCount]; - const int AUDCLNT_BUFFERFLAGS_SILENT = 0x2; - if ((flags & AUDCLNT_BUFFERFLAGS_SILENT) == 0) - Marshal.Copy(dataPtr, buffer, 0, byteCount); - // else leave zeroed — WASAPI signalled a silent packet. - - captureClient.ReleaseBuffer(frames); - DataAvailable?.Invoke(this, new WaveInEventArgs(buffer, byteCount)); - } - } + Activate(); // creates audioClient / captureClient / bufferReady on THIS thread + RunCaptureLoop(); } catch (Exception ex) { @@ -200,18 +191,66 @@ public sealed class ProcessLoopbackCapture : IWaveIn } finally { - if (failure != null) - RecordingStopped?.Invoke(this, new StoppedEventArgs(failure)); + TeardownComOnThisThread(); + running = false; + threadExited = true; + RecordingStopped?.Invoke(this, new StoppedEventArgs(failure)); } } + private void RunCaptureLoop() + { + var frameBytes = CaptureFormat.BlockAlign; // 8 bytes (2ch * float) + while (!stopRequested) + { + if (bufferReady!.WaitOne(200) == false) continue; + if (stopRequested) break; + + while (!stopRequested) + { + var hr = captureClient!.GetBuffer(out var dataPtr, out var frames, out var flags, out _, out _); + if (hr != 0) + { + // AUDCLNT_S_BUFFER_EMPTY (0x08890001) — nothing to read this wake. + if ((uint)hr == 0x08890001) break; + Marshal.ThrowExceptionForHR(hr); + } + if (frames == 0) break; + + var byteCount = frames * frameBytes; + var buffer = new byte[byteCount]; + const int AUDCLNT_BUFFERFLAGS_SILENT = 0x2; + if ((flags & AUDCLNT_BUFFERFLAGS_SILENT) == 0) + Marshal.Copy(dataPtr, buffer, 0, byteCount); + // else leave zeroed — WASAPI signalled a silent packet. + + captureClient.ReleaseBuffer(frames); + DataAvailable?.Invoke(this, new WaveInEventArgs(buffer, byteCount)); + } + } + } + + /// Releases the COM objects on the capture thread (the only thread that ever touches them). + /// Stop the client before releasing so no callback is in flight. + private void TeardownComOnThisThread() + { + try { audioClient?.Stop(); } catch { } + try { audioClient?.Reset(); } catch { } + if (captureClient != null) { try { Marshal.ReleaseComObject(captureClient); } catch { } captureClient = null; } + if (audioClient != null) { try { Marshal.ReleaseComObject(audioClient); } catch { } audioClient = null; } + } + public void Dispose() { StopRecording(); - if (captureClient != null) { try { Marshal.ReleaseComObject(captureClient); } catch { } captureClient = null; } - if (audioClient != null) { try { Marshal.ReleaseComObject(audioClient); } catch { } audioClient = null; } - bufferReady?.Dispose(); - bufferReady = null; + // Dispose the wait handle only once the thread has genuinely exited (it uses the handle). If the + // thread wedged and StopRecording's join timed out, leave the handle alone rather than pull it + // from under a live WaitOne — the leak is bounded and safe; a use-after-free would not be. + if (threadExited) + { + bufferReady?.Dispose(); + bufferReady = null; + } } // ---- Async activation completion handler -------------------------------------------------