diff --git a/CHANGELOG.md b/CHANGELOG.md index 290c529..bf1935e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Unhandled-exception logging.** Exceptions that previously vanished into + WinUI's "Continue?" debugger dialog (and would have crashed the process + outside the debugger) are now captured to + `%LOCALAPPDATA%\Snipdeck\logs\unhandled.log`. The handlers cover XAML + `Application.UnhandledException`, unobserved `Task` exceptions, and + `AppDomain.UnhandledException` for completeness. `IPathProvider` gains + `LogsDirectory`. The log rotates at 5 MB. + ### Fixed - First-run crash on startup (`RPC_E_WRONG_THREAD` / `0x8001010E`): `ShellViewModel.LoadAsync` resumed on a thread-pool thread after loading diff --git a/src/Snipdeck.App/App.xaml.cs b/src/Snipdeck.App/App.xaml.cs index 61b56ba..bdffb2e 100644 --- a/src/Snipdeck.App/App.xaml.cs +++ b/src/Snipdeck.App/App.xaml.cs @@ -26,6 +26,13 @@ public App() var dispatcher = Services.GetRequiredService(); _ = dispatcher.HasUiThreadAccess; + // Catch everything we can. Without these handlers an exception + // raised in XAML or in an unobserved Task is invisible — the + // debugger pops a "Continue?" dialog and the detail is lost. + UnhandledException += OnXamlUnhandledException; + TaskScheduler.UnobservedTaskException += OnUnobservedTaskException; + AppDomain.CurrentDomain.UnhandledException += OnAppDomainUnhandledException; + AppInstance.GetCurrent().Activated += OnInstanceActivated; } @@ -109,5 +116,30 @@ private void BringToFront() _mainWindow.AppWindow.Show(); _mainWindow.Activate(); } + + private static void OnXamlUnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExceptionEventArgs e) + { + CrashLog.Write("XAML.UnhandledException", e.Exception); + // Keep the app alive. Without this the process dies; with it + // we get the same "click Continue and carry on" behaviour the + // debugger gave us, minus the lost diagnostic. + e.Handled = true; + } + + private static void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e) + { + CrashLog.Write("TaskScheduler.UnobservedTaskException", e.Exception); + e.SetObserved(); + } + + private static void OnAppDomainUnhandledException(object? sender, System.UnhandledExceptionEventArgs e) + { + if (e.ExceptionObject is Exception ex) + { + CrashLog.Write( + e.IsTerminating ? "AppDomain.UnhandledException (terminating)" : "AppDomain.UnhandledException", + ex); + } + } } } diff --git a/src/Snipdeck.App/CrashLog.cs b/src/Snipdeck.App/CrashLog.cs new file mode 100644 index 0000000..197e207 --- /dev/null +++ b/src/Snipdeck.App/CrashLog.cs @@ -0,0 +1,116 @@ +using System.Globalization; +using System.Runtime.InteropServices; +using System.Text; + +using Microsoft.Extensions.DependencyInjection; + +using Snipdeck.Core.Abstractions; + +namespace Snipdeck.App +{ + /// + /// Best-effort writer for unhandled-exception diagnostics. Designed to + /// never throw — if logging itself fails, we silently give up rather + /// than turning a recovered exception into a fatal crash. + /// + internal static class CrashLog + { + private const string _logFileName = "unhandled.log"; + private const int _maxLogBytes = 5 * 1024 * 1024; + private static readonly object _writeLock = new(); + + public static void Write(string source, Exception exception) + { + try + { + ArgumentNullException.ThrowIfNull(exception); + + var paths = App.Services?.GetService(); + if (paths is null) + { + return; + } + + Directory.CreateDirectory(paths.LogsDirectory); + var logPath = Path.Combine(paths.LogsDirectory, _logFileName); + var text = Format(source, exception); + + lock (_writeLock) + { + RotateIfTooLarge(logPath); + File.AppendAllText(logPath, text); + } + } + catch + { + // Logger of last resort: must not throw, even if disk is + // full, the path is locked, or DI hasn't built yet. + } + } + + private static void RotateIfTooLarge(string logPath) + { + try + { + var info = new FileInfo(logPath); + if (!info.Exists || info.Length < _maxLogBytes) + { + return; + } + + var rotated = logPath + ".1"; + if (File.Exists(rotated)) + { + File.Delete(rotated); + } + File.Move(logPath, rotated); + } + catch + { + // Best-effort rotation; if it fails we'll just keep appending. + } + } + + private static string Format(string source, Exception exception) + { + var sb = new StringBuilder(); + sb.AppendLine(new string('=', 72)); + sb.Append(DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture)) + .Append(" ") + .AppendLine(source); + AppendException(sb, exception, depth: 0); + sb.AppendLine(); + return sb.ToString(); + } + + private static void AppendException(StringBuilder sb, Exception ex, int depth) + { + var indent = new string(' ', depth * 2); + + sb.Append(indent).Append("Type: ").AppendLine(ex.GetType().FullName ?? ex.GetType().Name); + if (ex is COMException) + { + sb.Append(indent).Append("HRESULT: 0x") + .AppendLine(ex.HResult.ToString("X8", CultureInfo.InvariantCulture)); + } + sb.Append(indent).Append("Message: ").AppendLine(ex.Message); + if (!string.IsNullOrEmpty(ex.Source)) + { + sb.Append(indent).Append("Source: ").AppendLine(ex.Source); + } + if (!string.IsNullOrWhiteSpace(ex.StackTrace)) + { + sb.Append(indent).AppendLine("Stack:"); + foreach (var line in ex.StackTrace!.Split('\n')) + { + sb.Append(indent).Append(" ").AppendLine(line.TrimEnd('\r')); + } + } + if (ex.InnerException is not null) + { + sb.Append(indent).AppendLine("Inner:"); + AppendException(sb, ex.InnerException, depth + 1); + } + } + } +} diff --git a/src/Snipdeck.App/Services/WindowsPathProvider.cs b/src/Snipdeck.App/Services/WindowsPathProvider.cs index 5b1a788..e70ab19 100644 --- a/src/Snipdeck.App/Services/WindowsPathProvider.cs +++ b/src/Snipdeck.App/Services/WindowsPathProvider.cs @@ -11,6 +11,7 @@ internal sealed class WindowsPathProvider : IPathProvider private const string _settingsFileName = "settings.json"; private const string _storeDirectoryName = "store"; private const string _backupsDirectoryName = "backups"; + private const string _logsDirectoryName = "logs"; public WindowsPathProvider() { @@ -20,6 +21,7 @@ public WindowsPathProvider() SettingsFilePath = Path.Combine(AppDataDirectory, _settingsFileName); DefaultStorageDirectory = Path.Combine(AppDataDirectory, _storeDirectoryName); DefaultBackupDirectory = Path.Combine(AppDataDirectory, _backupsDirectoryName); + LogsDirectory = Path.Combine(AppDataDirectory, _logsDirectoryName); } public string AppDataDirectory { get; } @@ -29,5 +31,7 @@ public WindowsPathProvider() public string DefaultStorageDirectory { get; } public string DefaultBackupDirectory { get; } + + public string LogsDirectory { get; } } } diff --git a/src/Snipdeck.Core/Abstractions/IPathProvider.cs b/src/Snipdeck.Core/Abstractions/IPathProvider.cs index e04b101..e422dfa 100644 --- a/src/Snipdeck.Core/Abstractions/IPathProvider.cs +++ b/src/Snipdeck.Core/Abstractions/IPathProvider.cs @@ -9,5 +9,7 @@ public interface IPathProvider string DefaultStorageDirectory { get; } string DefaultBackupDirectory { get; } + + string LogsDirectory { get; } } } diff --git a/tests/Snipdeck.Core.Tests/Support/FakePathProvider.cs b/tests/Snipdeck.Core.Tests/Support/FakePathProvider.cs index 3e9562b..7d3f89d 100644 --- a/tests/Snipdeck.Core.Tests/Support/FakePathProvider.cs +++ b/tests/Snipdeck.Core.Tests/Support/FakePathProvider.cs @@ -11,5 +11,7 @@ public sealed class FakePathProvider : IPathProvider public string DefaultStorageDirectory { get; init; } = "/data/store"; public string DefaultBackupDirectory { get; init; } = "/data/backups"; + + public string LogsDirectory { get; init; } = "/data/logs"; } }