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 send host (headless stream + yield)", ServiceSendHostStream);
RunStep(results, "Service registration args", ServiceRegistrationArgs); RunStep(results, "Service registration args", ServiceRegistrationArgs);
RunStep(results, "Recording engine (all formats + source gate + mono)", RecordingEngine); 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, "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);
@@ -389,26 +390,35 @@ internal static class SelfTest
if (asioDriver is not null) modes.Add((AudioMode.BothIndependent, asioDriver)); if (asioDriver is not null) modes.Add((AudioMode.BothIndependent, asioDriver));
var codecs = new[] { AudioTransportCodec.Pcm, AudioTransportCodec.Opus }; 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; var i = 0;
foreach (var (mode, driver) in modes) do
{ {
sender.SetAudioMode(mode, driver); foreach (var (mode, driver) in modes)
receiver.SetAudioMode(mode, driver);
foreach (var specs in specSets)
{ {
sender.Configure(specs); sender.SetAudioMode(mode, driver);
foreach (var recv in recvSets) receiver.SetOutputDevices(recv); receiver.SetAudioMode(mode, driver);
foreach (var dsp in dspStates) foreach (var specs in specSets)
{ {
receiver.SetPeerDsp(IPAddress.Loopback, dsp); sender.Configure(specs);
sender.ConfigureCodec(codecs[i % codecs.Length]); foreach (var recv in recvSets) receiver.SetOutputDevices(recv);
sender.SetTightLatency(i % 2 == 0); foreach (var dsp in dspStates)
Thread.Sleep(15); {
transitions++; receiver.SetPeerDsp(IPAddress.Loopback, dsp);
i++; 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 — // 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. // 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(); receiver.Stop();
} }
SettleForLeakCheck();
var handleGrowth = SafeHandleCount() - handlesBefore; var handleGrowth = SafeHandleCount() - handlesBefore;
Check(handleGrowth < 400, $"handle growth across the churn is too high ({handleGrowth}) — a transition may be leaking"); 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; } 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 /// <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 /// 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 /// 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 */ } } 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 // 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. // 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) private static long RecordTone(string temp, string path, RecordingSettings settings, bool feedReceived, bool feedSent)