Skip to content

feat(ui): add global exception backstop - #148

Merged
mrcsin merged 1 commit into
masterfrom
global-exception-backstop
Jul 27, 2026
Merged

feat(ui): add global exception backstop#148
mrcsin merged 1 commit into
masterfrom
global-exception-backstop

Conversation

@mrcsin

@mrcsin mrcsin commented Jul 27, 2026

Copy link
Copy Markdown
Member

Problem

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, three failure classes had no
handler at all: an unsubscribed ThrownExceptions, an unobserved fire-and-forget task fault,
and a background-thread throw. The first crashed or vanished silently; the last died with no
stack in the log, leaving a field-reported background crash undiagnosable.

What changed

Add GlobalExceptionBackstop, installed once at ReactiveUI build time (before the first
ReactiveCommand is constructed), wiring three last-resort handlers:

  • Recoverable ReactiveUI handler (via IReactiveUIBuilder.WithExceptionHandler): logs the
    exception and reports a generic message to the panel, keeping the app alive. In DEBUG with a
    debugger attached it breaks. The observer body is wrapped so the terminal handler can never itself
    crash the app.
  • TaskScheduler.UnobservedTaskException: logs Error and marks the fault observed.
  • AppDomain.CurrentDomain.UnhandledException: logs at Critical (Serilog Fatal) — including a
    non-Exception thrown object — and flushes Serilog before the process dies, so a background-thread
    crash leaves a stack behind.

The panel is resolved lazily at fire time so installing the handler doesn't construct a command
before it's set. This is the last-resort ring only — it doesn't change the happy path and doesn't
replace the per-command report+log ring from PR-F1.

This is PR-F2 of the error/logging pipe roadmap. Follow-on: #113 (F3) rebased on F1+F2.

Note on ReactiveUI: the pipe roadmap was written against RxApp.DefaultExceptionHandler, but
this project's ReactiveUI (23.2.28) removed RxApp; the handler is set via the one-shot
WithExceptionHandler at UseReactiveUI build time. Build-time registration is what guarantees the
"before first command construction" ordering.

Verification

  • dotnet build SemiStep.slnx — 0 warnings (warnings are errors).
  • dotnet test SemiStep/SemiStep.Tests/SemiStep.Tests.csproj — 1478 passed, 0 failed (+4 new tests).
  • dotnet format clean; Rider analyzer clean on the changed files.
  • Automated covers the handler logic (report+log / log-only / log-critical, exception identity) and the recoverable observer→panel wiring.
  • Manual smoke (an unhandled command fault keeps the app alive + logs; a fire-and-forget fault logs; a background-thread throw flushes a fatal stack; install precedes command construction) was run — these OS-hook / one-shot-API paths cannot be fired deterministically in-process.
  • Ran through the full multi-agent review chain (comprehensive, smells, comment audit, critical); the ReactiveUI-API deviation was caught and adapted mid-flow, and the observer was hardened (guarded Debugger.Break, never-throw body, non-Exception fatal logging).
Per-task commits before collapse
9d532fd feat(ui): add global exception backstop handler logic
06d5203 feat(ui): install global exception backstop hooks
99898f6 feat(ui): install exception backstop at ReactiveUI build time in App.Run
f6ab971 docs(architecture): document the global exception backstop ring
bc37741 fix(ui): harden backstop observer and reconcile F2 plan doc
2406df4 docs(plans): reconcile F2 plan with dropped _installed flag
101732d refactor(ui): qualify Serilog flush and log non-exception fatals
ee4bd87 docs(plans): record exec branch + verification steps for PR-F2

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.
@mrcsin
mrcsin marked this pull request as ready for review July 27, 2026 15:49
@mrcsin
mrcsin merged commit ce11e27 into master Jul 27, 2026
1 check passed
@mrcsin
mrcsin deleted the global-exception-backstop branch July 27, 2026 15:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant