From f1225303081cf8e63898fb6139a490e55e32542b Mon Sep 17 00:00:00 2001 From: Shao-Chicken Date: Sat, 15 Aug 2026 13:11:40 +0800 Subject: [PATCH] Bound Remote Server log growth --- ASCOM Remote.sln | 14 ++ .../Remote Server.Tests.csproj | 28 +++ Remote Server.Tests/TraceLoggerPlusTests.cs | 194 ++++++++++++++++++ Remote Server/ServerForm.cs | 22 +- Remote Server/TraceLoggerPlus.cs | 131 ++++++++++-- ...2026-08-15-bounded-log-retention-design.md | 74 +++++++ .../2026-08-15-bounded-log-retention-plan.md | 32 +++ 7 files changed, 467 insertions(+), 28 deletions(-) create mode 100644 Remote Server.Tests/Remote Server.Tests.csproj create mode 100644 Remote Server.Tests/TraceLoggerPlusTests.cs create mode 100644 docs/plans/2026-08-15-bounded-log-retention-design.md create mode 100644 docs/plans/2026-08-15-bounded-log-retention-plan.md diff --git a/ASCOM Remote.sln b/ASCOM Remote.sln index 29de99f..de9bd24 100644 --- a/ASCOM Remote.sln +++ b/ASCOM Remote.sln @@ -14,6 +14,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Remote Server Key.snk = Remote Server Key.snk EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Remote Server.Tests", "Remote Server.Tests\Remote Server.Tests.csproj", "{145221E3-AFA6-4526-BD3F-FF6E53608185}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -48,6 +50,18 @@ Global {15D5962B-2A5E-4B07-98B6-D4D4A888F9B7}.Release|x64.Build.0 = Release|x64 {15D5962B-2A5E-4B07-98B6-D4D4A888F9B7}.Release|x86.ActiveCfg = Release|x86 {15D5962B-2A5E-4B07-98B6-D4D4A888F9B7}.Release|x86.Build.0 = Release|x86 + {145221E3-AFA6-4526-BD3F-FF6E53608185}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {145221E3-AFA6-4526-BD3F-FF6E53608185}.Debug|Any CPU.Build.0 = Debug|Any CPU + {145221E3-AFA6-4526-BD3F-FF6E53608185}.Debug|x64.ActiveCfg = Debug|Any CPU + {145221E3-AFA6-4526-BD3F-FF6E53608185}.Debug|x64.Build.0 = Debug|Any CPU + {145221E3-AFA6-4526-BD3F-FF6E53608185}.Debug|x86.ActiveCfg = Debug|Any CPU + {145221E3-AFA6-4526-BD3F-FF6E53608185}.Debug|x86.Build.0 = Debug|Any CPU + {145221E3-AFA6-4526-BD3F-FF6E53608185}.Release|Any CPU.ActiveCfg = Release|Any CPU + {145221E3-AFA6-4526-BD3F-FF6E53608185}.Release|Any CPU.Build.0 = Release|Any CPU + {145221E3-AFA6-4526-BD3F-FF6E53608185}.Release|x64.ActiveCfg = Release|Any CPU + {145221E3-AFA6-4526-BD3F-FF6E53608185}.Release|x64.Build.0 = Release|Any CPU + {145221E3-AFA6-4526-BD3F-FF6E53608185}.Release|x86.ActiveCfg = Release|Any CPU + {145221E3-AFA6-4526-BD3F-FF6E53608185}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Remote Server.Tests/Remote Server.Tests.csproj b/Remote Server.Tests/Remote Server.Tests.csproj new file mode 100644 index 0000000..39eb85d --- /dev/null +++ b/Remote Server.Tests/Remote Server.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0-windows + enable + enable + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/Remote Server.Tests/TraceLoggerPlusTests.cs b/Remote Server.Tests/TraceLoggerPlusTests.cs new file mode 100644 index 0000000..5799424 --- /dev/null +++ b/Remote Server.Tests/TraceLoggerPlusTests.cs @@ -0,0 +1,194 @@ +using ASCOM.Remote; +using System.Text.RegularExpressions; +using Xunit; + +namespace Remote_Server.Tests; + +public sealed class TraceLoggerPlusTests : IDisposable +{ + private readonly string testRoot = Path.Combine( + Path.GetTempPath(), + $"ASCOMRemoteTests-{Guid.NewGuid():N}"); + + [Fact] + public void LogMessage_RollsAutomaticallyNamedFileAtConfiguredSize() + { + TraceLoggerPlus logger = CreateLogger("SizeBounded"); + try + { + logger.MaximumLogFileSizeBytes = 1; + logger.MaximumRetainedLogFiles = 10; + + logger.LogMessage("Test", "First message"); + logger.LogMessage("Test", "Second message"); + + string[] logFiles = FindLogs("SizeBounded"); + + Assert.Equal(2, logFiles.Length); + } + finally + { + logger.Dispose(); + } + } + + [Fact] + public void LogMessage_RetainsOnlyConfiguredFilesForSameLoggerType() + { + string yesterdayFolder = CreateDailyFolder(DateTime.Now.AddDays(-1)); + string todayFolder = CreateDailyFolder(DateTime.Now); + string[] boundedLogs = + [ + CreateOldLog(yesterdayFolder, "RetentionBounded", 0), + CreateOldLog(yesterdayFolder, "RetentionBounded", 1), + CreateOldLog(todayFolder, "RetentionBounded", 2), + CreateOldLog(todayFolder, "RetentionBounded", 3) + ]; + string otherLog = CreateOldLog(yesterdayFolder, "OtherLogger", 0); + string unrelatedFile = Path.Combine(todayFolder, "notes.txt"); + string lookalikeFile = Path.Combine( + todayFolder, + "ASCOM.RetentionBounded.manual-backup.txt"); + File.WriteAllText(unrelatedFile, "preserve me"); + File.WriteAllText(lookalikeFile, "preserve me too"); + + TraceLoggerPlus logger = CreateLogger("RetentionBounded"); + try + { + logger.MaximumRetainedLogFiles = 3; + logger.LogMessage("Test", "Current message"); + + Assert.Equal(3, FindAutomaticallyNamedLogs("RetentionBounded").Length); + Assert.True(File.Exists(otherLog)); + Assert.True(File.Exists(unrelatedFile)); + Assert.True(File.Exists(lookalikeFile)); + Assert.False(File.Exists(boundedLogs[0])); + Assert.False(File.Exists(boundedLogs[1])); + } + finally + { + logger.Dispose(); + } + } + + [Fact] + public void LogMessage_RetriesRolloverAfterFileCreationFailure() + { + FailOnceTraceLoggerPlus logger = new(testRoot, "RecoverableRollover") + { + MaximumLogFileSizeBytes = 1, + MaximumRetainedLogFiles = 10 + }; + + try + { + logger.LogMessage("Test", "First message"); + logger.FailNextCreate = true; + + Assert.Throws(() => + logger.LogMessage("Test", "Message that triggers failure")); + + logger.LogMessage("Test", "Message after recovery"); + + Assert.Equal(2, FindAutomaticallyNamedLogs("RecoverableRollover").Length); + } + finally + { + logger.Dispose(); + } + } + + [Fact] + public void LogMessage_DoesNotLimitGenericCallersByDefault() + { + string dailyFolder = CreateDailyFolder(DateTime.Now); + string oldLog = CreateOldLog(dailyFolder, "UnlimitedByDefault", 0); + TraceLoggerPlus logger = CreateLogger("UnlimitedByDefault"); + + try + { + logger.LogMessage("Test", "First message"); + logger.LogMessage("Test", "Second message"); + + Assert.Equal(2, FindAutomaticallyNamedLogs("UnlimitedByDefault").Length); + Assert.True(File.Exists(oldLog)); + } + finally + { + logger.Dispose(); + } + } + + public void Dispose() + { + if (Directory.Exists(testRoot)) + { + Directory.Delete(testRoot, true); + } + } + + private TraceLoggerPlus CreateLogger(string loggerType) + { + Directory.CreateDirectory(testRoot); + return new TraceLoggerPlus("", testRoot, loggerType, true); + } + + private string CreateDailyFolder(DateTime date) + { + string dailyFolder = Path.Combine( + testRoot, + $"Logs {date:yyyy-MM-dd}"); + Directory.CreateDirectory(dailyFolder); + return dailyFolder; + } + + private static string CreateOldLog( + string dailyFolder, + string loggerType, + int index) + { + string path = Path.Combine( + dailyFolder, + $"ASCOM.{loggerType}.0000.00000{index}.txt"); + File.WriteAllText(path, $"old log {index}"); + File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddMinutes(index - 10)); + return path; + } + + private string[] FindLogs(string loggerType) + { + return Directory.GetFiles( + testRoot, + $"ASCOM.{loggerType}.*.txt", + SearchOption.AllDirectories); + } + + private string[] FindAutomaticallyNamedLogs(string loggerType) + { + Regex automaticFileNamePattern = new( + $"^ASCOM\\.{Regex.Escape(loggerType)}\\.\\d{{4}}\\.\\d{{6,7}}\\.txt$", + RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + return FindLogs(loggerType) + .Where(path => automaticFileNamePattern.IsMatch(Path.GetFileName(path))) + .ToArray(); + } + + private sealed class FailOnceTraceLoggerPlus( + string logRoot, + string loggerType) : TraceLoggerPlus("", logRoot, loggerType, true) + { + public bool FailNextCreate { get; set; } + + protected override StreamWriter CreateStreamWriter(string filePath) + { + if (FailNextCreate) + { + FailNextCreate = false; + throw new IOException("Simulated log file creation failure."); + } + + return base.CreateStreamWriter(filePath); + } + } +} diff --git a/Remote Server/ServerForm.cs b/Remote Server/ServerForm.cs index 8b88ee9..fd66237 100644 --- a/Remote Server/ServerForm.cs +++ b/Remote Server/ServerForm.cs @@ -129,6 +129,8 @@ public partial class ServerForm : Form internal const string SERVER_ACCESS_LOG_PROFILENAME = "Server Access Log Enabled"; internal const bool SERVER_ACCESS_LOG_DEFAULT = true; internal const string SERVER_TRACE_LEVEL_PROFILENAME = "Server Trace Level"; internal const bool SERVER_TRACE_LEVEL_DEFAULT = true; internal const string SERVER_DEBUG_TRACE_PROFILENAME = "Server Include Debug Trace"; internal const bool SERVER_DEBUG_TRACE_DEFAULT = false; + internal const long SERVER_LOG_MAXIMUM_FILE_SIZE_BYTES = 50L * 1024L * 1024L; + internal const int SERVER_LOG_MAXIMUM_RETAINED_FILES = 10; internal const string SERVER_IPADDRESS_PROFILENAME = "Server IP Address"; internal const string SERVER_IPADDRESS_DEFAULT = SharedConstants.LOCALHOST_ADDRESS_IPV4; internal const string SERVER_PORTNUMBER_PROFILENAME = "Server Port Number"; internal const decimal SERVER_PORTNUMBER_DEFAULT = 11111; internal const string SERVER_AUTOCONNECT_PROFILENAME = "Server Auto Connect"; internal const bool SERVER_AUTOCONNECT_DEFAULT = true; @@ -359,7 +361,9 @@ public ServerForm() { LogFilePath = TraceFolder, // Set the trace folder to the user specified value Enabled = TraceState, // Enable the log if required - UseUtcTime = UseUtcTimeInLogs + UseUtcTime = UseUtcTimeInLogs, + MaximumLogFileSizeBytes = SERVER_LOG_MAXIMUM_FILE_SIZE_BYTES, + MaximumRetainedLogFiles = SERVER_LOG_MAXIMUM_RETAINED_FILES }; LogMessage(0, 0, 0, "New", $"Remote Server Version {Updates.AscomRemoteVersionDisplayString}, Started on {DateTime.Now:dddd d MMMM yyyy HH: mm:ss}"); @@ -372,7 +376,9 @@ public ServerForm() { LogFilePath = TraceFolder, // Set the trace folder to the user specified value Enabled = AccessLogEnabled, - UseUtcTime = UseUtcTimeInLogs + UseUtcTime = UseUtcTimeInLogs, + MaximumLogFileSizeBytes = SERVER_LOG_MAXIMUM_FILE_SIZE_BYTES, + MaximumRetainedLogFiles = SERVER_LOG_MAXIMUM_RETAINED_FILES }; LogMessage(0, 0, 0, "New", "Setting screen log check boxes"); // Must be done before enabling event handlers! @@ -1467,11 +1473,13 @@ private static void CheckWhetherNewLogRequired(uint clientID, uint clientTransac TL = null; // Start a new logger - TL = new TraceLoggerPlus("", SERVER_TRACELOGGER_NAME) + TL = new TraceLoggerPlus("", TraceFolder, SERVER_TRACELOGGER_NAME, true) { LogFilePath = TraceFolder, Enabled = true, // Enable the trace logger - IpAddressTraceState = LogClientIPAddress // Set the current state of the "include client IP address in trace lines" flag + IpAddressTraceState = LogClientIPAddress, // Set the current state of the "include client IP address in trace lines" flag + MaximumLogFileSizeBytes = SERVER_LOG_MAXIMUM_FILE_SIZE_BYTES, + MaximumRetainedLogFiles = SERVER_LOG_MAXIMUM_RETAINED_FILES }; TL.LogMessage(clientID, clientTransactionID, serverTransactionID, "StartOfDay", "Opening a new log because a new day has started. " + now.ToString("dddd d MMMM yyyy HH:mm:ss")); @@ -2744,11 +2752,13 @@ private void ProcessRestRequest(HttpListenerContext context) AccessLog = null; // Start a new logger - AccessLog = new TraceLoggerPlus("", ACCESSLOG_TRACELOGGER_NAME) + AccessLog = new TraceLoggerPlus("", TraceFolder, ACCESSLOG_TRACELOGGER_NAME, true) { LogFilePath = TraceFolder, Enabled = true, // Enable the trace logger - IpAddressTraceState = LogClientIPAddress // Set the current state of the "include client IP address in trace lines" flag + IpAddressTraceState = LogClientIPAddress, // Set the current state of the "include client IP address in trace lines" flag + MaximumLogFileSizeBytes = SERVER_LOG_MAXIMUM_FILE_SIZE_BYTES, + MaximumRetainedLogFiles = SERVER_LOG_MAXIMUM_RETAINED_FILES }; AccessLog.LogMessage(clientID, clientTransactionID, serverTransactionID, "StartOfDay", "Opening a new log because a new day has started. " + now.ToString("dddd d MMMM yyyy HH:mm:ss")); diff --git a/Remote Server/TraceLoggerPlus.cs b/Remote Server/TraceLoggerPlus.cs index 82593f2..e158edc 100644 --- a/Remote Server/TraceLoggerPlus.cs +++ b/Remote Server/TraceLoggerPlus.cs @@ -1,7 +1,9 @@ using ASCOM.Common.Interfaces; using System; using System.IO; +using System.Linq; using System.Runtime.InteropServices; +using System.Text.RegularExpressions; using System.Threading; namespace ASCOM.Remote @@ -41,6 +43,7 @@ public class TraceLoggerPlus : ITraceLogger private readonly bool autoGenerateFilePath; private bool traceLoggerHasBeenDisposed; private string mutexName; + private string activeLogFilePath; #endregion @@ -219,6 +222,13 @@ public void LogMessage(string identifier, string message) // Create the log file if it doesn't yet exist if (logFileStream == null) CreateLogFile(); + // Roll automatically named files before writing another message + // once the configured size boundary has been reached. + if (autoGenerateFileName && MaximumLogFileSizeBytes > 0 && logFileStream.BaseStream.Length >= MaximumLogFileSizeBytes) + { + CreateLogFile(); + } + // Right pad the identifier string to the required column width identifier = identifier.PadRight(identifierWidthValue); @@ -279,6 +289,16 @@ public void BlankLine() /// This call will return an empty string until the first line has been written to the log file because the file is not created until required. public string LogFilePath { get; set; } + /// + /// Maximum size of an automatically named log file in bytes. Set to zero to disable size-based rollover. + /// + public long MaximumLogFileSizeBytes { get; set; } + + /// + /// Maximum number of automatically named files retained for this logger type. Set to zero to disable retention. + /// + public int MaximumRetainedLogFiles { get; set; } + /// /// Set or return the width of the identifier field in the log message /// @@ -320,6 +340,7 @@ private void CreateLogFile() { // Initialise working copy of the log file path string logFilePath; + string logRootPath; int logFileSuffixInteger = 0; // Initialise suffix to 0 @@ -330,17 +351,19 @@ private void CreateLogFile() { if (!string.IsNullOrEmpty(Environment.GetFolderPath(Environment.SpecialFolder.Personal))) // This is a normaL "User" account { - logFilePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), AUTO_PATH_BASE_DIRECTORY, string.Format(AUTO_PATH_WINDOWS_DIRECTORY_TEMPLATE, DateTimeNow())); + logRootPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), AUTO_PATH_BASE_DIRECTORY); } else // This is the "System" account, which does not have a personal documents directory so put log files in the { - logFilePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), AUTO_PATH_WINDOWS_SYSTEM_USER_BASE_DIRECTORY, string.Format(AUTO_PATH_WINDOWS_DIRECTORY_TEMPLATE, DateTimeNow())); + logRootPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), AUTO_PATH_WINDOWS_SYSTEM_USER_BASE_DIRECTORY); } } else // We need to use the supplied log file path, which is already in the logFilePath property { - logFilePath = Path.Combine(LogFilePath, string.Format(AUTO_PATH_WINDOWS_DIRECTORY_TEMPLATE, DateTimeNow())); + logRootPath = LogFilePath; } + + logFilePath = Path.Combine(logRootPath, string.Format(AUTO_PATH_WINDOWS_DIRECTORY_TEMPLATE, DateTimeNow())); } catch (Exception ex) { @@ -370,18 +393,21 @@ private void CreateLogFile() } while (File.Exists(Path.Combine(logFilePath, LogFileName)) & (logFileSuffixInteger <= MAXIMUM_UNIQUE_SUFFIX_ATTEMPTS)); // Loop until the generated file name does not exist or we hit the maximum number of attempts - // Close any current file stream before creating a new one - if (logFileStream is not null) + // Create the replacement before closing the current stream so a + // transient creation failure leaves the existing logger usable. + string newLogFilePath = Path.Combine(logFilePath, LogFileName); + StreamWriter newLogFileStream = CreateStreamWriter(newLogFilePath); + StreamWriter previousLogFileStream = logFileStream; + logFileStream = newLogFileStream; + activeLogFilePath = Path.GetFullPath(newLogFilePath); + + if (previousLogFileStream is not null) { - logFileStream.Close(); - logFileStream.Dispose(); + previousLogFileStream.Close(); + previousLogFileStream.Dispose(); } - // Create the stream writer used to write to disk - logFileStream = new StreamWriter(Path.Combine(logFilePath, LogFileName), false) - { - AutoFlush = true - }; + ApplyRetention(logRootPath); } catch (Exception ex) { @@ -392,18 +418,17 @@ private void CreateLogFile() { try { - // Close any current file stream before creating a new one - if (logFileStream is not null) - { - logFileStream.Close(); - logFileStream.Dispose(); - } + string newLogFilePath = Path.Combine(logFilePath, LogFileName); + StreamWriter newLogFileStream = CreateStreamWriter(newLogFilePath); + StreamWriter previousLogFileStream = logFileStream; + logFileStream = newLogFileStream; + activeLogFilePath = Path.GetFullPath(newLogFilePath); - // Create the stream writer used to write to disk - logFileStream = new StreamWriter(Path.Combine(logFilePath, LogFileName), false) + if (previousLogFileStream is not null) { - AutoFlush = true - }; + previousLogFileStream.Close(); + previousLogFileStream.Dispose(); + } } catch (Exception ex) { @@ -412,6 +437,68 @@ private void CreateLogFile() } } + /// + /// Create a stream writer for a log file. + /// + /// Fully qualified path of the log file to create. + /// A stream writer configured to flush each completed line. + protected virtual StreamWriter CreateStreamWriter(string filePath) + { + return new StreamWriter(filePath, false) + { + AutoFlush = true + }; + } + + /// + /// Remove the oldest automatically named files for this logger type when the configured retention count is exceeded. + /// + /// Root directory containing the generated daily log directories. + private void ApplyRetention(string logRootPath) + { + if (!autoGenerateFileName || MaximumRetainedLogFiles <= 0 || string.IsNullOrEmpty(activeLogFilePath)) return; + + try + { + string canonicalLogRootPath = Path.GetFullPath(logRootPath); + string canonicalLogRootPrefix = canonicalLogRootPath.TrimEnd( + Path.DirectorySeparatorChar, + Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; + Regex automaticFileNamePattern = new( + $"^ASCOM\\.{Regex.Escape(logFileType)}\\.\\d{{4}}\\.\\d{{6,7}}\\.txt$", + RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + FileInfo[] matchingLogFiles = new DirectoryInfo(canonicalLogRootPath) + .EnumerateDirectories("Logs *", SearchOption.TopDirectoryOnly) + .Where(directory => (directory.Attributes & FileAttributes.ReparsePoint) == 0) + .SelectMany(directory => directory.EnumerateFiles("*.txt", SearchOption.TopDirectoryOnly)) + .Where(file => (file.Attributes & FileAttributes.ReparsePoint) == 0) + .Where(file => automaticFileNamePattern.IsMatch(file.Name)) + .Where(file => file.FullName.StartsWith(canonicalLogRootPrefix, StringComparison.OrdinalIgnoreCase)) + .Where(file => !string.Equals(file.FullName, activeLogFilePath, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(file => file.LastWriteTimeUtc) + .ThenByDescending(file => file.FullName, StringComparer.OrdinalIgnoreCase) + .Skip(Math.Max(0, MaximumRetainedLogFiles - 1)) + .ToArray(); + + foreach (FileInfo logFileToDelete in matchingLogFiles) + { + try + { + logFileToDelete.Delete(); + } + catch + { + // Retention is best effort. A protected or in-use old file must not stop current logging. + } + } + } + catch + { + // Retention is best effort. Enumeration failures must not stop current logging. + } + } + /// /// Translate control characters into printable versions /// diff --git a/docs/plans/2026-08-15-bounded-log-retention-design.md b/docs/plans/2026-08-15-bounded-log-retention-design.md new file mode 100644 index 0000000..aabd6c3 --- /dev/null +++ b/docs/plans/2026-08-15-bounded-log-retention-design.md @@ -0,0 +1,74 @@ +# Bounded Remote Server Log Retention + +## Problem + +ASCOM Remote writes detailed server and access logs for every Alpaca request. +The loggers roll at a configured time, but they do not limit individual file +size or remove old files. A continuously polled server can therefore create +multi-gigabyte daily files and consume all available disk space. + +## Goals + +- Preserve complete server and access diagnostics. +- Limit the size of each automatically named Remote Server log file. +- Limit the number of retained files independently for each Remote Server log + type. +- Delete only files generated for the same logger type. +- Keep logging available if retention cleanup cannot delete an old file. +- Preserve existing behavior for callers that do not opt into limits. + +## Non-goals + +- Rate-limit Alpaca clients or change request handling. +- Sample or suppress individual log messages. +- Delete arbitrary ASCOM logs, configuration, or user files. +- Add new setup-dialog controls in this change. + +## Design + +`TraceLoggerPlus` receives two optional properties: + +- `MaximumLogFileSizeBytes`: a positive value rolls an automatically named + file before the next message when the current file has reached the limit. +- `MaximumRetainedLogFiles`: a positive value retains at most that many files + for the same logger type beneath the configured log root. + +Both properties default to zero, which disables the new behavior for existing +generic callers. Remote Server configures both its server trace and access +loggers with product defaults of 50 MiB per file and 10 retained files per log +type. This bounds normal Remote Server logging to approximately 1 GiB in total, +apart from a single message that may cross a size boundary. + +Retention matches the exact automatic name prefix +`ASCOM.{logger-type}.HHmm.ssfff{suffix}.txt`, rejects lookalike names, and skips +reparse-point directories and files. It excludes the active file, orders +remaining candidates from newest to oldest, and removes only overflow files +across all generated daily directories. Files for other logger types and +unrelated files remain untouched. +Deletion is best effort: an unavailable or protected old file must not stop +request processing or current logging. + +A replacement stream is opened before the current stream is closed. If file +creation fails temporarily, the current stream remains valid and a later log +message can retry rollover. + +## Test Strategy + +- Verify that repeated writes create more than one automatically named file + after the configured size is reached. +- Verify that retention keeps the configured number of files for the active + logger type. +- Verify that another logger type and an unrelated text file are preserved. +- Verify that generic callers remain unlimited unless they opt into limits. +- Verify retention across more than one generated daily directory. +- Verify that a temporary replacement-file creation failure can recover on the + next log message. +- Build the complete solution after the focused tests pass. + +## Compatibility and Risk + +The default values on `TraceLoggerPlus` preserve its historical unbounded +behavior. Only Remote Server opts into the product safeguards. Rotation is +limited to automatically named files because reopening a caller-supplied fixed +name would overwrite it. Cleanup is deliberately scoped by logger type and +never removes the active file. diff --git a/docs/plans/2026-08-15-bounded-log-retention-plan.md b/docs/plans/2026-08-15-bounded-log-retention-plan.md new file mode 100644 index 0000000..5056890 --- /dev/null +++ b/docs/plans/2026-08-15-bounded-log-retention-plan.md @@ -0,0 +1,32 @@ +# Bounded Log Retention Implementation Plan + +## Objective + +Prevent ASCOM Remote server and access logs from growing without a disk bound +while preserving complete diagnostics and compatibility for other logger +callers. + +## Implementation Steps + +- Add a focused .NET test project and tests that describe size rollover, + bounded retention, and deletion scope. +- Run the focused tests and capture the expected pre-fix failure. +- Add optional size and retention properties to `TraceLoggerPlus`. +- Rotate automatically named files when the active stream reaches its limit. +- Apply best-effort retention after creating an automatic log file. +- Configure both Remote Server logger creation paths with 50 MiB and 10-file + defaults. +- Run focused tests, the complete test suite, and a solution build. +- Review the diff for deletion scope, compatibility, and unrelated changes. +- Commit the change on `fix/bounded-log-retention`, push to a fork, and open an + upstream pull request. Cite the maintainer's retention comment in issue 55 + for context without claiming that this change closes the OpenTelemetry issue. + +## Verification Criteria + +- Focused rollover and retention tests pass. +- Other log types and unrelated files survive cleanup. +- Existing callers remain unlimited unless they set the new properties. +- The solution builds successfully with the required .NET 8 SDK. +- The pull request clearly states the measured disk-growth failure mode and the + bounded behavior introduced by the fix.