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
37 changes: 37 additions & 0 deletions Docs/architecture/error-reporting.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,40 @@ seam stays concrete (matches `ResultReportingExtensions`).
Modal dialogs are the exception: `GridStyleEditorViewModel.SaveCommand` surfaces its fault on the
editor's own `ErrorMessage` property (and logs), not the shared panel, because a modal owns its error
surface while it is open.

## Global backstop (last-resort ring)

The per-command pipe above catches faults on a *subscribed* `ThrownExceptions`. What escapes every
inner ring reaches `GlobalExceptionBackstop` (`SemiStep.UI/Logging/`), installed once at startup. It
wires three handlers, each for a different escape route:

- **Recoverable UI faults** — set through ReactiveUI's `IReactiveUIBuilder.WithExceptionHandler(...)`
(namespace `ReactiveUI.Builder`). Fires when a `ReactiveCommand` faults and nothing else observed
it. Logs the exception at `Error` and reports a generic message
(`"An unexpected error occurred; see the log for details."`) to the panel; in a DEBUG build it then
calls `Debugger.Break()`. The app stays alive. This is the pipeline's default observer for
`ThrownExceptions`, so it is the safety net beneath every command that forgot to subscribe.
- **`TaskScheduler.UnobservedTaskException`** — a fire-and-forget task fault that no one awaited. Logs
at `Error` and calls `SetObserved()` so the runtime does not escalate. Log-only, no panel report.
- **`AppDomain.CurrentDomain.UnhandledException`** — a throw on a background thread with no handler.
Logs at `Critical` (MEL `Critical` maps to Serilog `Fatal`) and runs `Log.CloseAndFlushAsync()` so
the stack reaches the log file before the process dies. It does not stop the death; it guarantees a
flushed stack, which is the whole point for background-thread throws that `Program.cs` never sees.

**Ordering.** `Install(IReactiveUIBuilder, IServiceProvider)` runs inside the `UseReactiveUI` build
callback in `App.Run`. `WithExceptionHandler` captures the handler once at build time, which happens
before the `InitializeServices` `AfterSetup` builds the first `ReactiveCommand`. The recoverable
handler is therefore in place before any command can throw. (ReactiveUI 23 dropped the settable
`RxApp.DefaultExceptionHandler`; the handler is configured only through the builder, so this is the
one wiring point that satisfies the "set before command construction" constraint.)

**Lazy panel resolve.** Each handler closure resolves `MessagePanelViewModel` from the provider at
fire time, never inside `Install`. Resolving it eagerly would construct the panel and its
`ToggleCommand` before the backstop is in place, defeating the purpose. The logger is resolved once
up front (logger construction has no side effects).

**Deliberate dev trade.** For an unsubscribed `ThrownExceptions`, the old behaviour was a loud crash
in dev. The backstop converts that into report-loudly plus `Debugger.Break` (DEBUG). An operator-facing
PLC tool is better served by a generic panel message in production than by a crash, and the dev signal
survives through the debugger break. `Install` is idempotent (a static `_installed` guard) so a second
call does not double-subscribe the OS events.
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using System;

using Avalonia.Headless.XUnit;

using FluentAssertions;

using Microsoft.Extensions.DependencyInjection;

using SemiStep.UI.Logging;
using SemiStep.UI.MessageService;

using Xunit;

namespace SemiStep.Tests.UI.Logging;

[Trait("Component", "UI")]
[Trait("Area", "MessageReporting")]
[Trait("Category", "Integration")]
public sealed class GlobalExceptionBackstopInstallTests
{
[AvaloniaFact]
public void RecoverableExceptionHandler_RoutesUnhandledExceptionToPanel()
{
var services = new ServiceCollection();
services.AddSingleton<MessagePanelViewModel>();
services.AddLogging();
var provider = services.BuildServiceProvider();

var panel = provider.GetRequiredService<MessagePanelViewModel>();

try
{
var handler = GlobalExceptionBackstop.CreateRecoverableExceptionHandler(provider);

handler.OnNext(new Exception("boom"));

var entry = panel.Entries.Should().ContainSingle().Subject;
entry.Severity.Should().Be(MessageSeverity.Error);
entry.Message.Should().Be(GlobalExceptionBackstop.RecoverableUserMessage);
}
finally
{
panel.Dispose();
provider.Dispose();
}
}
}
72 changes: 72 additions & 0 deletions SemiStep/SemiStep.Tests/UI/Logging/GlobalExceptionBackstopTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System;

