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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user