ASIO: own the whole driver lifecycle on one pumped STA thread; free the card when ASIO is deselected

The problem: we stopped ever closing the ASIO driver because closing it could
crash the process natively (Audient threw an access violation on Dispose with no
managed stack). Keeping it open dodged the crash but held the sound card
exclusively for the whole time RemSound was running - so no second ASIO driver
could be used, and no other app could touch that card, even when RemSound wasn't
playing through it.

The cause: ASIO drivers are COM objects that want every control call (create /
init / start / stop / dispose) on ONE thread, with a live message pump to service
the messages the driver posts during init/reset/close. We were calling them from
whatever thread hit Start/Stop, with only a Sleep() before the close - so the
close ran with no pump and often on the wrong thread, and took the process down.

The fix: AsioApartment - a dedicated background STA thread running a real Windows
message pump. Every AsioOut control call in AsioCaptureBackend now goes through
apartment.Invoke(...): the create/init/play in Start, and the unhook/Sleep/Stop/
Dispose in StopInternal. The driver gets one stable home thread and a live pump,
so it can be closed cleanly. The real-time audio callback is untouched - it still
runs on the driver's own thread.

Because closing is safe again, EnsurePersistentAsioLocked now RELEASES the driver
(Dispose + null) when ASIO is deselected, instead of parking it open. The composite
backend borrows the persistent ASIO but never disposes it, so releasing here is the
single owner freeing the card - which lets another ASIO driver (or another app) use
the card once RemSound is off ASIO.

Validated: the ASIO-enabled gate churned the real Audient driver through 52
open/close transitions with no crash and bounded handles (+27). Live hardware
streaming still needs Ed's confirmation.

Test: "ASIO apartment thread" self-test asserts work runs on one dedicated STA
thread (not the caller's), exceptions propagate to the caller, and the apartment
survives a work item throwing. Gate 43/43.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-17 16:54:12 +01:00
co-authored by Claude Opus 4.8
parent d002130402
commit 3d4d5af326
4 changed files with 219 additions and 53 deletions
+27
View File
@@ -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
}
}
/// <summary>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).</summary>
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";
}
/// <summary>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