Add lifecycle-churn soak test for runtime transitions
Local checkpoint - NOT for public release.
Ed's ask: the ASIO-toggle crash was a lifecycle-transition bug, and those take an
age to test by hand but regress easily. Automate tearing features down and adding
them back in every combination.
New self-test "Lifecycle churn" drives a REAL sender+receiver pair over loopback
through a matrix of runtime transitions and asserts no crash + bounded handles:
- audio mode changes
- send sources: empty / device loopback / process-loopback (own pid) / both,
reconfigured repeatedly so the process-loopback capture is torn down and rebuilt
many times (the mechanism that hard-crashed)
- receive outputs on/off
- per-peer pan + parametric EQ: none / volume-only / full pan+EQ chain
- codec (PCM/Opus) and tight-latency toggles
- a rapid WASAPI-only reconfigure loop (no mode changes, never abuses hardware)
Any unsafe teardown crashes the test process and fails the gate; it also checks
handle growth stays bounded across the churn (caught nothing leaking: +25).
Real ASIO hardware cycling is OPT-IN via REMSOUND_TEST_ASIO ("1" = first installed
driver, or a driver name) so routine builds never open - and possibly hang or lock -
a real interface. When set it adds a GENTLE ASIO on/off loop (4 toggles, 600ms settle
- some drivers stall for seconds on a quick close+reopen) with a process-loopback
source live across the toggle, i.e. the exact Ed repro. Verified on the Audient:
52 transitions, no crash.
Gate: 17/17 (default, WASAPI-only path).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ffeba4ad2b
commit
8f61eb800d
@@ -61,6 +61,7 @@ internal static class SelfTest
|
|||||||
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, "Per-application capture lifecycle", AppSendCaptureLifecycle);
|
||||||
|
RunStep(results, "Lifecycle churn (modes, sources, pan/EQ, send/receive)", LifecycleChurn);
|
||||||
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);
|
||||||
@@ -310,6 +311,150 @@ internal static class SelfTest
|
|||||||
return $"ran {cycles} start/stop/dispose cycles on pid {pid} with no crash";
|
return $"ran {cycles} start/stop/dispose cycles on pid {pid} with no crash";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Soak test for runtime lifecycle transitions — the class of bug that hard-crashed when Ed
|
||||||
|
/// toggled the ASIO driver mid-app-send. Drives a REAL sender+receiver pair over loopback through a
|
||||||
|
/// matrix of transitions in every combination: audio mode, send sources (incl. process-loopback torn
|
||||||
|
/// down and rebuilt), receive outputs, per-peer pan/EQ on and off, codec, and tight-latency — then a
|
||||||
|
/// rapid reconfigure loop. Any unsafe teardown crashes the whole test process and fails the gate;
|
||||||
|
/// otherwise it also checks handles don't run away across the churn. These transitions take an age to
|
||||||
|
/// cover by hand and regress easily, so they live here.
|
||||||
|
///
|
||||||
|
/// Real ASIO hardware cycling is OPT-IN via the REMSOUND_TEST_ASIO env var ("1" = first installed
|
||||||
|
/// driver, or a driver name) so routine builds never open — and possibly hang or lock — a real audio
|
||||||
|
/// interface. Without it the churn still covers the WASAPI + process-loopback teardown paths that
|
||||||
|
/// actually crashed.</summary>
|
||||||
|
private static string? LifecycleChurn()
|
||||||
|
{
|
||||||
|
const int port = 47844;
|
||||||
|
var ownPid = Process.GetCurrentProcess().Id;
|
||||||
|
var procOk = RemSound.Sender.ProcessLoopbackCapture.IsSupported;
|
||||||
|
|
||||||
|
string? deviceId = null;
|
||||||
|
try { deviceId = AudioDeviceCatalog.LoadOutputs().FirstOrDefault(o => o.DeviceId is not null)?.DeviceId; }
|
||||||
|
catch { /* headless / no devices — still churn modes, proc capture and DSP */ }
|
||||||
|
|
||||||
|
string? asioDriver = null;
|
||||||
|
var asioEnv = Environment.GetEnvironmentVariable("REMSOUND_TEST_ASIO");
|
||||||
|
if (!string.IsNullOrWhiteSpace(asioEnv))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var drivers = RemSound.Sender.AsioDeviceProbe.EnumerateDriverNames();
|
||||||
|
asioDriver = string.Equals(asioEnv, "1", StringComparison.Ordinal)
|
||||||
|
? drivers.FirstOrDefault()
|
||||||
|
: drivers.FirstOrDefault(d => string.Equals(d, asioEnv, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
catch { /* driver probe failed — fall back to WASAPI-only churn */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// DSP states: none, a plain volume cut, and a full pan + parametric-EQ chain.
|
||||||
|
var panEq = new PeerShaping { Volume = 0.7f, Pan = -0.3f, EqMode = PeerEqMode.Parametric16Band };
|
||||||
|
panEq.ParametricBands.Add(new ParametricBand { StartHz = 200, EndHz = 800, GainDb = 5 });
|
||||||
|
var dspStates = new PeerDspChain?[]
|
||||||
|
{
|
||||||
|
null,
|
||||||
|
PeerDspChain.Build(new PeerShaping { Volume = 0.5f }, enabled: true),
|
||||||
|
PeerDspChain.Build(panEq, enabled: true),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send spec sets: empty, device loopback, process-loopback (own pid), and both together — so the
|
||||||
|
// process-loopback capture is repeatedly torn down and rebuilt (the crash path).
|
||||||
|
var loop = deviceId is null ? null : new CaptureSourceSpec(deviceId, CaptureKind.Loopback, "loopback");
|
||||||
|
var proc = procOk ? new CaptureSourceSpec(ProcessLoopbackId.Format(ownPid), CaptureKind.ProcessLoopback, "self") : null;
|
||||||
|
var specSets = new List<List<CaptureSourceSpec>> { new() };
|
||||||
|
if (loop is not null) specSets.Add(new() { loop });
|
||||||
|
if (proc is not null) specSets.Add(new() { proc });
|
||||||
|
if (loop is not null && proc is not null) specSets.Add(new() { loop, proc });
|
||||||
|
|
||||||
|
var recvSets = new List<string[]> { Array.Empty<string>() };
|
||||||
|
if (deviceId is not null) recvSets.Add(new[] { deviceId });
|
||||||
|
|
||||||
|
var handlesBefore = SafeHandleCount();
|
||||||
|
var transitions = 0;
|
||||||
|
|
||||||
|
using (var receiver = new AudioReceiver())
|
||||||
|
using (var sender = new RemSound.Sender.AudioSender())
|
||||||
|
{
|
||||||
|
try { receiver.Start(port); }
|
||||||
|
catch (Exception ex) { return Skip($"could not bind test port {port}: {ex.Message}"); }
|
||||||
|
receiver.SetOutputDevices(Array.Empty<string>()); // decode only — never make a sound
|
||||||
|
sender.SetReceivers(new[] { new IPEndPoint(IPAddress.Loopback, port) });
|
||||||
|
sender.Start();
|
||||||
|
|
||||||
|
var modes = new List<(AudioMode mode, string? driver)> { (AudioMode.WasapiOnly, null) };
|
||||||
|
if (asioDriver is not null) modes.Add((AudioMode.BothIndependent, asioDriver));
|
||||||
|
var codecs = new[] { AudioTransportCodec.Pcm, AudioTransportCodec.Opus };
|
||||||
|
|
||||||
|
var i = 0;
|
||||||
|
foreach (var (mode, driver) in modes)
|
||||||
|
{
|
||||||
|
sender.SetAudioMode(mode, driver);
|
||||||
|
receiver.SetAudioMode(mode, driver);
|
||||||
|
foreach (var specs in specSets)
|
||||||
|
{
|
||||||
|
sender.Configure(specs);
|
||||||
|
foreach (var recv in recvSets) receiver.SetOutputDevices(recv);
|
||||||
|
foreach (var dsp in dspStates)
|
||||||
|
{
|
||||||
|
receiver.SetPeerDsp(IPAddress.Loopback, dsp);
|
||||||
|
sender.ConfigureCodec(codecs[i % codecs.Length]);
|
||||||
|
sender.SetTightLatency(i % 2 == 0);
|
||||||
|
Thread.Sleep(15);
|
||||||
|
transitions++;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rapid WASAPI-only reconfigure loop: hammer the process-loopback capture teardown/rebuild —
|
||||||
|
// the mechanism that actually crashed. No mode changes here, so it never abuses real hardware.
|
||||||
|
sender.SetAudioMode(AudioMode.WasapiOnly, null);
|
||||||
|
receiver.SetAudioMode(AudioMode.WasapiOnly, null);
|
||||||
|
for (var k = 0; k < 24; k++)
|
||||||
|
{
|
||||||
|
sender.Configure(specSets[k % specSets.Count]);
|
||||||
|
receiver.SetPeerDsp(IPAddress.Loopback, dspStates[k % dspStates.Length]);
|
||||||
|
Thread.Sleep(10);
|
||||||
|
transitions++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gentle ASIO on/off cycling (opt-in only), with a process-loopback source live across the
|
||||||
|
// toggle — the exact Ed repro. Generous settle time between toggles: some ASIO drivers
|
||||||
|
// (e.g. Audient) stall for seconds on a quick close+reopen, so we must NOT hammer them.
|
||||||
|
if (asioDriver is not null)
|
||||||
|
{
|
||||||
|
for (var k = 0; k < 4; k++)
|
||||||
|
{
|
||||||
|
var toBoth = k % 2 == 0;
|
||||||
|
var mode = toBoth ? AudioMode.BothIndependent : AudioMode.WasapiOnly;
|
||||||
|
var driver = toBoth ? asioDriver : null;
|
||||||
|
sender.SetAudioMode(mode, driver);
|
||||||
|
receiver.SetAudioMode(mode, driver);
|
||||||
|
if (proc is not null) sender.Configure(new List<CaptureSourceSpec> { proc });
|
||||||
|
Thread.Sleep(600);
|
||||||
|
transitions++;
|
||||||
|
}
|
||||||
|
sender.SetAudioMode(AudioMode.WasapiOnly, null);
|
||||||
|
receiver.SetAudioMode(AudioMode.WasapiOnly, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
sender.Stop();
|
||||||
|
receiver.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
var handleGrowth = SafeHandleCount() - handlesBefore;
|
||||||
|
Check(handleGrowth < 400, $"handle growth across the churn is too high ({handleGrowth}) — a transition may be leaking");
|
||||||
|
|
||||||
|
return $"{transitions} transitions; specSets={specSets.Count}, dsp={dspStates.Length}, "
|
||||||
|
+ $"asio={(asioDriver ?? "skipped (set REMSOUND_TEST_ASIO)")}, proc={procOk}, handles+{handleGrowth}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int SafeHandleCount()
|
||||||
|
{
|
||||||
|
try { using var p = Process.GetCurrentProcess(); p.Refresh(); return p.HandleCount; }
|
||||||
|
catch { return 0; }
|
||||||
|
}
|
||||||
|
|
||||||
/// <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>
|
||||||
|
|||||||
Reference in New Issue
Block a user