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 -------------------------------------------------