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.