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.