From fd981211c0b269584535234a615a09708f0ba760 Mon Sep 17 00:00:00 2001 From: Ednunp <29843396+Ednunp@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:09:04 +0100 Subject: [PATCH] Tests: recording churn + env-gated soak, leak-accurate handle checks Local checkpoint - NOT for public release. - New "Recording churn / soak": rapidly start/feed/stop/dispose recordings across all four formats, asserting handles stay bounded - catches recorder/encoder lifecycle leaks a single recording wouldn't. - Both the recording churn and the lifecycle churn now honour REMSOUND_TEST_SOAK=: unset, one quick round in the gate; set, they hammer until the deadline for a real minutes-long soak. Verified at 8s: 312 lifecycle transitions + 16 record cycles. - Handle checks now force GC + finalizers to settle before measuring, so the reading reflects genuine leaks, not collection lag under fast churn (which would false-fail a long soak). Post-settle the churn shows NEGATIVE handle growth - no leaks. Gate 22/22. Co-Authored-By: Claude Opus 4.8 --- src/RemSound.App/SelfTest.cs | 85 ++++++++++++++++++++++++++++++------ 1 file changed, 72 insertions(+), 13 deletions(-) diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index dfd7e36..c813176 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -66,6 +66,7 @@ internal static class SelfTest RunStep(results, "Service send host (headless stream + yield)", ServiceSendHostStream); RunStep(results, "Service registration args", ServiceRegistrationArgs); RunStep(results, "Recording engine (all formats + source gate + mono)", RecordingEngine); + RunStep(results, "Recording churn / soak", RecordingChurn); RunStep(results, "v5 settings and shaping round-trip", V5ConfigRoundTrip); RunStep(results, "Profile save and reload", ProfileRoundTrip); RunStep(results, "What's-new update marker", WhatsNewMarkerRoundTrip); @@ -389,26 +390,35 @@ internal static class SelfTest if (asioDriver is not null) modes.Add((AudioMode.BothIndependent, asioDriver)); var codecs = new[] { AudioTransportCodec.Pcm, AudioTransportCodec.Opus }; + // Soak: with REMSOUND_TEST_SOAK= set, repeat the whole transition matrix until the + // deadline (a real minutes-long soak); unset, it runs the matrix once in the normal gate. + int.TryParse(Environment.GetEnvironmentVariable("REMSOUND_TEST_SOAK"), out var soakSeconds); + var deadline = Environment.TickCount64 + Math.Max(0, soakSeconds) * 1000L; + var i = 0; - foreach (var (mode, driver) in modes) + do { - sender.SetAudioMode(mode, driver); - receiver.SetAudioMode(mode, driver); - foreach (var specs in specSets) + foreach (var (mode, driver) in modes) { - sender.Configure(specs); - foreach (var recv in recvSets) receiver.SetOutputDevices(recv); - foreach (var dsp in dspStates) + sender.SetAudioMode(mode, driver); + receiver.SetAudioMode(mode, driver); + foreach (var specs in specSets) { - receiver.SetPeerDsp(IPAddress.Loopback, dsp); - sender.ConfigureCodec(codecs[i % codecs.Length]); - sender.SetTightLatency(i % 2 == 0); - Thread.Sleep(15); - transitions++; - i++; + 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++; + } } } } + while (Environment.TickCount64 < deadline); // 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. @@ -446,6 +456,7 @@ internal static class SelfTest receiver.Stop(); } + SettleForLeakCheck(); var handleGrowth = SafeHandleCount() - handlesBefore; Check(handleGrowth < 400, $"handle growth across the churn is too high ({handleGrowth}) — a transition may be leaking"); @@ -459,6 +470,20 @@ internal static class SelfTest catch { return 0; } } + /// Force pending finalizers/GC and give the OS a moment to release handles, so a leak check + /// after a churn reflects genuine leaks rather than not-yet-collected disposables (which pile up + /// under fast churn and would otherwise false-flag a long soak). + private static void SettleForLeakCheck() + { + for (var i = 0; i < 3; i++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + Thread.Sleep(60); + } + } + /// Records a short synthetic tone to disk in every output format and checks each file is /// written with real content — the thing Ed can't face testing by ear on every change. Drives the /// real writer (WAV / MP3 / OGG-Opus / FLAC encoders and their native @@ -508,6 +533,40 @@ internal static class SelfTest finally { try { Directory.Delete(temp, recursive: true); } catch { /* best-effort */ } } } + /// Load/soak: rapidly start, feed, stop and dispose recordings across every format, checking + /// nothing leaks handles across the churn — catches recorder/encoder lifecycle leaks and races that a + /// single recording wouldn't surface. Set the env var REMSOUND_TEST_SOAK=<seconds> to keep + /// hammering for that long (a real soak run); unset it does one quick round in the normal gate. + private static string? RecordingChurn() + { + var temp = Path.Combine(Path.GetTempPath(), "remsound-recchurn-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(temp); + try + { + int.TryParse(Environment.GetEnvironmentVariable("REMSOUND_TEST_SOAK"), out var soakSeconds); + var deadline = Environment.TickCount64 + Math.Max(0, soakSeconds) * 1000L; + var formats = new[] { RecordingFileFormat.Wav, RecordingFileFormat.Mp3, RecordingFileFormat.Ogg, RecordingFileFormat.Flac }; + var handlesBefore = SafeHandleCount(); + var cycles = 0; + do + { + foreach (var fmt in formats) + { + var path = Path.Combine(temp, $"c{cycles}.{AudioRecorder.ExtensionFor(fmt)}"); + RecordTone(temp, path, new RecordingSettings { FileFormat = fmt, Source = RecordingSource.Both }, feedReceived: true, feedSent: true); + try { File.Delete(path); } catch { /* best-effort */ } + cycles++; + } + } + while (Environment.TickCount64 < deadline); + SettleForLeakCheck(); + var growth = SafeHandleCount() - handlesBefore; + Check(growth < 500, $"handle growth across {cycles} record cycles is too high ({growth}) — a recorder may be leaking"); + return $"{cycles} record start/stop/dispose cycles; handles+{growth}" + (soakSeconds > 0 ? $"; soak={soakSeconds}s" : ""); + } + finally { try { Directory.Delete(temp, recursive: true); } catch { /* best-effort */ } } + } + // Records ~0.4s of a 440 Hz tone with the given settings to an explicit path and returns the file // size. Feeds the recorder's audio-thread taps directly, pacing so the writer thread drains the ring. private static long RecordTone(string temp, string path, RecordingSettings settings, bool feedReceived, bool feedSent)