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=<seconds>:
  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 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-12 16:09:04 +01:00
co-authored by Claude Opus 4.8
parent 47e4159cf9
commit fd981211c0
+72 -13
View File
@@ -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=<seconds> 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; }
}
/// <summary>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).</summary>
private static void SettleForLeakCheck()
{
for (var i = 0; i < 3; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
Thread.Sleep(60);
}
}
/// <summary>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 <see cref="AudioRecorder"/> 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 */ } }
}
/// <summary>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=&lt;seconds&gt; to keep
/// hammering for that long (a real soak run); unset it does one quick round in the normal gate.</summary>
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)