Resource phase 2: log rotation, streaming-scoped priority mode, crash cap, table ceiling

The four findings from the 2026-07-26 resource audit (the 'is anything abusing the
hardware over long runs' scan):

1. Diagnostic log rolls at a 50 MB cap: the current file closes with a pointer line
   and a fresh timestamped file continues the session, so a multi-day always-logging
   run produces a chain of capped files (aged out by the existing startup pruning)
   instead of one unbounded giant. Filenames gain a per-instance ordinal so rolls
   within one timestamp tick stay unique.

2. Priority mode is scoped to ACTUAL streaming: the levers (keep-awake, High
   priority, EcoQoS opt-out, fine timer, working-set lock) engage when audio is
   moving (send armed to a peer, or received audio hitting a live session) and
   release after a 30 s quiet hold-down - no flapping across brief silences. An
   idle-in-tray RemSound no longer holds the machine awake for hours. The service
   already scoped this way; the app now matches. The audio loops' own fine-timer
   scopes (the overnight-lag fix) are independent of Priority mode and untouched.

3. Crash reports are capped at the newest 10, pruned unconditionally at startup -
   nothing ever matched crash-*.txt before, so they accumulated forever.

4. The receiver's peer-security status cache gets a 256-entry ceiling (the one
   IP-keyed table with no eviction; only reachable unfiltered + WAN-exposed).