using Avalonia.Headless.XUnit;

using FluentAssertions;

using Microsoft.Extensions.Logging;

using SemiStep.Tests.Helpers;
using SemiStep.UI.Logging;
using SemiStep.UI.MessageService;

using Xunit;

namespace SemiStep.Tests.UI.Logging;

[Trait("Component", "UI")]
[Trait("Area", "MessageReporting")]
[Trait("Category", "Unit")]
public sealed class GlobalExceptionBackstopTests
{
[AvaloniaFact]
public void ReportRecoverable_LogsExceptionAndReportsGenericPanelMessage()
{
var panel = new MessagePanelViewModel();
var logger = new RecordingLogger<object>();
var failure = new InvalidOperationException("boom");

try
{
GlobalExceptionBackstop.ReportRecoverable(panel, logger, failure);

var entry = panel.Entries.Should().ContainSingle().Subject;
entry.Severity.Should().Be(MessageSeverity.Error);
entry.Message.Should().Be(GlobalExceptionBackstop.RecoverableUserMessage);

var logged = logger.Entries.Should().ContainSingle().Subject;
logged.Level.Should().Be(LogLevel.Error);
logged.Exception.Should().BeSameAs(failure);
}
finally
{
panel.Dispose();
}
}

[Fact]
public void LogUnobserved_LogsExceptionAtErrorLevel()
{
var logger = new RecordingLogger<object>();
var failure = new InvalidOperationException("boom");

GlobalExceptionBackstop.LogUnobserved(logger, failure);

var logged = logger.Entries.Should().ContainSingle().Subject;
logged.Level.Should().Be(LogLevel.Error);
logged.Exception.Should().BeSameAs(failure);
}

[Fact]
public void LogFatal_LogsCriticalWithException()
{
var logger = new RecordingLogger<object>();
var failure = new InvalidOperationException("boom");

GlobalExceptionBackstop.LogFatal(logger, failure);

var logged = logger.Entries.Should().ContainSingle().Subject;
logged.Level.Should().Be(LogLevel.Critical);
logged.Exception.Should().BeSameAs(failure);
}
}
10 changes: 7 additions & 3 deletions SemiStep/SemiStep.UI/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Microsoft.Extensions.DependencyInjection;

using ReactiveUI.Avalonia;
using ReactiveUI.Builder;

using SemiStep.Core.Configuration;
using SemiStep.Core.Recipes;
Expand Down Expand Up @@ -64,21 +65,24 @@ public override void OnFrameworkInitializationCompleted()
base.OnFrameworkInitializationCompleted();
}

private static AppBuilder BuildAvaloniaApp()
private static AppBuilder BuildAvaloniaApp(Action<IReactiveUIBuilder>? configureReactiveUi = null)
{
return AppBuilder.Configure<App>()
.UseWin32()
.UseSkia()
.UseHarfBuzz()
.UseReactiveUI(_ => { })
.UseReactiveUI(configureReactiveUi ?? (_ => { }))
.LogToSerilog();
}

