Fix hard crash when toggling ASIO while sending a specific app
Local checkpoint - NOT for public release. Repro (Ed): in applications send mode with an app being captured, turning the ASIO driver off hard-crashed the process. No log and no crash-report file were written - and MixingEngine.DisposeEntry swallows managed exceptions - which points to a native access violation, not a managed throw. Cause: the ASIO toggle rebuilds the capture backend (ApplyAsioMode -> ApplyAudioRuntime -> ApplySendSources), which disposes the live ProcessLoopbackCapture. The old design released the WASAPI COM objects from the disposing thread while the capture thread could still be inside a native GetBuffer call - a classic use-after-free / AV. Fix: the capture thread now owns the ENTIRE COM lifecycle. It activates, runs, and releases every COM object itself, in its finally, only after the loop has exited. StopRecording/Dispose merely signal and join (2s) - they never touch the COM objects. If the thread ever wedges in a native call we leak it rather than free from outside (a rare bounded leak beats a hard crash). The thread is also explicitly MTA, and activation moved onto it, so the async-activation callback can't stall the UI thread. bufferReady is volatile and only disposed once the thread has genuinely exited. Also: ApplyAsioMode force-sets the WASAPI send-list visibility for the new mode, which resurrected the loopback-outputs list in applications mode; re-assert ApplySendModeVisibility at the end so the correct list stays shown after an ASIO toggle. New self-test "Per-application capture lifecycle" runs real start/stop/dispose cycles of the native capture against our own process on hardware - a bad teardown would AV and fail the gate. Gate: 16/16. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f8a42f2806
commit
ffeba4ad2b
@@ -6566,6 +6566,10 @@ public sealed class MainForm : Form
|
|||||||
// to re-tick to get audio back.
|
// to re-tick to get audio back.
|
||||||
ApplyAudioRuntime();
|
ApplyAudioRuntime();
|
||||||
ApplyReceiveDevices();
|
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");
|
if (wipedSomething) logFile.Event($"audio mode change wiped now-hidden device ticks");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ internal static class SelfTest
|
|||||||
RunStep(results, "Per-peer shaping DSP", PeerShapingDsp);
|
RunStep(results, "Per-peer shaping DSP", PeerShapingDsp);
|
||||||
RunStep(results, "Multi-output fan-out (both lanes)", FanOutToBothOutputs);
|
RunStep(results, "Multi-output fan-out (both lanes)", FanOutToBothOutputs);
|
||||||
RunStep(results, "Per-application send enumeration", AppSendEnumeration);
|
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, "v5 settings and shaping round-trip", V5ConfigRoundTrip);
|
||||||
RunStep(results, "Profile save and reload", ProfileRoundTrip);
|
RunStep(results, "Profile save and reload", ProfileRoundTrip);
|
||||||
RunStep(results, "What's-new update marker", WhatsNewMarkerRoundTrip);
|
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}";
|
return $"enumerated {apps.Count} app(s); process-loopback supported={supported}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>The v5 machine-wide settings and per-peer shaping survive a JSON save/reload: new
|
/// <summary>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
|
/// 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.</summary>
|
/// bands, and the new recording default. All in-memory — the real config/profiles aren't touched.</summary>
|
||||||
|
|||||||
@@ -39,9 +39,11 @@ public sealed class ProcessLoopbackCapture : IWaveIn
|
|||||||
private readonly bool includeTree;
|
private readonly bool includeTree;
|
||||||
private IAudioClient? audioClient;
|
private IAudioClient? audioClient;
|
||||||
private IAudioCaptureClient? captureClient;
|
private IAudioCaptureClient? captureClient;
|
||||||
private EventWaitHandle? bufferReady;
|
private volatile EventWaitHandle? bufferReady;
|
||||||
private Thread? captureThread;
|
private Thread? captureThread;
|
||||||
private volatile bool running;
|
private volatile bool running;
|
||||||
|
private volatile bool stopRequested;
|
||||||
|
private volatile bool threadExited;
|
||||||
|
|
||||||
public WaveFormat WaveFormat { get; set; } = CaptureFormat;
|
public WaveFormat WaveFormat { get; set; } = CaptureFormat;
|
||||||
|
|
||||||
@@ -67,26 +69,38 @@ public sealed class ProcessLoopbackCapture : IWaveIn
|
|||||||
if (!IsSupported)
|
if (!IsSupported)
|
||||||
throw new PlatformNotSupportedException("Process-loopback capture needs Windows 10 build 19041 or newer.");
|
throw new PlatformNotSupportedException("Process-loopback capture needs Windows 10 build 19041 or newer.");
|
||||||
|
|
||||||
Activate();
|
|
||||||
running = true;
|
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,
|
IsBackground = true,
|
||||||
Name = $"proc-loopback-{targetPid}",
|
Name = $"proc-loopback-{targetPid}",
|
||||||
Priority = ThreadPriority.AboveNormal,
|
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();
|
captureThread.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void StopRecording()
|
public void StopRecording()
|
||||||
{
|
{
|
||||||
if (!running && captureThread == null) return;
|
var t = captureThread;
|
||||||
running = false;
|
|
||||||
bufferReady?.Set(); // wake the loop so it can exit
|
|
||||||
captureThread?.Join(500);
|
|
||||||
captureThread = null;
|
captureThread = null;
|
||||||
try { audioClient?.Stop(); } catch { }
|
running = false;
|
||||||
RecordingStopped?.Invoke(this, new StoppedEventArgs());
|
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()
|
private void Activate()
|
||||||
@@ -160,39 +174,16 @@ public sealed class ProcessLoopbackCapture : IWaveIn
|
|||||||
if (startHr != 0) Marshal.ThrowExceptionForHR(startHr);
|
if (startHr != 0) Marshal.ThrowExceptionForHR(startHr);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CaptureLoop()
|
/// <summary>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.</summary>
|
||||||
|
private void CaptureThreadMain()
|
||||||
{
|
{
|
||||||
Exception? failure = null;
|
Exception? failure = null;
|
||||||
var frameBytes = CaptureFormat.BlockAlign; // 8 bytes (2ch * float)
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
while (running)
|
Activate(); // creates audioClient / captureClient / bufferReady on THIS thread
|
||||||
{
|
RunCaptureLoop();
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -200,18 +191,66 @@ public sealed class ProcessLoopbackCapture : IWaveIn
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
if (failure != null)
|
TeardownComOnThisThread();
|
||||||
RecordingStopped?.Invoke(this, new StoppedEventArgs(failure));
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
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()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
StopRecording();
|
StopRecording();
|
||||||
if (captureClient != null) { try { Marshal.ReleaseComObject(captureClient); } catch { } captureClient = null; }
|
// Dispose the wait handle only once the thread has genuinely exited (it uses the handle). If the
|
||||||
if (audioClient != null) { try { Marshal.ReleaseComObject(audioClient); } catch { } audioClient = null; }
|
// thread wedged and StopRecording's join timed out, leave the handle alone rather than pull it
|
||||||
bufferReady?.Dispose();
|
// from under a live WaitOne — the leak is bounded and safe; a use-after-free would not be.
|
||||||
bufferReady = null;
|
if (threadExited)
|
||||||
|
{
|
||||||
|
bufferReady?.Dispose();
|
||||||
|
bufferReady = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Async activation completion handler -------------------------------------------------
|
// ---- Async activation completion handler -------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user