From 7bd35406055c5ab6020f3c5430f5f9283aee715c Mon Sep 17 00:00:00 2001 From: mushymato Date: Sat, 5 Sep 2026 16:15:33 -0400 Subject: [PATCH] Adapt async log queue from cinderbox for SMAPI --- src/SMAPI/Framework/Logging/AsyncLogQueue.cs | 195 +++++++++++++++++++ src/SMAPI/Framework/Logging/LogManager.cs | 11 +- src/SMAPI/Framework/Monitor.cs | 29 +-- 3 files changed, 214 insertions(+), 21 deletions(-) create mode 100644 src/SMAPI/Framework/Logging/AsyncLogQueue.cs diff --git a/src/SMAPI/Framework/Logging/AsyncLogQueue.cs b/src/SMAPI/Framework/Logging/AsyncLogQueue.cs new file mode 100644 index 000000000..846586c3d --- /dev/null +++ b/src/SMAPI/Framework/Logging/AsyncLogQueue.cs @@ -0,0 +1,195 @@ +// Originally implemented by Ekyso in SMAPI-For-Cinderbox +// https://github.com/Ekyso/SMAPI-For-Cinderbox/blob/2d499a9cf2521d2b97552988f30f7989e7bb4247/src/SMAPI/Framework/Logging/AsyncLogQueue.cs +using System; +using System.Collections.Concurrent; +using System.Threading; +using StardewModdingAPI.Internal.ConsoleWriting; + +namespace StardewModdingAPI.Framework.Logging; + +/// Provides asynchronous logging by queuing messages and processing them on a background thread. +internal sealed class AsyncLogQueue : IDisposable +{ + /********* + ** Fields + *********/ + /// The queue of pending log messages. + private readonly BlockingCollection Queue = new(new ConcurrentQueue()); + + /// The background thread that processes log messages. + private readonly Thread WorkerThread; + + /// Whether the queue has been disposed. + private bool IsDisposed; + + /// The log file manager for file output. + private readonly LogFileManager LogFile; + + /// The console writer for logcat output. + private readonly IConsoleWriter ConsoleWriter; + + /********* + ** Public methods + *********/ + /// Construct an instance. + public AsyncLogQueue(LogFileManager logFile, IConsoleWriter consoleWriter) + { + this.LogFile = logFile; + this.ConsoleWriter = consoleWriter; + this.WorkerThread = new Thread(this.ProcessQueue) + { + Name = "SMAPI.AsyncLogger", + IsBackground = true, + Priority = ThreadPriority.BelowNormal + }; + this.WorkerThread.Start(); + } + + /// Queue a message for logging. + /// The message to write to console/logcat. + /// The message to write to the log file. + /// The log level. + /// Whether to write to console/logcat. + public void Enqueue(string consoleMessage, string fileMessage, ConsoleLogLevel level, bool writeToConsole) + { + if (this.IsDisposed) + return; + + try + { + this.Queue.Add(new LogEntry(consoleMessage, fileMessage, level, writeToConsole)); + } + catch (InvalidOperationException) + { + // queue was marked as complete + } + } + + /// Queue a newline for logging. + /// Whether to write to console. + public void EnqueueNewline(bool writeToConsole) + { + if (this.IsDisposed) + return; + + try + { + this.Queue.Add(new LogEntry(null, "", ConsoleLogLevel.Info, writeToConsole, isNewline: true)); + } + catch (InvalidOperationException) + { + // queue was marked as complete + } + } + + /// Flush all pending messages synchronously. + public void Flush() + { + // wait for queue to drain + var timeout = DateTime.UtcNow.AddSeconds(5); + while (this.Queue.Count > 0 && DateTime.UtcNow < timeout) + { + Thread.Sleep(10); + } + } + + /// + public void Dispose() + { + if (this.IsDisposed) + return; + + this.IsDisposed = true; + this.Queue.CompleteAdding(); + + // wait for worker thread to finish + this.WorkerThread.Join(TimeSpan.FromSeconds(5)); + + this.Queue.Dispose(); + } + + + /********* + ** Private methods + *********/ + /// Process log messages from the queue. + private void ProcessQueue() + { + try + { + foreach (var entry in this.Queue.GetConsumingEnumerable()) + { + try + { + this.WriteEntry(entry); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[AsyncLogQueue] Error writing log entry: {ex.Message}"); + } + } + } + catch (OperationCanceledException) + { + // expected when disposing + } + } + + /// Write a log entry to the configured outputs. + /// The entry to write. + private void WriteEntry(LogEntry entry) + { + if (entry.IsNewline) + { + if (entry.WriteToConsole) + Console.WriteLine(); + this.LogFile?.WriteLine(""); + return; + } + + // write to console/logcat + if (entry.WriteToConsole && entry.ConsoleMessage != null) + { + this.ConsoleWriter?.WriteLine(entry.ConsoleMessage, entry.Level); + } + + // write to log file + if (entry.FileMessage != null) + { + this.LogFile?.WriteLine(entry.FileMessage); + } + } + + + /********* + ** Private types + *********/ + /// A log entry waiting to be written. + private readonly struct LogEntry + { + /// The message to write to console/logcat. + public readonly string? ConsoleMessage; + + /// The message to write to the log file. + public readonly string? FileMessage; + + /// The log level. + public readonly ConsoleLogLevel Level; + + /// Whether to write to console/logcat. + public readonly bool WriteToConsole; + + /// Whether this is a newline entry. + public readonly bool IsNewline; + + /// Construct an instance. + public LogEntry(string? consoleMessage, string? fileMessage, ConsoleLogLevel level, bool writeToConsole, bool isNewline = false) + { + this.ConsoleMessage = consoleMessage; + this.FileMessage = fileMessage; + this.Level = level; + this.WriteToConsole = writeToConsole; + this.IsNewline = isNewline; + } + } +} diff --git a/src/SMAPI/Framework/Logging/LogManager.cs b/src/SMAPI/Framework/Logging/LogManager.cs index 4c7283862..e77ee900f 100644 --- a/src/SMAPI/Framework/Logging/LogManager.cs +++ b/src/SMAPI/Framework/Logging/LogManager.cs @@ -3,7 +3,6 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; -using System.Text; using System.Threading; using StardewModdingAPI.Framework.Commands; using StardewModdingAPI.Framework.Models; @@ -38,6 +37,10 @@ internal class LogManager : IDisposable /********* ** Accessors *********/ + + /// The async log queue and worker thread for writing logs. + public AsyncLogQueue LogQueue { get; } + /// The core logger and monitor for SMAPI. public Monitor Monitor { get; } @@ -68,6 +71,8 @@ public LogManager(string logPath, MonitorColorScheme colorSchemeId, Dictionary this.CreateAndRegisterMonitor(id, name, verboseLogging, getScreenIdForLog, writeToConsole, isDeveloperMode); + this.LogQueue = new AsyncLogQueue(this.LogFile, this.ConsoleWriter); + this.Monitor = this.GetMonitor("SMAPI", "SMAPI"); this.MonitorForGame = this.GetMonitor("game", "game"); @@ -342,6 +347,8 @@ public void LogModInfo(IReadOnlyList loaded, IReadOnlyList public void Dispose() { + this.LogQueue.Flush(); + this.LogQueue.Dispose(); this.LogFile.Dispose(); } @@ -358,7 +365,7 @@ public void Dispose() /// Whether to enable full console output for developers. private Monitor CreateAndRegisterMonitor(string modId, string source, HashSet verboseLogging, Func getScreenIdForLog, bool writeToConsole, bool isDeveloperMode) { - Monitor monitor = new(modId, source, this.LogFile, this.ConsoleWriter, getScreenIdForLog) + Monitor monitor = new(modId, source, this.LogQueue, getScreenIdForLog) { WriteToConsole = writeToConsole }; diff --git a/src/SMAPI/Framework/Monitor.cs b/src/SMAPI/Framework/Monitor.cs index 8e13ab1dc..eb3e59d53 100644 --- a/src/SMAPI/Framework/Monitor.cs +++ b/src/SMAPI/Framework/Monitor.cs @@ -16,18 +16,15 @@ internal class Monitor : IMonitor /// The name of the module which logs messages using this instance. private readonly string Source; - /// Handles writing text to the console. - private readonly IConsoleWriter ConsoleWriter; - - /// The log file to which to write messages. - private readonly LogFileManager LogFile; - /// The maximum length of the values. private static readonly int MaxLevelLength = Enum.GetValues().Max(level => level.ToString().Length); /// The cached representation for each level when added to a log header. private static readonly Dictionary LogStrings = Enum.GetValues().ToDictionary(level => level, level => level.ToString().ToUpperInvariant().PadRight(Monitor.MaxLevelLength)); + /// The async log queue and worker thread for writing logs. + private readonly AsyncLogQueue LogQueue; + /// A cache of messages that should only be logged once. private readonly HashSet LogOnceCache = []; @@ -76,10 +73,9 @@ public bool IsVerbose /// Construct an instance. /// The mod ID, if applicable. /// The name of the module which logs messages using this instance. - /// The log file to which to write messages. - /// Handles writing text to the console. + /// The async log queue responsible for writing messages. /// Get the screen ID that should be logged to distinguish between players in split-screen mode, if any. - public Monitor(string modId, string source, LogFileManager logFile, IConsoleWriter consoleWriter, Func getScreenIdForLog) + public Monitor(string modId, string source, AsyncLogQueue logQueue, Func getScreenIdForLog) { // validate if (string.IsNullOrWhiteSpace(source)) @@ -88,8 +84,7 @@ public Monitor(string modId, string source, LogFileManager logFile, IConsoleWrit // initialize this.ModId = modId; this.Source = source; - this.LogFile = logFile ?? throw new ArgumentNullException(nameof(logFile), "The log file manager cannot be null."); - this.ConsoleWriter = consoleWriter ?? throw new ArgumentNullException(nameof(consoleWriter), "The console writer cannot be null."); + this.LogQueue = logQueue ?? throw new ArgumentNullException(nameof(logQueue), "The log queue cannot be null."); this.GetScreenIdForLog = getScreenIdForLog; } @@ -123,9 +118,7 @@ public void VerboseLog([InterpolatedStringHandlerArgument("")] ref VerboseLogStr /// Write a newline to the console and log file. internal void Newline() { - if (this.WriteToConsole) - Console.WriteLine(); - this.LogFile.WriteLine(""); + this.LogQueue.EnqueueNewline(this.WriteToConsole); } /// Log a fatal error message. @@ -141,7 +134,7 @@ internal void LogUserInput(string input) { // user input already appears in the console, so just need to write to file string prefix = this.GenerateMessagePrefix(this.Source, (ConsoleLogLevel)LogLevel.Info); - this.LogFile.WriteLine($"{prefix} $>{input}"); + this.LogQueue.Enqueue(string.Empty, $"{prefix} $>{input}", (ConsoleLogLevel)LogLevel.Info, false); } @@ -160,11 +153,9 @@ private void LogImpl(string source, string message, ConsoleLogLevel level) string consoleMessage = this.ShowFullStampInConsole ? fullMessage : $"[{source}] {message}"; // write to console - if (this.WriteToConsole && (this.ShowTraceInConsole || level != ConsoleLogLevel.Trace || Monitor.ForceVerboseLoggingForAll || Monitor.ForceVerboseLogging.Contains(this.ModId))) - this.ConsoleWriter.WriteLine(consoleMessage, level); + bool writeToConsole = this.WriteToConsole && (this.ShowTraceInConsole || level != ConsoleLogLevel.Trace || Monitor.ForceVerboseLoggingForAll || Monitor.ForceVerboseLogging.Contains(this.ModId)); - // write to log file - this.LogFile.WriteLine(fullMessage); + this.LogQueue.Enqueue(consoleMessage, fullMessage, level, writeToConsole); } /// Generate a message prefix for the current time.