TASK 55 slice 1: the startup log is size-capped
The creator (2026-10-09): 'we need some sort of log-rotation so that we don't inadvertently fill up a user's drive.' startup.log was uncapped - 23MB and growing every few seconds, because FfmpegEncoder logs every non-progress ffmpeg stderr line. The policy lives in a new Helpers/LogRotation.cs as a pure function over a path (RollIfNeeded(logPath, maxBytes, retainedFiles)), so AppLog's 156 call sites keep their surface and the rule is testable on its own. At 5MB the live file rolls to startup.1.log and one rolled copy is kept - the FILE SET is bounded, not just the live file, so a long stream cannot grow the log without limit. A failed rotation degrades to the old behaviour instead of taking the app down. Pattern reference (cited): Serilog's file sink pairs fileSizeLimitBytes (the cap) with retainedFileCountLimit (how many rolled files to keep) - https://github.com/serilog/serilog-sinks-file Guard: ytLive.Tests/AppLogRotationTests.cs - the ONE test drives the real AppLog.Write against a temp dir (two internal seams: PathOverride, MaxBytes) and asserts the roll, the fresh live file, and that after a SECOND roll the directory still holds exactly startup.log + startup.1.log. Also fixed a stale catalog row: TASK 12 (master limiter) shipped 2026-08-15 and was still marked 'Queued'.
This commit is contained in:
+19
-3
@@ -10,19 +10,35 @@ namespace ytLive.Helpers;
|
||||
/// </summary>
|
||||
public static class AppLog
|
||||
{
|
||||
private static readonly string LogPath = Path.Combine(
|
||||
/// <summary>The cap for the live log file. Past this, it rolls to <c>startup.1.log</c>.</summary>
|
||||
private const long DefaultMaxBytes = 5 * 1024 * 1024;
|
||||
|
||||
/// <summary>How many rolled copies to keep (startup.1.log, startup.2.log, …).</summary>
|
||||
private const int RetainedFiles = 1;
|
||||
|
||||
private static readonly string DefaultLogPath = Path.Combine(
|
||||
InstanceProfile.DataRoot, "startup.log");
|
||||
|
||||
private static readonly object Gate = new();
|
||||
|
||||
/// <summary>Test seam: point the log somewhere else. Null = the real profile's log.</summary>
|
||||
internal static string? PathOverride { get; set; }
|
||||
|
||||
/// <summary>Test seam: the cap that triggers a roll.</summary>
|
||||
internal static long MaxBytes { get; set; } = DefaultMaxBytes;
|
||||
|
||||
private static string LogPath => PathOverride ?? DefaultLogPath;
|
||||
|
||||
public static void Write(string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!);
|
||||
File.AppendAllText(LogPath, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}{Environment.NewLine}");
|
||||
var path = LogPath;
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
LogRotation.RollIfNeeded(path, MaxBytes, RetainedFiles);
|
||||
File.AppendAllText(path, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}{Environment.NewLine}");
|
||||
}
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.IO;
|
||||
|
||||
namespace ytLive.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Size-capped log rotation.
|
||||
/// <para>When a log file grows past a cap, it is rolled to <c><name>.1.log</c> and a
|
||||
/// fresh one is started, keeping a bounded number of rolled files. The pattern is the
|
||||
/// one every sized logger uses — Serilog's file sink pairs <c>fileSizeLimitBytes</c>
|
||||
/// (the cap) with <c>retainedFileCountLimit</c> (how many rolled files to keep):
|
||||
/// https://github.com/serilog/serilog-sinks-file</para>
|
||||
/// <para><b>Why it lives here and not in the logger:</b> <see cref="AppLog"/>'s call surface
|
||||
/// (156 call sites) must not change, and the rotation rule is worth testing on its own —
|
||||
/// so the policy is a pure function over a path, and <see cref="AppLog"/> just calls it.</para>
|
||||
/// </summary>
|
||||
public static class LogRotation
|
||||
{
|
||||
/// <summary>
|
||||
/// Rolls <paramref name="logPath"/> if it has reached <paramref name="maxBytes"/>,
|
||||
/// keeping <paramref name="retainedFiles"/> rolled copies
|
||||
/// (<c>name.1.log</c>, <c>name.2.log</c>, …). Does nothing when the file is missing
|
||||
/// or still under the cap.
|
||||
/// <para>Never throws: a failed rotation must not take the app down — the log just
|
||||
/// keeps growing, which is the old behaviour.</para>
|
||||
/// </summary>
|
||||
public static void RollIfNeeded(string logPath, long maxBytes, int retainedFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(logPath);
|
||||
if (!info.Exists || info.Length < maxBytes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Oldest first: name.1 → name.2 → … → name.N, then the live file → name.1.
|
||||
for (var i = retainedFiles; i >= 1; i--)
|
||||
{
|
||||
var source = i == 1 ? logPath : RolledPath(logPath, i - 1);
|
||||
var target = RolledPath(logPath, i);
|
||||
if (!File.Exists(source))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
File.Move(source, target, overwrite: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Never let logging itself crash the app.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The rolled path for a given index: <c>startup.log</c> → <c>startup.1.log</c>.</summary>
|
||||
public static string RolledPath(string logPath, int index)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(logPath) ?? string.Empty;
|
||||
var name = Path.GetFileNameWithoutExtension(logPath);
|
||||
var extension = Path.GetExtension(logPath);
|
||||
return Path.Combine(directory, $"{name}.{index}{extension}");
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -6,7 +6,8 @@ Cross-cutting utilities. See [`schema.md`](../schema.md) for the memory-map conv
|
||||
|------|---------|
|
||||
| `ViewModelBase.cs` | `INotifyPropertyChanged` + `SetProperty<T>()` — base for all ViewModels |
|
||||
| `RelayCommand.cs` | `ICommand` implementation for all button actions |
|
||||
| `AppLog.cs` | File logger to `%APPDATA%\ytLlive\startup.log`; startup checkpoints + unhandled-exception capture (the crash this map is named for) |
|
||||
| `AppLog.cs` | File logger to `%APPDATA%\ytLlive\startup.log`; startup checkpoints + unhandled-exception capture (the crash this map is named for). Size-capped: rolls to `startup.1.log` at 5 MB (TASK 55) |
|
||||
| `LogRotation.cs` | The size-capped-rotation policy as a pure function over a path: roll `startup.log` → `startup.1.log` when it passes the cap, keeping N rolled copies. Separate from `AppLog` so the logger's 156-call-site surface never changes and the rule is testable alone (TASK 55) |
|
||||
| `FocusPreservingListBox.cs` | `ListBox` subclass that keeps selection/focus coherent when the selected item is deleted; `IsSelectable` predicate skips hidden scenes; drag-reorder guard |
|
||||
| `OAuthCredentials.cs` | Baked-in Google OAuth client ID/secret (desktop app; loopback callback) |
|
||||
| `TokenStore.cs` | DPAPI-protected OAuth session persistence (`%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope); `Save`/`Load`/`Clear` — sign-in survives restarts |
|
||||
|
||||
Reference in New Issue
Block a user