diff --git a/src/SharpEmu.CLI/Program.cs b/src/SharpEmu.CLI/Program.cs index 60ec8d2..0d43b6d 100644 --- a/src/SharpEmu.CLI/Program.cs +++ b/src/SharpEmu.CLI/Program.cs @@ -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}"); diff --git a/src/SharpEmu.Logging/CompositeLogSink.cs b/src/SharpEmu.Logging/CompositeLogSink.cs new file mode 100644 index 0000000..ae09e12 --- /dev/null +++ b/src/SharpEmu.Logging/CompositeLogSink.cs @@ -0,0 +1,68 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.Logging; + +/// +/// Dispatches each to every child sink. +/// Exceptions thrown by a child are swallowed so one failing sink +/// (e.g. a closed file) cannot silence the others. +/// +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 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 + { + } + } + } + } +} diff --git a/src/SharpEmu.Logging/FileLogSink.cs b/src/SharpEmu.Logging/FileLogSink.cs new file mode 100644 index 0000000..bc2118b --- /dev/null +++ b/src/SharpEmu.Logging/FileLogSink.cs @@ -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; + +/// +/// Writes log entries to a file. Thread-safe via an internal lock. +/// is enabled so entries survive a crash. +/// +public sealed class FileLogSink : ISharpEmuLogSink, IDisposable +{ + private readonly object _sync = new(); + private readonly StreamWriter _writer; + private bool _disposed; + + /// Absolute or relative file path. Parent directories are created if missing. + /// to append to an existing file; to overwrite. + /// Always recommended for file logs — entries include a full date-time prefix. + 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", + }; +} diff --git a/src/SharpEmu.Logging/SharpEmuLog.cs b/src/SharpEmu.Logging/SharpEmuLog.cs index 51a4c76..e3dcac9 100644 --- a/src/SharpEmu.Logging/SharpEmuLog.cs +++ b/src/SharpEmu.Logging/SharpEmuLog.cs @@ -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 } } + /// + /// Disposes the active sink if it implements . + /// Call at shutdown to flush file buffers. Logging after this call + /// continues to work for non-disposable sinks (e.g. ). + /// + 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))