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;
}
}