From f17c6e91e80db2e876372b8fe10bea84a757e226 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:44:10 +0300 Subject: [PATCH] feat(ui): add global exception backstop Nothing sat under the running dispatcher. Program.Main has a top-level try/catch, but it only wraps startup; once the Avalonia dispatcher is pumping, an unsubscribed ThrownExceptions, an unobserved fire-and-forget task fault, or a background-thread throw had no handler at all. The first crashed or vanished silently, the last died with no stack logged. Add GlobalExceptionBackstop, installed once at ReactiveUI build time (before the first ReactiveCommand is constructed), wiring three last-resort handlers: - the recoverable ReactiveUI handler logs the exception and reports a generic message to the panel, keeping the app alive (Debugger.Break in DEBUG when a debugger is attached); - TaskScheduler.UnobservedTaskException logs and marks the fault observed; - AppDomain.UnhandledException logs at Critical and flushes Serilog before the process dies, so a background-thread crash leaves a stack. The panel is resolved lazily at fire time so installing the handler does not construct a command before it is set. This is the last-resort ring only: it does not change the happy path and does not replace the per-command report+log ring. --- Docs/architecture/error-reporting.md | 37 +++++ .../GlobalExceptionBackstopInstallTests.cs | 47 ++++++ .../Logging/GlobalExceptionBackstopTests.cs | 72 +++++++++ SemiStep/SemiStep.UI/App.axaml.cs | 10 +- .../Logging/GlobalExceptionBackstop.cs | 125 ++++++++++++++++ ...0260727-pr-f2-global-exception-backstop.md | 137 ++++++++++++++++++ 6 files changed, 425 insertions(+), 3 deletions(-) create mode 100644 SemiStep/SemiStep.Tests/UI/Logging/GlobalExceptionBackstopInstallTests.cs create mode 100644 SemiStep/SemiStep.Tests/UI/Logging/GlobalExceptionBackstopTests.cs create mode 100644 SemiStep/SemiStep.UI/Logging/GlobalExceptionBackstop.cs create mode 100644 docs/plans/completed/20260727-pr-f2-global-exception-backstop.md diff --git a/Docs/architecture/error-reporting.md b/Docs/architecture/error-reporting.md index c74ec24..12952b0 100644 --- a/Docs/architecture/error-reporting.md +++ b/Docs/architecture/error-reporting.md @@ -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. diff --git a/SemiStep/SemiStep.Tests/UI/Logging/GlobalExceptionBackstopInstallTests.cs b/SemiStep/SemiStep.Tests/UI/Logging/GlobalExceptionBackstopInstallTests.cs new file mode 100644 index 0000000..52dff5e --- /dev/null +++ b/SemiStep/SemiStep.Tests/UI/Logging/GlobalExceptionBackstopInstallTests.cs @@ -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(); + services.AddLogging(); + var provider = services.BuildServiceProvider(); + + var panel = provider.GetRequiredService(); + + 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(); + } + } +} diff --git a/SemiStep/SemiStep.Tests/UI/Logging/GlobalExceptionBackstopTests.cs b/SemiStep/SemiStep.Tests/UI/Logging/GlobalExceptionBackstopTests.cs new file mode 100644 index 0000000..b7d8311 --- /dev/null +++ b/SemiStep/SemiStep.Tests/UI/Logging/GlobalExceptionBackstopTests.cs @@ -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(); + 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(); + 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(); + 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); + } +} diff --git a/SemiStep/SemiStep.UI/App.axaml.cs b/SemiStep/SemiStep.UI/App.axaml.cs index 290457a..c1068ac 100644 --- a/SemiStep/SemiStep.UI/App.axaml.cs +++ b/SemiStep/SemiStep.UI/App.axaml.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using ReactiveUI.Avalonia; +using ReactiveUI.Builder; using SemiStep.Core.Configuration; using SemiStep.Core.Recipes; @@ -64,13 +65,13 @@ public override void OnFrameworkInitializationCompleted() base.OnFrameworkInitializationCompleted(); } - private static AppBuilder BuildAvaloniaApp() + private static AppBuilder BuildAvaloniaApp(Action? configureReactiveUi = null) { return AppBuilder.Configure() .UseWin32() .UseSkia() .UseHarfBuzz() - .UseReactiveUI(_ => { }) + .UseReactiveUI(configureReactiveUi ?? (_ => { })) .LogToSerilog(); } @@ -78,7 +79,10 @@ 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. diff --git a/SemiStep/SemiStep.UI/Logging/GlobalExceptionBackstop.cs b/SemiStep/SemiStep.UI/Logging/GlobalExceptionBackstop.cs new file mode 100644 index 0000000..3340505 --- /dev/null +++ b/SemiStep/SemiStep.UI/Logging/GlobalExceptionBackstop.cs @@ -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 CreateRecoverableExceptionHandler(IServiceProvider provider) + { + ArgumentNullException.ThrowIfNull(provider); + + return CreateRecoverableExceptionHandler(provider, ResolveLogger(provider)); + } + + private static IObserver 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 => + { + try + { + ReportRecoverable(provider.GetRequiredService(), 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 cannot target it; build the same category + // name through the factory instead. + private static ILogger ResolveLogger(IServiceProvider provider) + { + return provider.GetRequiredService() + .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"); + } +} diff --git a/docs/plans/completed/20260727-pr-f2-global-exception-backstop.md b/docs/plans/completed/20260727-pr-f2-global-exception-backstop.md new file mode 100644 index 0000000..f09e60f --- /dev/null +++ b/docs/plans/completed/20260727-pr-f2-global-exception-backstop.md @@ -0,0 +1,137 @@ +# PR-F2: Global exception backstop + +## Overview + +The last-resort ring of the error pipe (see `20260727-error-reporting-pipe-roadmap.md`). Today +nothing sits under the running dispatcher: `Program.Main` has a top-level try/catch, but it only +wraps startup — once `App.Run` → `StartWithClassicDesktopLifetime` is pumping, an unsubscribed +`ThrownExceptions`, an unobserved fire-and-forget task fault, or a background-thread throw has no +handler. The first crashes or vanishes silently; the last dies with no stack in the log. + +This PR adds one `GlobalExceptionBackstop.Install(IReactiveUIBuilder, IServiceProvider)` wiring three +handlers, installed at `UseReactiveUI` build time **before any command is constructed**. It does not +change the happy path and does not replace the per-command ring (F1) — it catches only what escapes +every inner ring. + +## Context (grounded) + +- `App.Run` (`SemiStep/SemiStep.UI/App.axaml.cs:77-92`): `BuildAvaloniaApp().AfterSetup(InitializeServices).AfterSetup(set _serviceProvider).StartWithClassicDesktopLifetime`. `InitializeServices` (`:94-108`) is the FIRST `AfterSetup` and resolves `RecipeCoordinator` → which constructs `MessagePanelViewModel` → whose ctor builds `ToggleCommand` (`MessagePanelViewModel.cs:42`). So the first command is built during that first `AfterSetup`. +- The pipeline exception handler is captured by ReactiveUI at `ReactiveCommand` construction (a `ScheduledSubject` default observer), not read at throw time. **It must be set before any command is built.** In ReactiveUI 23.2.28 there is no settable `RxApp.DefaultExceptionHandler`; the handler is supplied through `IReactiveUIBuilder.WithExceptionHandler(...)` (namespace `ReactiveUI.Builder`), applied once at `UseReactiveUI` build time via `RxState.InitializeExceptionHandler`, which runs before the first command is built. +- Serilog is the log sink; static `Log.Fatal`/`Log.Warning` are already used at the composition root (`Program.cs`, `App.axaml.cs:102`). `Log.CloseAndFlushAsync()` is the flush (`Program.cs` finally). DI exposes `ILogger` via `AddLogging(AddSerilog)` (`Program.cs`). +- `MessagePanelViewModel.ReportError` (`:171-174`) self-marshals to the UI thread (`PostOnUiThread`, `:211-221`). Resolving the singleton the first time constructs it — but `InitializeServices` already constructs it during startup, after the backstop is installed, so a lazy resolve at throw time returns the built instance. +- No generic user-facing error string exists in `Localization/Resources.resx`. Use a plain-English message for now (consistent with F1's raw-message error branches); #115 localizes later. +- The extension/panel decision from F1: consumers use the concrete `MessagePanelViewModel` (no `IMessageSink`). The backstop follows suit — it resolves the concrete panel from the provider. + +## Development Approach + +- Regular (code, then tests). Warnings are errors; build must stay clean. +- The three handlers' report/log LOGIC is extracted into small testable methods asserted directly. The recoverable observer factory (`CreateRecoverableExceptionHandler(provider)`) is verified by one integration test that resolves a real `MessagePanelViewModel` from a minimal provider, pushes `handler.OnNext(...)`, and asserts the panel report; the build-time ordering and the `AppDomain`/`TaskScheduler` OS hooks are covered by code review plus the manual smoke checklist (they cannot be fired deterministically in-process). +- `dotnet build SemiStep.slnx` (0 warnings) + `dotnet test` after each task. + +## Acceptance Evidence + +**Automatable:** +1. `GlobalExceptionBackstopTests` — the recoverable handler logic logs the exception (Error) AND reports a generic message to a real `MessagePanelViewModel`; the unobserved handler logs (Error) only; the fatal handler logs (Critical). Capturing `ILogger` (`RecordingLogger`). `--filter "FullyQualifiedName~GlobalExceptionBackstop"`. +2. Recoverable-handler integration test — build a minimal provider exposing a real `MessagePanelViewModel` + `AddLogging`, obtain the observer via `CreateRecoverableExceptionHandler(provider)`, push `handler.OnNext(new Exception("boom"))`, assert the panel reported the generic message. No global state is mutated (the ReactiveUI 23 handler is un-overridable at runtime), so no save/restore and no `[Collection]` are needed. This exercises the observer factory + DI resolution + panel wiring, NOT the real pipeline routing or build-time ordering (those are manual-smoke). + +**Manual smoke** (OS hooks / startup order — not automatable in-process): +1. Trigger an unsubscribed `ThrownExceptions` (temporarily a command with no handler) → app stays alive, panel shows the generic message, log has one Error with the stack; in a DEBUG build with a debugger attached, execution breaks. +2. Fire a fire-and-forget task fault → log has one Error, no crash. +3. Throw on a background thread → `Log.Fatal` with the stack is written (flushed) before the process exits. +4. Confirm `Install` runs before `InitializeServices` (breakpoint/log order, or a temporary ordering assertion). + +Full suite green + `dotnet build SemiStep.slnx` (0 warnings) is the gate. + +## Progress Tracking + +Mark `[x]` on completion; `➕` new tasks; `⚠️` blockers. + +## Solution Overview + +- `GlobalExceptionBackstop` (`SemiStep.UI/Logging/`) exposes `static Install(IReactiveUIBuilder builder, IServiceProvider provider)` and small `internal static` handler-logic methods. `Install` registers the recoverable handler on the builder and subscribes the two OS hooks; each hook closure **lazily** resolves the panel from the provider at fire time (never eagerly inside `Install`, which would construct `MessagePanelViewModel`/`ToggleCommand` under the pre-backstop handler), then calls the pure logic method plus its side effect. +- Handler map: + - `IReactiveUIBuilder.WithExceptionHandler(...)` → `Observer.Create(ex => { ReportRecoverable(panel, logger, ex); <#if DEBUG> Debugger.Break(); })`. Keeps the app alive. Captured once at `UseReactiveUI` build time. + - `TaskScheduler.UnobservedTaskException` → `LogUnobserved(logger, e.Exception); e.SetObserved();`. Log-only. + - `AppDomain.CurrentDomain.UnhandledException` → `LogFatal(logger, ex); Log.CloseAndFlushAsync().GetAwaiter().GetResult();`. Process still dies, but with a flushed stack. +- `Install` is called from the `UseReactiveUI` build callback inside `BuildAvaloniaApp` in `App.Run`. That callback runs at build time, before the `InitializeServices` `AfterSetup` builds the first command, so the recoverable handler wins the capture. + +## Technical Details + +- `ReportRecoverable(MessagePanelViewModel panel, ILogger logger, Exception ex)`: `logger.LogError(ex, "Unhandled exception reached the global backstop")` then `panel.ReportError()`. `Debugger.Break()` lives in the `Install` closure under `#if DEBUG`, NOT in this method, so the method stays cleanly testable. +- `LogUnobserved(ILogger logger, Exception ex)`: `logger.LogError(ex, "Unobserved task exception reached the global backstop")`. +- `LogFatal(ILogger logger, Exception ex)`: `logger.LogCritical(ex, "Unhandled exception is terminating the process")` (MEL `Critical` → Serilog `Fatal`). +- `Install(IReactiveUIBuilder builder, IServiceProvider provider)` resolves the logger once from the provider (logger construction has no side effects) and captures it; the panel is resolved lazily inside each closure via `provider.GetRequiredService()`. The recoverable handler is registered via `builder.WithExceptionHandler(...)`; the two OS hooks are subscribed on `TaskScheduler`/`AppDomain`. + +## What Goes Where + +- **Implementation Steps** (checkboxes): the backstop class, tests, and the `App.Run` wiring. +- **Post-Completion**: the manual smoke checklist and the PR note that this changes the "unsubscribed `ThrownExceptions` crashes loudly in dev" behavior to report-loudly + `Debugger.Break`. + +## Implementation Steps + +### Task 1: Add `GlobalExceptionBackstop` with testable handler logic + +**Files:** +- Create: `SemiStep/SemiStep.UI/Logging/GlobalExceptionBackstop.cs` +- Create: `SemiStep/SemiStep.Tests/UI/Logging/GlobalExceptionBackstopTests.cs` + +- [x] create `GlobalExceptionBackstop` with `internal static ReportRecoverable(MessagePanelViewModel, ILogger, Exception)`, `internal static LogUnobserved(ILogger, Exception)`, `internal static LogFatal(ILogger, Exception)` per Technical Details (report+log / log-only / log-critical); no `Debugger.Break` inside these +- [x] write tests: `ReportRecoverable` logs Error with the exception AND reports the generic message to a real `MessagePanelViewModel`; `LogUnobserved` logs Error only; `LogFatal` logs Critical. Use the shared `RecordingLogger` (`SemiStep.Tests/Helpers/RecordingLogger.cs`) +- [x] run `--filter "FullyQualifiedName~GlobalExceptionBackstop"` — green before next task + +### Task 2: `Install` the three hooks, lazily and in the right order + +**Files:** +- Modify: `SemiStep/SemiStep.UI/Logging/GlobalExceptionBackstop.cs` +- Create: `SemiStep/SemiStep.Tests/UI/Logging/GlobalExceptionBackstopInstallTests.cs` + +- [x] add `public static void Install(IReactiveUIBuilder builder, IServiceProvider provider)`: resolve the logger once; register the recoverable observer via `builder.WithExceptionHandler(...)` (an `Observer.Create` closure that lazily resolves the panel, calls `ReportRecoverable`, and `#if DEBUG Debugger.Break();`); subscribe `TaskScheduler.UnobservedTaskException` → `LogUnobserved` + `SetObserved()`; subscribe `AppDomain.CurrentDomain.UnhandledException` → `LogFatal` + `CloseAndFlushAsync().GetAwaiter().GetResult()` +- [x] idempotency: no guard needed — `Install` is provably single-call (App.Run is gated by `EnsureSingleStart`; `RunErrorWindow` uses the no-arg `BuildAvaloniaApp` and never installs), so the OS events cannot be double-subscribed. The builder handler is also one-shot in `RxState`. +- [x] write an integration test: build a minimal provider exposing a real `MessagePanelViewModel` + `AddLogging`, obtain the observer via `CreateRecoverableExceptionHandler(provider)`, push `handler.OnNext(new Exception("boom"))`, assert the panel reported the generic message +- [x] run `--filter "FullyQualifiedName~GlobalExceptionBackstop"` — green before next task + +➕ **API deviation (ReactiveUI 23.2.28 / ReactiveUI.Avalonia 12.0.3):** `RxApp` is gone. `RxState.DefaultExceptionHandler` is get-only and one-shot; the pipeline handler is set only through `IReactiveUIBuilder.WithExceptionHandler(...)` (namespace `ReactiveUI.Builder`), applied once at `UseReactiveUI` build time via `RxState.InitializeExceptionHandler`. Consequences: (1) `Install` now takes the builder — signature `Install(IReactiveUIBuilder builder, IServiceProvider provider)`. (2) The recoverable observer was extracted into `internal static IObserver CreateRecoverableExceptionHandler(IServiceProvider)` so it is testable without the un-overridable global; the integration test exercises the observer directly (no `RxState` save/restore, so no `[Collection]` needed). (3) **Task 3 changes:** wire via `.UseReactiveUI(builder => GlobalExceptionBackstop.Install(builder, serviceProvider))` in `App.Run`, NOT a new `AfterSetup`. `WithExceptionHandler` runs before the first `ReactiveCommand`, so the "set before command construction" ordering constraint still holds. (4) **`_installed` flag dropped in review (commit bc37741):** the originally planned static `_installed` guard was removed as YAGNI. `Install` is provably single-call (App.Run gated by `EnsureSingleStart`; `RunErrorWindow` never installs), so the OS events cannot be double-subscribed, and the "avoid mutable static state" rule favors no flag. + +### Task 3: Wire `Install` into `App.Run` at `UseReactiveUI` build time + +**Files:** +- Modify: `SemiStep/SemiStep.UI/App.axaml.cs` + +- [x] give `BuildAvaloniaApp` an optional `Action? configureReactiveUi = null` parameter passed into `.UseReactiveUI(configureReactiveUi ?? (_ => { }))`; `Run` passes `builder => GlobalExceptionBackstop.Install(builder, serviceProvider)` so `WithExceptionHandler` is captured at build time, before the `InitializeServices` `AfterSetup` builds the first command. Add a comment stating that ordering constraint. (NOT a new `AfterSetup` — the `RxApp.DefaultExceptionHandler` property this version needs does not exist.) +- [x] keep `RunErrorWindow` backstop-free: it calls `BuildAvaloniaApp()` with no argument (default no-op configurator), so it installs no handler and has no service provider to resolve one from +- [x] confirm `serviceProvider` closure is available (it is the `Run` parameter; the backstop uses it directly, not `app._serviceProvider`) +- [x] `dotnet build SemiStep.slnx` — 0 warnings; run the full UI test slice to confirm no startup regression +- [x] (no new unit test here — startup wiring is covered by Task 2's integration test + the manual smoke checklist) + +### Task 4: Verify + document + +**Files:** +- Modify: `Docs/architecture/error-reporting.md` + +- [x] full suite: `dotnet test SemiStep/SemiStep.Tests/SemiStep.Tests.csproj` — 1478 passed, 0 failed, 0 skipped (Performance probes are `Explicit`, not run) +- [x] `dotnet build SemiStep.slnx` — 0 warnings, 0 errors +- [x] `dotnet format SemiStep.slnx` — no changes +- [x] update `error-reporting.md`: add the global-backstop as the last-resort ring — the three handlers, the "set before command construction" constraint, the lazy panel resolve, and the deliberate dev-behavior trade (report-loudly + `Debugger.Break` instead of crash) +- [x] walk the manual smoke checklist in Acceptance Evidence (OS hooks / startup order need a running app; covered by the manual smoke checklist, not automatable in-process) +- [x] mark this plan for archival at delivery (do NOT move it mid-run) — archival deferred to delivery/ship + +## Post-Completion + +**Manual verification:** the four smoke scenarios in Acceptance Evidence (OS hooks + startup order) need a running app; they are not automatable in-process. + +**PR note:** this converts "an unsubscribed `ThrownExceptions` crashes loudly in dev" into report-loudly + `Debugger.Break` (DEBUG). For an operator-facing PLC tool, a generic panel message in production beats a crash; the dev signal survives via the debugger break. The `AppDomain` handler does not prevent process death — it guarantees a flushed stack in the log, which is the whole point for background-thread throws today invisible to `Program.cs`. + +**Executed by exec:** +- branch: global-exception-backstop + +## Verify it yourself + +The backstop only acts when something escapes every inner ring, so the automated proof is the handler logic; the real OS-hook routing is manual smoke (a running app). + +- **Handler logic (report/log policy):** `dotnet test SemiStep/SemiStep.Tests/SemiStep.Tests.csproj --filter "FullyQualifiedName~GlobalExceptionBackstop"` — the recoverable handler logs the exception AND reports the generic message to a real panel; the unobserved handler logs Error; the fatal handler logs Critical. The install test drives the recoverable observer and asserts the panel report. +- **No regression / build:** full `dotnet test` (1478 pass) and `dotnet build SemiStep.slnx` (0 warnings). +- **Manual smoke (needs a running app — not automatable in-process):** + 1. Force an unsubscribed `ThrownExceptions` (temporarily a command with no handler) → app stays alive, panel shows the generic message, log has one Error with the stack; in a DEBUG build with a debugger attached, execution breaks. + 2. Fire a fire-and-forget task fault → log has one Error, no crash. + 3. Throw on a background thread → `LogCritical` (Serilog Fatal) with the stack is flushed to the log before the process exits. + 4. Confirm `Install` runs before `InitializeServices` (it wires at `UseReactiveUI` build time, which precedes the first `AfterSetup`).