Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions src/Snipdeck.App/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ public App()
var dispatcher = Services.GetRequiredService<IDispatcher>();
_ = 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;
}

Expand Down Expand Up @@ -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);
}
}
}
}
116 changes: 116 additions & 0 deletions src/Snipdeck.App/CrashLog.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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<IPathProvider>();
if (paths is null)
{
return;
}

Directory.CreateDirectory(paths.LogsDirectory);

Check failure on line 34 in src/Snipdeck.App/CrashLog.cs

View workflow job for this annotation

GitHub Actions / App build (windows)

Expression value is never used (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0058)

Check failure on line 34 in src/Snipdeck.App/CrashLog.cs

View workflow job for this annotation

GitHub Actions / App build (windows)

Expression value is never used (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0058)
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));

Check failure on line 77 in src/Snipdeck.App/CrashLog.cs

View workflow job for this annotation

GitHub Actions / App build (windows)

Expression value is never used (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0058)

Check failure on line 77 in src/Snipdeck.App/CrashLog.cs

View workflow job for this annotation

GitHub Actions / App build (windows)

Expression value is never used (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0058)
sb.Append(DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture))

Check failure on line 78 in src/Snipdeck.App/CrashLog.cs

View workflow job for this annotation

GitHub Actions / App build (windows)

Expression value is never used (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0058)

Check failure on line 78 in src/Snipdeck.App/CrashLog.cs

View workflow job for this annotation

GitHub Actions / App build (windows)

Expression value is never used (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0058)
.Append(" ")
.AppendLine(source);
AppendException(sb, exception, depth: 0);
sb.AppendLine();

Check failure on line 82 in src/Snipdeck.App/CrashLog.cs

View workflow job for this annotation

GitHub Actions / App build (windows)

Expression value is never used (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0058)

Check failure on line 82 in src/Snipdeck.App/CrashLog.cs

View workflow job for this annotation

GitHub Actions / App build (windows)

Expression value is never used (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0058)
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);

Check failure on line 90 in src/Snipdeck.App/CrashLog.cs

View workflow job for this annotation

GitHub Actions / App build (windows)

Expression value is never used (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0058)

Check failure on line 90 in src/Snipdeck.App/CrashLog.cs

View workflow job for this annotation

GitHub Actions / App build (windows)

Expression value is never used (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0058)
if (ex is COMException)
{
sb.Append(indent).Append("HRESULT: 0x")

Check failure on line 93 in src/Snipdeck.App/CrashLog.cs

View workflow job for this annotation

GitHub Actions / App build (windows)

Expression value is never used (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0058)

Check failure on line 93 in src/Snipdeck.App/CrashLog.cs

View workflow job for this annotation

GitHub Actions / App build (windows)

Expression value is never used (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0058)
.AppendLine(ex.HResult.ToString("X8", CultureInfo.InvariantCulture));
}
sb.Append(indent).Append("Message: ").AppendLine(ex.Message);

Check failure on line 96 in src/Snipdeck.App/CrashLog.cs

View workflow job for this annotation

GitHub Actions / App build (windows)

Expression value is never used (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0058)

Check failure on line 96 in src/Snipdeck.App/CrashLog.cs

View workflow job for this annotation

GitHub Actions / App build (windows)

Expression value is never used (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0058)
if (!string.IsNullOrEmpty(ex.Source))
{
sb.Append(indent).Append("Source: ").AppendLine(ex.Source);

Check failure on line 99 in src/Snipdeck.App/CrashLog.cs

View workflow job for this annotation

GitHub Actions / App build (windows)

Expression value is never used (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0058)

Check failure on line 99 in src/Snipdeck.App/CrashLog.cs

View workflow job for this annotation

GitHub Actions / App build (windows)

Expression value is never used (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0058)
}
if (!string.IsNullOrWhiteSpace(ex.StackTrace))
{
sb.Append(indent).AppendLine("Stack:");

Check failure on line 103 in src/Snipdeck.App/CrashLog.cs

View workflow job for this annotation

GitHub Actions / App build (windows)

Expression value is never used (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0058)

Check failure on line 103 in src/Snipdeck.App/CrashLog.cs

View workflow job for this annotation

GitHub Actions / App build (windows)

Expression value is never used (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0058)
foreach (var line in ex.StackTrace!.Split('\n'))
{
sb.Append(indent).Append(" ").AppendLine(line.TrimEnd('\r'));

Check failure on line 106 in src/Snipdeck.App/CrashLog.cs

View workflow job for this annotation

GitHub Actions / App build (windows)

Expression value is never used (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0058)

Check failure on line 106 in src/Snipdeck.App/CrashLog.cs

View workflow job for this annotation

GitHub Actions / App build (windows)

Expression value is never used (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0058)
}
}
if (ex.InnerException is not null)
{
sb.Append(indent).AppendLine("Inner:");
AppendException(sb, ex.InnerException, depth + 1);
}
}
}
}
4 changes: 4 additions & 0 deletions src/Snipdeck.App/Services/WindowsPathProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand All @@ -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; }
Expand All @@ -29,5 +31,7 @@ public WindowsPathProvider()
public string DefaultStorageDirectory { get; }

public string DefaultBackupDirectory { get; }

public string LogsDirectory { get; }
}
}
2 changes: 2 additions & 0 deletions src/Snipdeck.Core/Abstractions/IPathProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,7 @@ public interface IPathProvider
string DefaultStorageDirectory { get; }

string DefaultBackupDirectory { get; }

string LogsDirectory { get; }
}
}
2 changes: 2 additions & 0 deletions tests/Snipdeck.Core.Tests/Support/FakePathProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
}
Loading