ASIO: full lifecycle breadcrumbs so a crash (or clean run) is legible in the log

If the new apartment ever DOES still crash on Ed's Audient hardware, the crash is a
native access violation that leaves no managed stack - so the only way to see where
it died is a breadcrumb written to disk immediately before each native call. The log
already runs with AutoFlush on, so each line is on disk before the next native call.

The close path already logged step-by-step (unhook / stop / dispose / released). This
makes the OPEN path symmetric and gives the apartment thread its own voice - all
through the normal gated Event() sink (no gate bypass; logging stays off when the user
has it off):

- Open: "asio open: creating driver" -> "init record+playback" -> "starting stream
  (play)" -> "stream running". A native death names the exact stage.
- Apartment: "thread up (STA, managed id N)" on construct; "thread down (clean)" or
  "thread did not join in 2s (leaked)" on dispose - confirms the dedicated STA thread
  actually came up and tore down.

So a clean run reads: apartment up -> open steps -> [use] -> close steps -> released
cleanly -> apartment down. A crash truncates at the exact native call that failed.

Gate 43/43 (ASIO churn over the real Audient driver, 52 open/close transitions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-17 17:00:58 +01:00
co-authored by Claude Opus 4.8
parent 3d4d5af326
commit 7b1eac8df4
2 changed files with 18 additions and 3 deletions
+7 -2
View File
@@ -23,6 +23,7 @@ internal sealed class AsioApartment : IDisposable
private readonly object queueGate = new();
private readonly Queue<WorkItem> queue = new();
private readonly AutoResetEvent workAvailable = new(false);
private readonly Action<string>? log;
private volatile bool shutdown;
private sealed class WorkItem
@@ -32,12 +33,14 @@ internal sealed class AsioApartment : IDisposable
public Exception? Error;
}
public AsioApartment(string name = "asio-control")
public AsioApartment(string name = "asio-control", Action<string>? log = null)
{
this.log = log;
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
log?.Invoke($"asio apartment: thread up (STA, managed id {thread.ManagedThreadId})");
}
/// <summary>Run <paramref name="action"/> on the apartment thread and block until it finishes,
@@ -90,7 +93,9 @@ internal sealed class AsioApartment : IDisposable
if (shutdown) return;
shutdown = true;
workAvailable.Set();
try { if (thread.IsAlive && Thread.CurrentThread != thread) thread.Join(2000); } catch { /* leaked thread beats a hang */ }
var joined = true; // already-terminated or self-call counts as clean; only a real timeout is a leak
try { if (thread.IsAlive && Thread.CurrentThread != thread) joined = thread.Join(2000); } catch { /* leaked thread beats a hang */ }
log?.Invoke(joined ? "asio apartment: thread down (clean)" : "asio apartment: thread did not join in 2s (leaked)");
try { workAvailable.Dispose(); } catch { }
try { started.Dispose(); } catch { }
}
+11 -1
View File
@@ -51,7 +51,8 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
// 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();
// Created in the ctor so it can announce its own thread up/down through the same diagnostic sink.
private readonly AsioApartment apartment;
private List<int> activeChannelPairIndices = [];
private int recordChannelCount;
private float[] mixScratch = new float[1024];
@@ -83,6 +84,7 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
this.driverName = driverName;
this.onMixedSamples = onMixedSamples;
this.onDiagnostic = onDiagnostic;
apartment = new AsioApartment($"asio-control:{driverName}", onDiagnostic);
}
/// <summary>
@@ -141,6 +143,11 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
// channel driver becomes a throw so the catch below runs the same StopInternal cleanup.
apartment.Invoke(() =>
{
// Step-by-step breadcrumbs, symmetric with the close path in StopInternal. If a
// native call here takes the process down (as the driver used to do on close), the
// last line in the log names exactly which stage died — with AutoFlush on, each line
// is on disk before the next native call runs.
onDiagnostic?.Invoke($"asio open: creating driver \"{driverName}\"");
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 —
@@ -160,10 +167,13 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
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})");
onDiagnostic?.Invoke($"asio open: init record+playback ({recordChannelCount} ch @ {MixSampleRate} Hz)");
asio.InitRecordAndPlayback(null, recordChannelCount, MixSampleRate);
asio.AudioAvailable += OnAudioAvailable;
captureFormat = $"{MixSampleRate} Hz, {recordChannelCount} input channel(s), 32-bit float (ASIO)";
onDiagnostic?.Invoke("asio open: starting stream (play)");
asio.Play();
onDiagnostic?.Invoke("asio open: stream running");
});
uptime.Restart();
onDiagnostic?.Invoke($"asio capture started \"{driverName}\" {captureFormat}; pairs={string.Join(",", activeChannelPairIndices)}");