From 8f61eb800d8c572bb63d70b8bfb7ad53ab4b880c Mon Sep 17 00:00:00 2001 From: Ednunp <29843396+Ednunp@users.noreply.github.com> Date: Sun, 12 Jul 2026 11:31:50 +0100 Subject: [PATCH] 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 --- src/RemSound.App/SelfTest.cs | 145 +++++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index 6a1bdaf..1daa717 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -61,6 +61,7 @@ internal static class SelfTest RunStep(results, "Multi-output fan-out (both lanes)", FanOutToBothOutputs); RunStep(results, "Per-application send enumeration", AppSendEnumeration); 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, "Profile save and reload", ProfileRoundTrip); 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"; } + /// 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. + 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> { 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 { Array.Empty() }; + 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()); // 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 { 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; } + } + /// 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 /// bands, and the new recording default. All in-memory — the real config/profiles aren't touched.