New gate step: log rolls at cap into a real file chain; crash pile pruned newest-10;
priority-scope decision matrix. Gate 64/64.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-26 23:40:13 +01:00
co-authored by Claude Fable 5
parent 9a18d18656
commit a46af2ee51
6 changed files with 198 additions and 10 deletions
+25
View File
@@ -79,6 +79,31 @@ internal static class LogMaintenance
return deleted;
}
/// <summary>Keep only the newest <paramref name="keep"/> crash reports (<c>crash-*.txt</c>) in
/// <paramref name="dir"/>, deleting older ones. Crash files were the one log-folder artefact
/// nothing ever cleaned (2026-07-26 resource audit — the *.log pruning never matched them), so
/// they accumulated forever. Runs unconditionally at startup: unlike log pruning this is not
/// opt-in, because ten reports diagnose a crash pattern just as well as a hundred. Returns how
/// many were deleted. Best-effort throughout.</summary>
public static int PruneCrashReports(string dir, int keep = 10)
{
var deleted = 0;
try
{
if (!Directory.Exists(dir)) return 0;
var crashes = Directory.EnumerateFiles(dir, "crash-*.txt")
.Select(f => new FileInfo(f))
.OrderByDescending(f => f.LastWriteTimeUtc)
.Skip(keep);
foreach (var old in crashes)
{
try { old.Delete(); deleted++; } catch { /* locked — leave it */ }
}
}
catch { /* give up quietly */ }
return deleted;
}
private static bool IsSamePath(string a, string? b) =>
!string.IsNullOrEmpty(b)
&& string.Equals(Path.GetFullPath(a), Path.GetFullPath(b), StringComparison.OrdinalIgnoreCase);
+43 -6
View File
@@ -1123,11 +1123,11 @@ public sealed partial class MainForm : Form
sender.SetTightLatency(true);
logFile.Event($"tight latency at startup: on (always) (audio mode={settings.LoadAudioMode()})");
// Priority mode (per-profile). Applies every PerformanceMode lever on first launch
// under this profile so the OS doesn't start coasting before the user has tabbed
// onto the Audio profile tab. The Audio-profile tab's checkbox handler re-applies
// on every toggle.
PerformanceMode.Apply(settings.LoadPriorityMode(), msg => logFile.Event(msg));
// Priority mode (per-profile) is SCOPED to actual streaming now — see
// EvaluatePriorityModeScope on the 1 Hz status tick. Nothing to engage at construction:
// the levers come up within a tick of audio moving and drop after the quiet hold-down.
// (Pre-2026-07-26 this applied every lever here and held them for the whole app
// lifetime, keeping an idle-in-tray machine awake and off deep power states.)
// Native-rate passthrough is automatic now (driven by codec, not a user setting):
// PCM+single-source-WASAPI-push = pass capture-device rate through to the wire;
// Opus = always pre-resample to 48 kHz (encoder is locked at 48 k); MixingEngine /
@@ -1370,6 +1370,7 @@ public sealed partial class MainForm : Form
// recovers. The individual Sync* methods are also hardened (see SafeSelectedItem).
try
{
EvaluatePriorityModeScope();
UpdateStatus();
SnapshotLogIfDue();
EnsureRequestedAudioRunning();
@@ -4074,7 +4075,10 @@ public sealed partial class MainForm : Form
priorityModeBox.CheckedChanged += (_, _) =>
{
settings.SavePriorityMode(priorityModeBox.Checked);
PerformanceMode.Apply(priorityModeBox.Checked, msg => logFile.Event(msg));
// Re-evaluate the streaming-scoped levers right away: unticking releases them
// immediately; ticking engages them now if audio is moving (else on the next tick
// once it is). See EvaluatePriorityModeScope.
EvaluatePriorityModeScope();
MarkProfileDirty();
};
@@ -5719,6 +5723,39 @@ public sealed partial class MainForm : Form
UpdateStatus();
}
// --- Priority-mode scoping (2026-07-26 resource audit) -------------------------------------
// The opt-in Priority mode's levers (keep-awake, High priority, EcoQoS opt-out, fine timer,
// working-set lock) used to engage at profile load and hold for the WHOLE app lifetime — a
// machine with RemSound idle in the tray was kept awake and off deep power states for
// nothing. They now engage only while audio is actually moving (send armed to at least one
// peer, or received audio hitting a live session) and release after a quiet hold-down, so
// brief silences and re-arms never flap the levers. The service already scoped this way; the
// app now matches. The audio loops' own fine-timer scopes (SystemTimerResolution — the
// overnight-lag fix) are independent of Priority mode and deliberately untouched.
internal static readonly TimeSpan PriorityModeHoldDown = TimeSpan.FromSeconds(30);
private DateTime lastStreamActivityUtc = DateTime.MinValue;
private bool priorityModeEngaged;
/// <summary>Pure decision core, pinned by the self-test: engage while the toggle is on AND
/// stream activity happened within the hold-down; everything else releases.</summary>
internal static bool PriorityModeShouldEngage(bool priorityModeOn, DateTime lastActivityUtc, DateTime nowUtc) =>
priorityModeOn && nowUtc - lastActivityUtc < PriorityModeHoldDown;
private void EvaluatePriorityModeScope()
{
var sendActive = connected && sender.IsRunning && !string.IsNullOrEmpty(activeAudioReceiverSignature);
var receiveActive = connected && receiver.AnyRecentAudio(TimeSpan.FromSeconds(3));
if (sendActive || receiveActive) lastStreamActivityUtc = DateTime.UtcNow;
var want = PriorityModeShouldEngage(settings.LoadPriorityMode(), lastStreamActivityUtc, DateTime.UtcNow);
if (want == priorityModeEngaged) return;
priorityModeEngaged = want;
logFile.Event(want
? "priority mode engaging (streaming active)"
: "priority mode releasing (no stream activity for the hold-down)");
PerformanceMode.Apply(want, msg => logFile.Event(msg));
}
private void HandleCapabilityChange()
{
if (!connected) return;
+4
View File
@@ -252,6 +252,10 @@ internal static class Program
// Best-effort: clear leftover update temp stages (and any relics of the old batch updater).
// We hold the single-instance lock here, so only the live copy does this — no sibling race.
RemSoundUpdater.CleanUpUpdateStages();
// Cap the crash-report pile (keep the newest 10) — the *.log pruning never matched
// crash-*.txt, so an unlucky install accumulated them forever. Unconditional, unlike the
// opt-in log pruning: ten reports diagnose a pattern as well as a hundred.
LogMaintenance.PruneCrashReports(AppConfig.LogsDirectory);
// F1 anywhere = open the bundled manual. Installed *before* the first ShowDialog so
// it works on the profile picker (the very first thing the user sees). The filter
+44 -4
View File
@@ -30,6 +30,17 @@ internal sealed class RemSoundLog : IDisposable
private StreamWriter? writer;
private bool fileCreationFailed;
// Bytes written to the CURRENT file, tracked so a multi-day session with logging on can't
// grow one file without bound (2026-07-26 resource audit: ~25-60 MB/day while streaming,
// startup-only pruning never touches the live file). At the cap the file is closed with a
// "continued in next file" line and a fresh timestamped file starts — the startup pruning
// then ages out the closed ones like any other log. Internal seam so the self-test can
// roll at a tiny size instead of writing 50 MB.
private long bytesWrittenToCurrentFile;
private int fileOrdinal;
internal long RollAfterBytes { get; set; } = 50L * 1024 * 1024;
/// <summary>How many times the log has rolled to a fresh file this session (test seam).</summary>
internal int RollCount { get; private set; }
/// <summary>Serialises all writes. StreamWriter is documented as non-thread-safe and
/// concurrent WriteLine calls from the mix loop, heartbeat thread, network listener,
/// UI thread and ASIO callback can interleave bytes into a single line in the output
@@ -70,12 +81,17 @@ internal sealed class RemSoundLog : IDisposable
{
var dir = RemSound.Core.AppConfig.LogsDirectory;
Directory.CreateDirectory(dir);
var name = $"RemSound-{Sanitize(Environment.MachineName)}-{Environment.ProcessId}-{DateTime.Now:yyyyMMdd-HHmmss}.log";
// The file ordinal makes a rolled file's name unique even when rolls land within the
// same timestamp tick (tiny caps in the self-test roll several times per millisecond);
// pid keeps concurrent instances apart as before.
fileOrdinal++;
var name = $"RemSound-{Sanitize(Environment.MachineName)}-{Environment.ProcessId}-{DateTime.Now:yyyyMMdd-HHmmss}-{fileOrdinal}.log";
Path = System.IO.Path.Combine(dir, name);
writer = new StreamWriter(new FileStream(Path, FileMode.CreateNew, FileAccess.Write, FileShare.ReadWrite))
{
AutoFlush = true,
};
bytesWrittenToCurrentFile = 0;
writer.WriteLine(SnapHeader);
writer.WriteLine($"EVT\t{DateTime.Now:o}\tlog started");
return true;
@@ -120,7 +136,7 @@ internal sealed class RemSoundLog : IDisposable
if (!EnsureFileOpenLocked()) return;
try
{
writer!.WriteLine(string.Join('\t',
var line = string.Join('\t',
"SNAP",
DateTime.Now.ToString("o"),
Environment.MachineName,
@@ -143,7 +159,9 @@ internal sealed class RemSoundLog : IDisposable
opusFecRecoveries,
opusUnrecoveredGaps,
maxLatencyMsAsio,
targetLatencyMsAsio));
targetLatencyMsAsio);
writer!.WriteLine(line);
AccountAndMaybeRollLocked(line.Length);
}
catch { /* swallow — log is best-effort */ }
}
@@ -157,12 +175,34 @@ internal sealed class RemSoundLog : IDisposable
if (!EnsureFileOpenLocked()) return;
try
{
writer!.WriteLine($"EVT\t{DateTime.Now:o}\t{message.Replace('\t', ' ').Replace('\n', ' ')}");
var line = $"EVT\t{DateTime.Now:o}\t{message.Replace('\t', ' ').Replace('\n', ' ')}";
writer!.WriteLine(line);
AccountAndMaybeRollLocked(line.Length);
}
catch { /* swallow */ }
}
}
/// <summary>Account a just-written line and roll to a fresh file at the size cap. Must be
/// called holding <see cref="writeGate"/>. Rolling closes the current file with a pointer
/// line and clears <see cref="writer"/>, so the next write lazily creates the successor —
/// a failed re-open then degrades to the existing best-effort behaviour. The cap bounds a
/// multi-day always-logging session to a chain of capped files (which the startup pruning
/// ages out) instead of one unbounded giant.</summary>
private void AccountAndMaybeRollLocked(int lineChars)
{
bytesWrittenToCurrentFile += lineChars + 2; // + newline
if (bytesWrittenToCurrentFile < RollAfterBytes) return;
try
{
writer?.WriteLine($"EVT\t{DateTime.Now:o}\tlog reached its size cap — continuing in a fresh file");
writer?.Dispose();
}
catch { /* swallow */ }
writer = null;
RollCount++;
}
public void Dispose()
{
lock (writeGate)
+56
View File
@@ -114,6 +114,7 @@ internal static class SelfTest
RunStep(results, "Service log discovery (newest activity log)", ServiceLogDiscovery);
RunStep(results, "Sealed remote control (auth + replay + skew) + nonce discipline", SealedRemoteControl);
RunStep(results, "Service folder lockdown args (cross-user LPE hardening)", ServiceDirHardeningArgs);
RunStep(results, "Long-run hygiene (log rotation, crash-report cap, priority-mode scope)", LongRunHygiene);
var failed = results.Count(r => r.Status == "FAIL");
var skipped = results.Count(r => r.Status == "SKIP");
@@ -2373,6 +2374,61 @@ internal static class SelfTest
return "sealed + replay/stale/wrong-key/plaintext all rejected; skew tolerated; nonces counter-based";
}
/// <summary>2026-07-26 resource audit trio: (1) the diagnostic log rolls to a fresh file at its
/// size cap, so a multi-day always-logging session can never grow one unbounded file; (2) crash
/// reports are capped at the newest N (nothing ever pruned crash-*.txt before); (3) the
/// priority-mode scope decision — levers only while streaming, released after the hold-down.</summary>
private static string? LongRunHygiene()
{
// 1. Log rotation at the cap. Tiny cap via the internal seam; verify multiple real files.
var createdLogs = new List<string>();
var log = new RemSoundLog { Enabled = true, RollAfterBytes = 400 };
try
{
for (var i = 0; i < 30; i++)
{
log.Event($"rotation self-test line {i} — padding padding padding padding");
if (log.Path is { } p && !createdLogs.Contains(p)) createdLogs.Add(p);
}
Check(log.RollCount >= 2, $"a 400-byte cap over 30 writes must roll at least twice (rolled {log.RollCount})");
Check(createdLogs.Count >= 3, "each roll must continue into a NEW timestamped file");
Check(createdLogs.All(File.Exists), "every rolled file must exist on disk (the chain, not one giant)");
}
finally
{
log.Dispose();
foreach (var f in createdLogs) { try { File.Delete(f); } catch { } }
}
// 2. Crash-report cap: 14 fake reports → newest 10 survive.
var tmp = Path.Combine(Path.GetTempPath(), "remsound-selftest-crash-" + Guid.NewGuid().ToString("N"));
try
{
Directory.CreateDirectory(tmp);
for (var i = 0; i < 14; i++)
{
var f = Path.Combine(tmp, $"crash-2026072{i % 10}-{i:D6}.txt");
File.WriteAllText(f, "fake");
File.SetLastWriteTimeUtc(f, DateTime.UtcNow.AddDays(-14 + i));
}
var deleted = LogMaintenance.PruneCrashReports(tmp, keep: 10);
var left = Directory.GetFiles(tmp, "crash-*.txt");
Check(deleted == 4 && left.Length == 10, $"14 crash files pruned to the newest 10 (deleted {deleted}, left {left.Length})");
var oldest = left.Select(f => File.GetLastWriteTimeUtc(f)).Min();
Check(oldest > DateTime.UtcNow.AddDays(-11), "the OLDEST files must be the ones pruned");
}
finally { try { Directory.Delete(tmp, recursive: true); } catch { } }
// 3. Priority-mode scope decision (the pure core the 1 Hz tick drives).
var now = new DateTime(2026, 7, 26, 4, 0, 0, DateTimeKind.Utc);
Check(MainForm.PriorityModeShouldEngage(true, now.AddSeconds(-1), now), "streaming + toggle on → engaged");
Check(MainForm.PriorityModeShouldEngage(true, now.AddSeconds(-20), now), "a quiet spell inside the hold-down must NOT release (no lever flapping)");
Check(!MainForm.PriorityModeShouldEngage(true, now.AddMinutes(-5), now), "idle past the hold-down → released (idle-in-tray no longer holds the machine awake)");
Check(!MainForm.PriorityModeShouldEngage(false, now.AddSeconds(-1), now), "toggle off → never engaged, even while streaming");
return "log rolls at cap into a file chain; crash pile capped at newest 10; levers scoped to streaming";
}
/// <summary>The icacls contract for the service-folder lockdown (cross-user LPE fix): inheritance
/// stripped (kills the CREATOR OWNER hole), exactly SYSTEM + Administrators + the installing user
/// granted, recursive. And the SID recording round-trips with garbage rejected.</summary>
+26
View File
@@ -125,6 +125,22 @@ public sealed class AudioReceiver : IDisposable
/// so a heartbeat blip while audio keeps flowing never fires a false "disconnect" cue, and
/// the connect cue can fire the moment audio starts. Returns false when not receiving (no
/// sessions), so the caller falls back to the heartbeat for send-only setups. 2026-05-31.</summary>
/// <summary>True when audio from ANY peer has hit a live session within the window. The app's
/// Priority-mode scoping reads this as its "receiving right now" signal (2026-07-26 resource
/// audit — the power levers now engage only while audio actually moves).</summary>
public bool AnyRecentAudio(TimeSpan within)
{
var cutoff = DateTime.UtcNow - within;
lock (sessionsLock)
{
foreach (var session in sessions.Values)
{
if (session.LastWriteUtc >= cutoff) return true;
}
}
return false;
}
public bool IsAudioFlowingFrom(IPAddress address, TimeSpan within)
{
var cutoff = DateTime.UtcNow - within;
@@ -300,6 +316,16 @@ public sealed class AudioReceiver : IDisposable
}
lock (securityLock)
{
// Ceiling (2026-07-26 resource audit): keyed by source IP with no eviction, this was
// the one table that could creep over a very long run if the receiver sits unfiltered
// (null allow-list) on a WAN-exposed port collecting one entry per distinct sender.
// It's a status cache, so the cheap fix is honest: at the cap, drop entries for
// strangers rather than grow. Normal use (allow-listed peers) never gets near 256.
if (peerSecurity.Count >= 256 && !peerSecurity.ContainsKey(address))
{
peerSecurity.Clear();
diagnosticSink?.Invoke("peer-security cache hit its ceiling (256 distinct senders) — cleared; statuses repopulate from live format packets");
}
peerSecurity[address] = status;
}
}