diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index 33636d4..bd5e257 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -73,6 +73,7 @@ internal static class SelfTest RunStep(results, "Remembered applications list is global + clearable", RememberedApplicationsGlobal); RunStep(results, "Remembered peers migrate once (cleared list not resurrected)", RememberedPeersMigrationOnce); RunStep(results, "Session-start watcher lifecycle (construct/rehook/dispose)", SessionStartWatcher); + RunStep(results, "ASIO apartment thread (single STA home for driver calls)", AsioApartmentThread); RunStep(results, "Send-app lists semantics (ticked → Active, out of Remembered)", SendAppListSemantics); RunStep(results, "Service registration args", ServiceRegistrationArgs); RunStep(results, "Service self-contained install (own bin + user stop rights)", ServiceSelfContainedInstall); @@ -1762,6 +1763,32 @@ internal static class SelfTest } } + /// The dedicated ASIO control thread (AsioApartment) must run every work item on ONE STA + /// thread (not the caller's), propagate exceptions back to the caller, and keep working after a work + /// item throws — the guarantees that let the ASIO driver be opened AND closed from a single, pumped + /// home thread (the fix for the native crash-on-close). + private static string? AsioApartmentThread() + { + using var apt = new RemSound.Sender.AsioApartment("asio-selftest"); + var state = ApartmentState.Unknown; + int workThread = 0, workThread2 = 0; + apt.Invoke(() => { state = Thread.CurrentThread.GetApartmentState(); workThread = Environment.CurrentManagedThreadId; }); + apt.Invoke(() => workThread2 = Environment.CurrentManagedThreadId); + Check(state == ApartmentState.STA, "the ASIO apartment must run work on an STA thread"); + Check(workThread == workThread2, "all work must run on the SAME dedicated thread"); + Check(workThread != Environment.CurrentManagedThreadId, "work must run on the apartment thread, not the caller's"); + + var threw = false; + try { apt.Invoke(() => throw new InvalidOperationException("boom")); } + catch (InvalidOperationException ex) when (ex.Message == "boom") { threw = true; } + Check(threw, "an exception on the apartment thread must propagate to the caller"); + + var ranAfter = false; + apt.Invoke(() => ranAfter = true); + Check(ranAfter, "the apartment must keep working after a work item threw"); + return "runs work on one dedicated STA thread; exceptions propagate; survives a throw"; + } + /// The instant capture-on-app-open watcher (AudioSessionStartWatcher) must construct, re-hook /// its default-device notification without throwing, and dispose idempotently — the plumbing behind /// "catch a per-app send from its very start" and the service's boot session-kick. (It hooks live diff --git a/src/RemSound.Sender/AsioApartment.cs b/src/RemSound.Sender/AsioApartment.cs new file mode 100644 index 0000000..9a38094 --- /dev/null +++ b/src/RemSound.Sender/AsioApartment.cs @@ -0,0 +1,127 @@ +using System.Runtime.InteropServices; + +namespace RemSound.Sender; + +/// +/// A dedicated STA thread with a Windows message pump, used to own the ENTIRE lifecycle of one ASIO +/// driver — create, initialise, start, stop and (crucially) dispose. ASIO drivers are COM objects that +/// generally require every one of their control calls to be made from a single thread, and several +/// (notably Audient) crash NATIVELY on close when that isn't honoured, or when there is no message pump +/// running to service the messages the driver posts during init/reset/close. The previous code made +/// these calls on whatever thread happened to call Start/Stop, with only a Sleep() before the close — +/// which is why closing the driver (on "ASIO → none" or a driver switch) could take the whole process +/// down with an access violation that left no managed stack. +/// +/// Route every AsioOut control call through and the driver gets a stable home +/// thread and a live pump. The ASIO audio callback still runs on the driver's own real-time thread — that +/// is unchanged; only the control calls move here. +/// +internal sealed class AsioApartment : IDisposable +{ + private readonly Thread thread; + private readonly ManualResetEventSlim started = new(false); + private readonly object queueGate = new(); + private readonly Queue queue = new(); + private readonly AutoResetEvent workAvailable = new(false); + private volatile bool shutdown; + + private sealed class WorkItem + { + public required Action Action; + public required ManualResetEventSlim Done; + public Exception? Error; + } + + public AsioApartment(string name = "asio-control") + { + thread = new Thread(Run) { IsBackground = true, Name = name }; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + started.Wait(); // don't accept Invoke()s until the pump is up + } + + /// Run on the apartment thread and block until it finishes, + /// rethrowing anything it raised. If called from the apartment thread itself, or after Dispose, runs + /// inline so a teardown path can never deadlock on itself. + public void Invoke(Action action) + { + if (shutdown || Thread.CurrentThread == thread) { action(); return; } + var item = new WorkItem { Action = action, Done = new ManualResetEventSlim(false) }; + lock (queueGate) queue.Enqueue(item); + workAvailable.Set(); + item.Done.Wait(); + item.Done.Dispose(); + if (item.Error is not null) throw item.Error; + } + + private void Run() + { + // Force a message queue to exist on this thread before anyone posts to it. + PeekMessage(out _, IntPtr.Zero, 0, 0, PM_NOREMOVE); + started.Set(); + + var handles = new[] { workAvailable.SafeWaitHandle.DangerousGetHandle() }; + while (!shutdown) + { + // Wake on either queued work or an incoming window message, so the driver's posted messages + // are dispatched promptly (that servicing is what several ASIO drivers need to close cleanly). + MsgWaitForMultipleObjects(1, handles, false, INFINITE, QS_ALLINPUT); + + while (PeekMessage(out var msg, IntPtr.Zero, 0, 0, PM_REMOVE)) + { + TranslateMessage(ref msg); + DispatchMessage(ref msg); + } + + while (true) + { + WorkItem? item; + lock (queueGate) item = queue.Count > 0 ? queue.Dequeue() : null; + if (item is null) break; + try { item.Action(); } + catch (Exception ex) { item.Error = ex; } + finally { item.Done.Set(); } + } + } + } + + public void Dispose() + { + if (shutdown) return; + shutdown = true; + workAvailable.Set(); + try { if (thread.IsAlive && Thread.CurrentThread != thread) thread.Join(2000); } catch { /* leaked thread beats a hang */ } + try { workAvailable.Dispose(); } catch { } + try { started.Dispose(); } catch { } + } + + // ---- native message pump ---- + private const uint QS_ALLINPUT = 0x04FF; + private const uint INFINITE = 0xFFFFFFFF; + private const uint PM_REMOVE = 0x0001; + private const uint PM_NOREMOVE = 0x0000; + + [StructLayout(LayoutKind.Sequential)] + private struct MSG + { + public IntPtr hwnd; + public uint message; + public IntPtr wParam; + public IntPtr lParam; + public uint time; + public int ptX; + public int ptY; + } + + [DllImport("user32.dll")] + private static extern uint MsgWaitForMultipleObjects(uint nCount, IntPtr[] pHandles, bool bWaitAll, uint dwMilliseconds, uint dwWakeMask); + + [DllImport("user32.dll")] + private static extern bool PeekMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg); + + [DllImport("user32.dll")] + private static extern bool TranslateMessage(ref MSG lpMsg); + + [DllImport("user32.dll")] + private static extern IntPtr DispatchMessage(ref MSG lpMsg); +} diff --git a/src/RemSound.Sender/AsioCaptureBackend.cs b/src/RemSound.Sender/AsioCaptureBackend.cs index b22aaff..59dfa30 100644 --- a/src/RemSound.Sender/AsioCaptureBackend.cs +++ b/src/RemSound.Sender/AsioCaptureBackend.cs @@ -48,6 +48,10 @@ internal sealed class AsioCaptureBackend : ICaptureBackend private readonly object gate = new(); private AsioOut? asio; + // Every AsioOut control call (create / init / play / stop / dispose) is marshalled onto this one + // dedicated STA+message-pump thread. That single-threaded, pumped home is what lets the driver close + // WITHOUT the native crash we used to hit on "ASIO → none" and driver switches — see AsioApartment. + private readonly AsioApartment apartment = new(); private List activeChannelPairIndices = []; private int recordChannelCount; private float[] mixScratch = new float[1024]; @@ -133,36 +137,34 @@ internal sealed class AsioCaptureBackend : ICaptureBackend try { - asio = new AsioOut(driverName); - // Always open with the driver's full input channel count. Pulling channels we - // don't immediately need is essentially free — the driver fills them anyway — - // and it removes the need to ever reopen the AsioOut when the user toggles a - // higher-numbered channel pair. Reopening is what previously caused 15-second - // freezes when both sender and receiver held the same single-client driver - // (Komplete Audio etc.) — see Andre's localhost lockup, 2026-04-30. - recordChannelCount = asio.DriverInputChannelCount; - if (recordChannelCount <= 0) + // Open + init + play, ALL on the ASIO apartment thread (see the apartment field). A zero- + // channel driver becomes a throw so the catch below runs the same StopInternal cleanup. + apartment.Invoke(() => { - onDiagnostic?.Invoke($"asio capture: driver \"{driverName}\" reports zero input channels"); - StopInternal(); - return; - } - asio.InputChannelOffset = 0; - // Sanity-check that the requested pairs are within the driver's channel range. - // We open the full count anyway, but if a saved spec references a pair above - // the driver's range, the OnAudioAvailable mixer would silently emit zero — - // surface that as a diagnostic so it's not mysterious. - var maxPairIndex = activeChannelPairIndices.Max(); - var highestNeededChannel = (maxPairIndex + 1) * 2; - if (highestNeededChannel > recordChannelCount) - { - onDiagnostic?.Invoke($"asio capture: driver \"{driverName}\" only has {recordChannelCount} input channels, but spec requests channel pair {maxPairIndex} (channels {maxPairIndex * 2 + 1}/{maxPairIndex * 2 + 2})"); - // Continue anyway — out-of-range pairs just contribute silence to the mix. - } - asio.InitRecordAndPlayback(null, recordChannelCount, MixSampleRate); - asio.AudioAvailable += OnAudioAvailable; - captureFormat = $"{MixSampleRate} Hz, {recordChannelCount} input channel(s), 32-bit float (ASIO)"; - asio.Play(); + asio = new AsioOut(driverName); + // Always open with the driver's full input channel count. Pulling channels we + // don't immediately need is essentially free — the driver fills them anyway — + // and it removes the need to ever reopen the AsioOut when the user toggles a + // higher-numbered channel pair. Reopening is what previously caused 15-second + // freezes when both sender and receiver held the same single-client driver + // (Komplete Audio etc.) — see Andre's localhost lockup, 2026-04-30. + recordChannelCount = asio.DriverInputChannelCount; + if (recordChannelCount <= 0) + throw new InvalidOperationException($"driver \"{driverName}\" reports zero input channels"); + asio.InputChannelOffset = 0; + // Sanity-check that the requested pairs are within the driver's channel range. + // We open the full count anyway, but if a saved spec references a pair above + // the driver's range, the OnAudioAvailable mixer would silently emit zero — + // surface that as a diagnostic so it's not mysterious. + var maxPairIndex = activeChannelPairIndices.Max(); + var highestNeededChannel = (maxPairIndex + 1) * 2; + if (highestNeededChannel > recordChannelCount) + onDiagnostic?.Invoke($"asio capture: driver \"{driverName}\" only has {recordChannelCount} input channels, but spec requests channel pair {maxPairIndex} (channels {maxPairIndex * 2 + 1}/{maxPairIndex * 2 + 2})"); + asio.InitRecordAndPlayback(null, recordChannelCount, MixSampleRate); + asio.AudioAvailable += OnAudioAvailable; + captureFormat = $"{MixSampleRate} Hz, {recordChannelCount} input channel(s), 32-bit float (ASIO)"; + asio.Play(); + }); uptime.Restart(); onDiagnostic?.Invoke($"asio capture started \"{driverName}\" {captureFormat}; pairs={string.Join(",", activeChannelPairIndices)}"); } @@ -207,22 +209,25 @@ internal sealed class AsioCaptureBackend : ICaptureBackend private void StopInternal() { - if (asio is not null) + var toClose = asio; + if (toClose is not null) { - // Step-by-step logging: closing some drivers (notably Audient) can crash NATIVELY inside their - // own Stop/Dispose — a native access violation blows past these try/catch blocks and kills the - // process with no managed stack (why the ASIO->none crash leaves no crash file). Logging each - // step means the log ends right AFTER the line for whichever native call died, pinpointing it. - onDiagnostic?.Invoke("asio close: unhooking callback"); - try { asio.AudioAvailable -= OnAudioAvailable; } catch { /* ignore */ } - // Let any ASIO buffer callback already in flight finish before we stop/release the driver, so - // the native close isn't racing a live callback (a common trigger for the crash). - System.Threading.Thread.Sleep(60); - onDiagnostic?.Invoke("asio close: stopping stream"); - try { asio.Stop(); } catch (Exception ex) { onDiagnostic?.Invoke($"asio close: stop threw {ex.GetType().Name}: {ex.Message}"); } - onDiagnostic?.Invoke("asio close: releasing driver (dispose)"); - try { asio.Dispose(); } catch (Exception ex) { onDiagnostic?.Invoke($"asio close: dispose threw {ex.GetType().Name}: {ex.Message}"); } - onDiagnostic?.Invoke("asio close: driver released cleanly"); + // Close the driver on the ASIO apartment thread — the same single, pumped thread it was opened + // on. That is the fix for the native access violation that used to kill the process here (it + // blew past these try/catch blocks with no managed stack). Step-by-step logging still pinpoints + // any native call that dies, and the callback is unhooked + drained before stop/dispose so the + // close isn't racing a live buffer callback (a common trigger for the crash). + apartment.Invoke(() => + { + onDiagnostic?.Invoke("asio close: unhooking callback"); + try { toClose.AudioAvailable -= OnAudioAvailable; } catch { /* ignore */ } + System.Threading.Thread.Sleep(60); + onDiagnostic?.Invoke("asio close: stopping stream"); + try { toClose.Stop(); } catch (Exception ex) { onDiagnostic?.Invoke($"asio close: stop threw {ex.GetType().Name}: {ex.Message}"); } + onDiagnostic?.Invoke("asio close: releasing driver (dispose)"); + try { toClose.Dispose(); } catch (Exception ex) { onDiagnostic?.Invoke($"asio close: dispose threw {ex.GetType().Name}: {ex.Message}"); } + onDiagnostic?.Invoke("asio close: driver released cleanly"); + }); asio = null; } uptime.Stop(); @@ -230,7 +235,11 @@ internal sealed class AsioCaptureBackend : ICaptureBackend recordChannelCount = 0; } - public void Dispose() => Stop(); + public void Dispose() + { + Stop(); + apartment.Dispose(); // shut down the dedicated ASIO thread last, after the driver is closed + } private static List ParseChannelPairIndices(IReadOnlyList specs) { diff --git a/src/RemSound.Sender/AudioSender.cs b/src/RemSound.Sender/AudioSender.cs index 3556035..f847040 100644 --- a/src/RemSound.Sender/AudioSender.cs +++ b/src/RemSound.Sender/AudioSender.cs @@ -358,16 +358,19 @@ public sealed class AudioSender : IDisposable if (!willUseAsio) { - // Mode no longer uses ASIO. Ed's approach (2026-07-15): do NOT close the driver here — closing - // it is the native call that crashes Audient (the "ASIO -> none kills the app" bug). Instead - // keep the persistent instance OPEN but PARKED: rewire its callback to a no-op and drop it to - // zero active pairs. That stops it feeding any lane WITHOUT a native close, so no crash. It's - // reused instantly if the user turns ASIO back on, and only truly closes on a driver CHANGE - // (below) or app exit — where the OS reclaims it anyway. + // ASIO is no longer selected (driver set to "(none)", or a WASAPI-only mode). RELEASE the + // driver now — close it on its dedicated apartment thread, which is safe as of the AsioApartment + // change — so the sound card is FREE for other applications (a DAW, etc.) instead of being held + // exclusively for the whole time RemSound is open. This replaces the old "park it open forever" + // workaround, which existed only because closing the Audient driver crashed. The composite is + // rebuilt without ASIO immediately after (RebuildEngineLocked); the composite borrows but never + // disposes this instance, so disposing it here can't pull the rug from a live engine. if (persistentAsio is not null) { - try { persistentAsio.SetCallback(static _ => { }); } catch { /* ignore */ } - try { persistentAsio.UpdateSources(Array.Empty()); } catch { /* ignore */ } + try { persistentAsio.Dispose(); } + catch (Exception ex) { diagnostic?.Invoke($"asio: release-on-idle dispose threw {ex.GetType().Name}: {ex.Message}"); } + persistentAsio = null; + persistentAsioDriverName = null; } return; }