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:
co-authored by
Claude Opus 4.8
parent
47e4159cf9
commit
fd981211c0
@@ -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=<seconds> 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)
|
||||
|
||||
Reference in New Issue
Block a user