From a46af2ee51e5cff15d05a2db963720554a74e1cc Mon Sep 17 00:00:00 2001 From: Ednunp <29843396+Ednunp@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:40:13 +0100 Subject: [PATCH] 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 --- src/RemSound.App/LogMaintenance.cs | 25 ++++++++++++ src/RemSound.App/MainForm.cs | 49 +++++++++++++++++++--- src/RemSound.App/Program.cs | 4 ++ src/RemSound.App/RemSoundLog.cs | 48 ++++++++++++++++++++-- src/RemSound.App/SelfTest.cs | 56 ++++++++++++++++++++++++++ src/RemSound.Receiver/AudioReceiver.cs | 26 ++++++++++++ 6 files changed, 198 insertions(+), 10 deletions(-) diff --git a/src/RemSound.App/LogMaintenance.cs b/src/RemSound.App/LogMaintenance.cs index 283ca53..8b1b764 100644 --- a/src/RemSound.App/LogMaintenance.cs +++ b/src/RemSound.App/LogMaintenance.cs @@ -79,6 +79,31 @@ internal static class LogMaintenance return deleted; } + /// Keep only the newest crash reports (crash-*.txt) in + /// , 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. + 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); diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index 51c9653..3fb76c4 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -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; + + /// 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. + 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; diff --git a/src/RemSound.App/Program.cs b/src/RemSound.App/Program.cs index 3074a8c..3d3a092 100644 --- a/src/RemSound.App/Program.cs +++ b/src/RemSound.App/Program.cs @@ -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 diff --git a/src/RemSound.App/RemSoundLog.cs b/src/RemSound.App/RemSoundLog.cs index fecdf7f..24147cd 100644 --- a/src/RemSound.App/RemSoundLog.cs +++ b/src/RemSound.App/RemSoundLog.cs @@ -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; + /// How many times the log has rolled to a fresh file this session (test seam). + internal int RollCount { get; private set; } /// 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 */ } } } + /// Account a just-written line and roll to a fresh file at the size cap. Must be + /// called holding . Rolling closes the current file with a pointer + /// line and clears , 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. + 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) diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index 044655f..7922cde 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -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"; } + /// 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. + private static string? LongRunHygiene() + { + // 1. Log rotation at the cap. Tiny cap via the internal seam; verify multiple real files. + var createdLogs = new List(); + 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"; + } + /// 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. diff --git a/src/RemSound.Receiver/AudioReceiver.cs b/src/RemSound.Receiver/AudioReceiver.cs index a565a06..ca13a66 100644 --- a/src/RemSound.Receiver/AudioReceiver.cs +++ b/src/RemSound.Receiver/AudioReceiver.cs @@ -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. + /// 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). + 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; } }