mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-14 12:56:14 +08:00
[logging] Add FileLogSink, CompositeLogSink, and SHARPEMU_LOG_FILE env support (#50)
- FileLogSink: thread-safe file writer with AutoFlush, FileShare.Read for concurrent read access (tail -f), automatic parent directory creation, full date-time timestamps, IDisposable for graceful shutdown - CompositeLogSink: fan-out to multiple sinks with per-sink exception isolation (one broken sink cannot silence the others), IDisposable propagates to children - SharpEmuLog: ResolveSinkFromEnvironment() reads SHARPEMU_LOG_FILE and creates CompositeLogSink(console + file) when set; Sink setter now disposes the previous IDisposable sink to prevent file handle leaks; Shutdown() flushes and disposes the active sink - Program.cs: Main wrapped in try/finally to guarantee SharpEmuLog.Shutdown() runs on all exit paths (GUI, mitigated child, normal, exception) Co-authored-by: Hermes Atlas <hermesatlas@example.com>
This commit is contained in:
@@ -36,6 +36,18 @@ internal static partial class Program
|
||||
|
||||
[STAThread]
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Run(args);
|
||||
}
|
||||
finally
|
||||
{
|
||||
SharpEmuLog.Shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
private static int Run(string[] args)
|
||||
{
|
||||
args = NormalizeInternalArguments(args, out var isMitigatedChild);
|
||||
if (args.Length == 0 && !isMitigatedChild)
|
||||
@@ -66,7 +78,7 @@ internal static partial class Program
|
||||
|
||||
ebootPath = Path.GetFullPath(ebootPath);
|
||||
Console.Error.WriteLine($"[DEBUG] Full path: {ebootPath}");
|
||||
|
||||
|
||||
if (!File.Exists(ebootPath))
|
||||
{
|
||||
Log.Error($"EBOOT file was not found: {ebootPath}");
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Dispatches each <see cref="LogEntry"/> to every child sink.
|
||||
/// Exceptions thrown by a child are swallowed so one failing sink
|
||||
/// (e.g. a closed file) cannot silence the others.
|
||||
/// </summary>
|
||||
public sealed class CompositeLogSink : ISharpEmuLogSink, IDisposable
|
||||
{
|
||||
private readonly ISharpEmuLogSink[] _sinks;
|
||||
private bool _disposed;
|
||||
|
||||
public CompositeLogSink(params ISharpEmuLogSink[] sinks)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sinks);
|
||||
|
||||
foreach (var sink in sinks)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sink, nameof(sinks));
|
||||
}
|
||||
|
||||
_sinks = sinks;
|
||||
}
|
||||
|
||||
public IReadOnlyList<ISharpEmuLogSink> Sinks => _sinks;
|
||||
|
||||
public void Write(in LogEntry entry)
|
||||
{
|
||||
foreach (var sink in _sinks)
|
||||
{
|
||||
try
|
||||
{
|
||||
sink.Write(in entry);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// A broken sink must not prevent the remaining sinks from logging.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
foreach (var sink in _sinks)
|
||||
{
|
||||
if (sink is IDisposable disposable)
|
||||
{
|
||||
try
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace SharpEmu.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Writes log entries to a file. Thread-safe via an internal lock.
|
||||
/// <see cref="StreamWriter.AutoFlush"/> is enabled so entries survive a crash.
|
||||
/// </summary>
|
||||
public sealed class FileLogSink : ISharpEmuLogSink, IDisposable
|
||||
{
|
||||
private readonly object _sync = new();
|
||||
private readonly StreamWriter _writer;
|
||||
private bool _disposed;
|
||||
|
||||
/// <param name="path">Absolute or relative file path. Parent directories are created if missing.</param>
|
||||
/// <param name="append"><see langword="true"/> to append to an existing file; <see langword="false"/> to overwrite.</param>
|
||||
/// <param name="includeTimestamp">Always recommended for file logs — entries include a full date-time prefix.</param>
|
||||
public FileLogSink(string path, bool append = true, bool includeTimestamp = true)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
|
||||
var directory = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var fileStream = new FileStream(
|
||||
path,
|
||||
append ? FileMode.Append : FileMode.Create,
|
||||
FileAccess.Write,
|
||||
FileShare.Read,
|
||||
bufferSize: 4096,
|
||||
FileOptions.SequentialScan);
|
||||
_writer = new StreamWriter(fileStream, Encoding.UTF8)
|
||||
{
|
||||
AutoFlush = true
|
||||
};
|
||||
|
||||
IncludeTimestamp = includeTimestamp;
|
||||
}
|
||||
|
||||
public bool IncludeTimestamp { get; set; }
|
||||
|
||||
public void Write(in LogEntry entry)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (IncludeTimestamp)
|
||||
{
|
||||
_writer.Write('[');
|
||||
_writer.Write(entry.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff"));
|
||||
_writer.Write(']');
|
||||
}
|
||||
|
||||
_writer.Write('[');
|
||||
_writer.Write(ToLevelLabel(entry.Level));
|
||||
_writer.Write(']');
|
||||
_writer.Write('[');
|
||||
_writer.Write(entry.Category);
|
||||
_writer.Write(']');
|
||||
_writer.Write(' ');
|
||||
|
||||
_writer.Write(entry.SourceFileName);
|
||||
if (entry.SourceLine > 0)
|
||||
{
|
||||
_writer.Write(':');
|
||||
_writer.Write(entry.SourceLine);
|
||||
}
|
||||
|
||||
_writer.Write(' ');
|
||||
_writer.WriteLine(entry.Message);
|
||||
|
||||
if (entry.Exception is not null)
|
||||
{
|
||||
_writer.WriteLine(entry.Exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_writer.Flush();
|
||||
_writer.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static string ToLevelLabel(LogLevel level) => level switch
|
||||
{
|
||||
LogLevel.Trace => "TRACE",
|
||||
LogLevel.Debug => "DEBUG",
|
||||
LogLevel.Info => "INFO",
|
||||
LogLevel.Warning => "WARNING",
|
||||
LogLevel.Error => "ERROR",
|
||||
LogLevel.Critical => "CRITICAL",
|
||||
_ => "LOG",
|
||||
};
|
||||
}
|
||||
@@ -12,9 +12,7 @@ public static class SharpEmuLog
|
||||
new(StringComparer.Ordinal);
|
||||
private static readonly object ConfigurationSync = new();
|
||||
private static volatile LogLevel _minimumLevel = ResolveMinimumLevelFromEnvironment();
|
||||
private static ISharpEmuLogSink _sink = new ConsoleLogSink(
|
||||
useColors: ResolveColorEnabledFromEnvironment(),
|
||||
includeTimestamp: false);
|
||||
private static ISharpEmuLogSink _sink = ResolveSinkFromEnvironment();
|
||||
|
||||
public static LogLevel MinimumLevel
|
||||
{
|
||||
@@ -37,11 +35,26 @@ public static class SharpEmuLog
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
lock (ConfigurationSync)
|
||||
{
|
||||
if (ReferenceEquals(_sink, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_sink is IDisposable disposable)
|
||||
{
|
||||
try
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
_sink = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void Configure(LogLevel? minimumLevel = null, ISharpEmuLogSink? sink = null)
|
||||
{
|
||||
if (minimumLevel.HasValue)
|
||||
@@ -55,6 +68,22 @@ public static class SharpEmuLog
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the active sink if it implements <see cref="IDisposable"/>.
|
||||
/// Call at shutdown to flush file buffers. Logging after this call
|
||||
/// continues to work for non-disposable sinks (e.g. <see cref="ConsoleLogSink"/>).
|
||||
/// </summary>
|
||||
public static void Shutdown()
|
||||
{
|
||||
lock (ConfigurationSync)
|
||||
{
|
||||
if (_sink is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static SharpEmuLogger For(string category)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(category);
|
||||
@@ -146,6 +175,30 @@ public static class SharpEmuLog
|
||||
return !IsTrueLike(raw);
|
||||
}
|
||||
|
||||
private static ISharpEmuLogSink ResolveSinkFromEnvironment()
|
||||
{
|
||||
var consoleSink = new ConsoleLogSink(
|
||||
useColors: ResolveColorEnabledFromEnvironment(),
|
||||
includeTimestamp: false);
|
||||
|
||||
var logFilePath = Environment.GetEnvironmentVariable("SHARPEMU_LOG_FILE");
|
||||
if (!string.IsNullOrWhiteSpace(logFilePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileSink = new FileLogSink(logFilePath, append: true, includeTimestamp: true);
|
||||
return new CompositeLogSink(consoleSink, fileSink);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Bootstrapping — the logging system is not yet active, so stderr is the only channel.
|
||||
Console.Error.WriteLine($"[SHARPEMU_LOG] Failed to open log file '{logFilePath}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return consoleSink;
|
||||
}
|
||||
|
||||
private static bool IsTrueLike(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
|
||||
Reference in New Issue
Block a user