public static void Run(IServiceProvider serviceProvider)
{
ArgumentNullException.ThrowIfNull(serviceProvider);
EnsureSingleStart();
BuildAvaloniaApp()
// Install the backstop inside the UseReactiveUI build callback: WithExceptionHandler is captured
// once at build time, which runs before InitializeServices builds the first ReactiveCommand, so
// the recoverable handler is in place before any command can throw.
BuildAvaloniaApp(builder => GlobalExceptionBackstop.Install(builder, serviceProvider))
.AfterSetup(_ =>
// Initialize after setup: UseReactiveUI() has registered AvaloniaScheduler as
// MainThreadScheduler by now, so ReactiveCommand singletons capture it at construction.
Expand Down
125 changes: 125 additions & 0 deletions SemiStep/SemiStep.UI/Logging/GlobalExceptionBackstop.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
using System;
using System.Reactive;
using System.Threading.Tasks;
#if DEBUG
using System.Diagnostics;
#endif

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

using ReactiveUI.Builder;

using SemiStep.UI.MessageService;

using SerilogLog = Serilog.Log;

namespace SemiStep.UI.Logging;

internal static class GlobalExceptionBackstop
{
internal const string RecoverableUserMessage = "An unexpected error occurred; see the log for details.";

// ReactiveUI 23 dropped the settable RxApp.DefaultExceptionHandler; the pipeline handler is configured
// once through the builder (RxState.InitializeExceptionHandler), which runs before the first
// ReactiveCommand is built, so the backstop wins the capture. The two OS hooks are runtime events.
// Install is called exactly once (App.Run is gated by EnsureSingleStart; RunErrorWindow never installs),
// so no idempotency guard is needed.
public static void Install(IReactiveUIBuilder builder, IServiceProvider provider)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(provider);

var logger = ResolveLogger(provider);

builder.WithExceptionHandler(CreateRecoverableExceptionHandler(provider, logger));

TaskScheduler.UnobservedTaskException += (_, args) =>
{
LogUnobserved(logger, args.Exception);
args.SetObserved();
};

AppDomain.CurrentDomain.UnhandledException += (_, args) =>
{
if (args.ExceptionObject is Exception exception)
{
LogFatal(logger, exception);
}
else
{
logger.LogCritical(
"Unhandled non-exception object is terminating the process: {ExceptionObject}",
args.ExceptionObject);
}

// Flush so the fatal stack reaches the log file before the process dies.
SerilogLog.CloseAndFlushAsync().GetAwaiter().GetResult();
};
}

internal static IObserver<Exception> CreateRecoverableExceptionHandler(IServiceProvider provider)
{
ArgumentNullException.ThrowIfNull(provider);

return CreateRecoverableExceptionHandler(provider, ResolveLogger(provider));
}

private static IObserver<Exception> CreateRecoverableExceptionHandler(IServiceProvider provider, ILogger logger)
{
// Resolve the panel lazily at fire time: resolving it eagerly would construct MessagePanelViewModel
// and its ToggleCommand before the backstop is in place, defeating the purpose.
return Observer.Create<Exception>(exception =>
{
try
{
ReportRecoverable(provider.GetRequiredService<MessagePanelViewModel>(), logger, exception);
#if DEBUG
if (Debugger.IsAttached)
{
Debugger.Break();
}
#endif
}
catch (Exception handlerFailure)
{
// Terminal handler: a throw here would crash the app and defeat keep-alive.
logger.LogError(handlerFailure, "The global backstop handler itself threw");
}
});
}

// GlobalExceptionBackstop is a static type, so ILogger<T> cannot target it; build the same category
// name through the factory instead.
private static ILogger ResolveLogger(IServiceProvider provider)
{
return provider.GetRequiredService<ILoggerFactory>()
.CreateLogger(typeof(GlobalExceptionBackstop).FullName!);
}

internal static void ReportRecoverable(MessagePanelViewModel panel, ILogger logger, Exception exception)
{
ArgumentNullException.ThrowIfNull(panel);
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(exception);

logger.LogError(exception, "Unhandled exception reached the global backstop");
panel.ReportError(RecoverableUserMessage);
}

internal static void LogUnobserved(ILogger logger, Exception exception)
{
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(exception);

logger.LogError(exception, "Unobserved task exception reached the global backstop");
}

internal static void LogFatal(ILogger logger, Exception exception)
{
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(exception);

logger.LogCritical(exception, "Unhandled exception is terminating the process");
}
}
Loading