A Decision Table for Whether to Exit or Continue After an Unexpected Exception
· Updated: · Go Komura · Windows Development, Exception Handling, Design, C# / .NET, Reliability
Revision history (1 updates, last updated Sep 1, 2026)
A log of the changes made to this article. Where a pre-update version was archived, it stays readable at a permanent DOI link.
- Retranslated as a full translation of the Japanese original. The previous English version was an abridgement that carried only part of the source, so sections, tables, Mermaid diagrams, figure captions and FAQ entries were missing. All of them have been restored to match the Japanese original, and the technical claims are the same as in the Japanese version. Read the version before this update (DOI: 10.5281/zenodo.21614515)
- First published
Cite this article(DOI: 10.5281/zenodo.21614514)
This article is archived on Zenodo. Below are both the DOI that always resolves to the latest version and the DOI pinned to the version you are reading.
Go Komura (2026). A Decision Table for Whether to Exit or Continue After an Unexpected Exception. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21614514 https://comcomponent.com/en/blog/2026/03/16/005-unexpected-exception-exit-or-continue-decision-table/
- DOI (latest version)
- 10.5281/zenodo.21614514
- DOI (this version)
- 10.5281/zenodo.22217153
Download the Excel checklist with Japanese and English sheets
The file has two sheets, Checklist-ja and Checklist-en, and breaks the criteria in this article into 27 items across four categories: conditions for continuing / conditions for exiting / implementation policy / decision order. The Status and Notes columns are left blank, so you can use it as-is as an incident-response record or a design-review checklist.
When the topic of unexpected exceptions comes up, it is tempting to frame it as a binary choice: crash, or catch and keep going. In practice, though, that framing is a little crude.
What you really want to know is whether you can contain the range of what may have been corrupted.
- Can you fail just that one operation and stop there?
- Is it enough to reinitialize just that screen / connection / worker?
- Or is the integrity of the entire process now in question?
Looking at it in that order makes things much easier to sort out.
flowchart TB
accTitle: The order for checking what may be broken
accDescr: Diagram showing the order in which to check the range that may have been corrupted after an unexpected exception - whether that one operation alone can be failed, whether only a subsystem needs reinitializing, and whether the integrity of the whole process is in question.
q1["Can you fail just that operation"] --> q2["Reinitialize only a screen or connection"]
q2 --> q3["Is the whole process in question"]
Figure 1: Check the range that may have been corrupted in this order: the operation, the subsystem, then the whole process.
In this article, assuming C# / .NET Windows apps, resident apps, Windows services, and device-integration tools, we put together a decision table for the conditions under which it is acceptable to continue after an unexpected exception, and the conditions under which it is better to exit.
1. The Conclusion First
- Swallowing everything with
catch (Exception)and carrying on is dangerous in most cases. - Continuing is acceptable only when three things hold together: you can discard the failed unit, you can restore shared state, and you can account for external side effects.
- If the processing boundary is clear, as with one UI operation, one input record, or one job, continuation is sometimes possible.
- Conversely, if shared mutable state, resident loops, the main thread, startup code, native boundaries, or signs of memory corruption are involved, lean toward exiting.
- Exceptions that call the health of the entire process into question, such as
StackOverflowException,AccessViolationException, andOutOfMemoryException, are safer not to treat as something you can continue from. - WPF and Windows Forms do offer ways to catch unhandled exceptions and appear to keep running, but being able to continue and being safe to continue are different things.
- For long-running services and monitoring apps, crashing and being restarted is often safer, and easier to diagnose, than limping along half-broken.
In short, the axis of the decision is whether you can restore your invariants.
flowchart TB
accTitle: The three conditions for continuing
accDescr: Diagram showing that continuing is acceptable only when three conditions hold together - the failed unit can be discarded, shared state can be restored, and external side effects can be accounted for - and that the axis of the decision is whether invariants can be restored.
c1["The failed unit can be discarded"] --> ok3["All three hold, so continuing is acceptable"]
c2["Shared state can be restored"] --> ok3
c3["External side effects can be accounted for"] --> ok3
ok3 -.-> jiku["The axis is whether invariants can be restored"]
Figure 2: Continuing is acceptable only when all three conditions hold: the failed unit, shared state, and external side effects.
1.1 Terms Used in This Article
Before reading the decision table, let us pin down the five words that keep coming up.
| Term | What it means in this article |
|---|---|
| Invariant | A promise about state that must hold both before and after an operation. Examples include the line-item total matching the aggregate value, the cache and the DB holding the same content, and every open connection appearing in the management list. Once this breaks and the app keeps running, every subsequent operation becomes suspect |
| Unit of failure | The range you can discard wholesale when something fails. One operation, one screen, one job, one connection, and so on |
| External side effect | A change that has already left the process. DB updates, file writes, email sends, commands sent to a device: things catch cannot undo |
| Subsystem | A unit you can stop and reinitialize as a whole. A connection, a screen, a worker, a child process, and so on |
FailFast |
Environment.FailFast. An API that terminates the process immediately without running try / finally or finalizers; section 9.7 covers the details |
Knowledge map for this article
This article sets out that the response to an unexpected exception is not to swallow it with catch (Exception) and keep processing, but to decide between continuing and terminating based on whether the invariants of the shared state, the unit of failure, and the external side effects can be explained. Exceptions that come with signs of memory corruption, such as StackOverflowException, AccessViolationException, and a serious OutOfMemoryException, and faults at a native boundary such as COM or P/Invoke lean toward termination, and Environment.FailFast and Environment.Exit are the means of implementing that termination. It also covers the case where the parent loop of a BackgroundService goes down on an unexpected exception: as long as it runs as a Windows service, the recovery actions do not work unless an exit code is returned with Environment.Exit, and unhandled exception handlers such as AppDomain.UnhandledException should be used for logging and are not a means of recovering state.
flowchart LR
accTitle: Continue or terminate decisions for unexpected exceptions
accDescr: Diagram showing that deciding between continuing and terminating on an unexpected exception presupposes checking the invariants, the unit of failure, and the external side effects, that exceptions suspected of memory corruption and faults at a native boundary lean toward termination, that an exit code from Environment.Exit is required for the recovery actions of a Windows service when a BackgroundService hits an unexpected exception, and that unhandled exception handlers should be used for logging rather than for recovery
unexpected_exception["Unexpected Exception (Broken Assumption)"]
invariant["Invariant"]
continue_processing["Continue processing"]
failure_unit["Unit of Failure"]
external_side_effect["External Side Effect"]
catch_exception_antipattern["catch (Exception) Swallowing Antipattern"]
zombie_process["Zombie Process (Alive but Stalled)"]
terminate_process["Process Termination"]
stackoverflowexception["StackOverflowException"]
accessviolationexception["AccessViolationException"]
environment_failfast["Environment.FailFast"]
outofmemoryexception["OutOfMemoryException"]
memory_corruption_symptom["Memory Corruption Symptoms"]
environment_exit["Environment.Exit"]
native_interop_boundary["Native Interop Boundary"]
backgroundservice["BackgroundService"]
stopapplication["IHostApplicationLifetime.StopApplication"]
windows_service["Windows Service"]
unhandled_exception_handler["Unhandled Exception Handler"]
exception_recovery_attempt["State Recovery in Exception Handlers"]
continue_processing -->|"requires"| invariant
continue_processing -->|"requires"| failure_unit
continue_processing -->|"requires"| external_side_effect
catch_exception_antipattern -.->|"may cause"| zombie_process
catch_exception_antipattern -->|"not recommended for"| unexpected_exception
terminate_process -->|"recommended for"| stackoverflowexception
terminate_process -->|"recommended for"| accessviolationexception
environment_failfast -->|"recommended for"| outofmemoryexception
accessviolationexception -.->|"may cause"| memory_corruption_symptom
environment_failfast -->|"recommended for"| memory_corruption_symptom
environment_failfast -->|"implements"| terminate_process
environment_exit -->|"implements"| terminate_process
native_interop_boundary -.->|"may cause"| memory_corruption_symptom
terminate_process -.->|"recommended for"| native_interop_boundary
backgroundservice -.->|"requires"| environment_exit
stopapplication -.->|"not recommended for"| windows_service
backgroundservice -->|"implements"| windows_service
unhandled_exception_handler -->|"not recommended for"| exception_recovery_attempt
catch_exception_antipattern -.->|"incompatible with"| backgroundservice
environment_failfast -.->|"prevents"| external_side_effect
unexpected_exception -->|"verified by"| unhandled_exception_handler
In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (21 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle
2. What “Unexpected Exception” Means in This Article
2.1 Separating Expected from Unexpected
First, a rare exception and an unexpected exception are not the same thing.
For example, these can be treated as expected even if they are infrequent.
- The user selected a file that does not exist
- The remote endpoint timed out temporarily
- One row of an imported CSV was malformed
- An
OperationCanceledExceptionwas thrown by a cancel operation - A business-rule violation should fail just that one operation
These are the kind of failures whose handling can be decided up front in the design.
By contrast, the unexpected exceptions this article mainly deals with look like this.
- An assumption in your own code broke and a
NullReferenceExceptionorInvalidOperationExceptionwas thrown - An exception flew out mid-update of shared state, and it is unclear how much was applied
- The parent loop of a monitoring loop or message-processing loop died
- Something went wrong at a COM / P/Invoke / vendor SDK boundary
- The process itself fails its health check, as with
AccessViolationExceptionorStackOverflowException
In other words, these are the cases where you no longer know whether the app’s state can still be trusted after the exception.
flowchart TB
accTitle: The line between expected and unexpected
accDescr: Diagram showing that an exception whose handling can be decided up front in the design counts as expected even when it is infrequent, and that an exception after which you cannot tell whether the app state can be trusted is what this article calls unexpected.
e1["An exception was thrown"] --> q1{"Can the handling be decided up front in design"}
q1 -->|"Yes"| soutei["Expected (may be infrequent)"]
q1 -->|"No"| sotogai["Unexpected (state trust unknown)"]
Figure 3: A rare exception and an unexpected exception are different things; the dividing line is whether the handling can be decided up front in the design.
2.2 It Looks Like Two Choices, But There Are Really Three
The culprit that makes this discussion confusing is treating continuation as a single option.
In practice, it usually breaks down into three levels.
| Choice | Meaning |
|---|---|
| Fail only that operation and continue | Keep the screen, but treat just this save or import as failed |
| Stop only the subsystem and continue | Reinitialize only the connection, screen, worker, or child process |
| Exit the process | The extent of state corruption cannot be determined, so assume a restart |
Saying “the app continues” covers two very different things: carrying on as if nothing happened, and continuing after isolating the broken part.
flowchart TB
accTitle: The same continuation carries different weight
accDescr: Diagram showing that carrying on as if nothing happened and continuing after isolating the broken part carry very different weight, even though both are described as continuing to run the app.
keizoku["Continue running the app"] --> k1["Carry on as if nothing happened"]
keizoku --> k2["Continue after isolating the broken part"]
k1 -.-> omomi["The same continuation carries different weight"]
k2 -.-> omomi
Figure 4: Do not treat continuation as a single option; distinguish it from continuing after isolating the broken part.
3. The Decision Table to Look at First
3.1 The Big Picture
Start with this table and the general direction is usually settled.
| Situation | First choice | Reason |
|---|---|---|
| Only one input, one screen operation, or one job failed, and its state can be discarded | Lean toward continuing | The failed unit can be contained |
| After the exception, the affected object or connection can be disposed and recreated | Lean toward subsystem reinitialization | The damaged area can be localized |
| Shared state was partially updated and it is unclear how much was applied | Lean toward exiting | Invariants may have been broken |
| External side effects such as DB writes, files, or device commands are half-done and you cannot account for duplicates or missing writes | Lean toward exiting | Consistency with the outside world cannot be determined |
| The monitoring loop, reconnection loop, or parent message-processing loop died from an unexpected exception | Lean toward exiting | Silently losing part of the functionality tends to create a zombie process |
| Startup, configuration loading, DI composition, or initialization of a required dependency failed | Lean toward exiting as a startup failure | Starting half-initialized is more dangerous |
AccessViolationException, StackOverflowException, a severe OutOfMemoryException, or signs of corruption on the native side |
Lean toward immediate exit | The health of the entire process is in question |
| The dangerous work is isolated in a separate process and the parent process is untouched | Parent continues, restart the child | The fault domain is already isolated |
flowchart TD
A["Unexpected exception"] --> B{"Signs of memory corruption / stack exhaustion / fatal resource exhaustion?"}
B -- "Yes" --> Z["Exit / FailFast / restart"]
B -- "No" --> C{"Can the failed unit be discarded?"}
C -- "No" --> Y["Lean toward exiting"]
C -- "Yes" --> D{"Can shared state be rolled back / reinitialized?"}
D -- "No" --> X["Stop the subsystem or exit"]
D -- "Yes" --> E{"Can external side effects be accounted for?"}
E -- "No" --> X
E -- "Yes" --> W["Continue, failing only that operation"]
Figure 5: The flow for settling on a direction by checking signs of corruption, the unit of failure, shared state, and external side effects in that order.
The “signs of memory corruption” in the first branch of the diagram are covered concretely in 3.4, and the FailFast in the top right in 9.7.
3.2 What to Check Before the Exception Type
It is better not to decide on the exception type alone. These are the things to check first.
| Aspect | What to confirm |
|---|---|
| Where it happened | A UI event, a single job, a parent loop, startup code, or a native boundary |
| How far it got | Whether in-memory state, the DB, files, or device state changed partway through |
| Possible blast radius | Just that object, the whole screen, or the whole process |
| Rollback possible? | Whether it can be disposed and recreated, or rolled back with a transaction |
| External side effects | Sent or not sent, whether double execution is safe, whether compensation is possible |
| Monitoring and restart | Whether there is automatic restart or a recovery path after exiting |
3.3 High-Risk Exceptions
You do not need to go through every exception type in detail, but some should never be viewed with continuation in mind.
| Exception / symptom | First choice | Why it matters |
|---|---|---|
StackOverflowException |
Lean toward immediate exit | The call stack has collapsed, so normal recovery is hard to assume |
AccessViolationException |
Lean toward immediate exit | Illegal access to protected memory, so native boundaries or memory corruption are suspect |
OutOfMemoryException |
Lean toward exiting | Recovery code that itself needs further allocations tends to be unstable |
An unexpected NullReferenceException / InvalidOperationException |
Context-dependent, but lean toward exiting | Your own assumptions broke, and partial changes may remain |
| An unexpected exception that escaped a parent loop | Lean toward exiting | The core of the feature is dead while the process risks staying alive |
| Failures originating in COM / P/Invoke / vendor SDK callbacks | Immediate exit to strongly exit-leaning | Safety is hard to judge from the managed side alone |
3.4 What “Signs of Memory Corruption” and “Zombie Process” Actually Refer To
These two sound like vague, intuitive phrases, but what you are actually looking at is quite specific.
First, the symptoms that make memory corruption a suspect.
| What you see | Where to look |
|---|---|
The process crashes with exception code 0xc0000005 (access violation) or 0xc0000374 (heap corruption) |
Event Viewer > Windows Logs > Application, the Application Error entry (Event ID 1000) |
| The crash location differs every time. It crashes in code you did not touch just before | Logs, the call stack in a dump |
| It crashes only when you touch an object or handle that should already have been released | Reproduction steps, dumps |
It crashes in native release code (free / delete / a COM release) |
The call stack in a dump, for example crashing inside ntdll.dll |
| A value you never touched has changed. The same input produces a different result | Input/output logs, comparison of re-run results |
0xc0000005 is STATUS_ACCESS_VIOLATION and 0xc0000374 is STATUS_HEAP_CORRUPTION; both are values defined in Microsoft’s NTSTATUS list. Heap corruption in particular does not crash at the moment of corruption but on the next code that touches the corrupted heap, so the crash site is not necessarily the culprit. If you are seeing symptoms of this shape, it is safer to assume that catching on the managed side and carrying on is pointless.
flowchart TB
accTitle: The gap between where heap corruption happens and where it crashes
accDescr: Diagram showing that heap corruption does not crash at the moment of corruption but on the next code that touches the corrupted heap, so the crash site is not necessarily the culprit.
h1["The heap is corrupted somewhere"] --> h2["Nothing crashes at that moment"]
h2 --> h3["The next code to touch it crashes"]
h3 -.-> h4["The crash site is not necessarily the culprit"]
Figure 6: Heap corruption crashes on the next code that touches it, so the crash site and the corruption site diverge.
Next, the symptoms that make a zombie process a suspect.
| What you see | Where to look |
|---|---|
| The process is alive, but the last processing time never advances | The last-processing-time log, heartbeat |
| Only the backlog in the queue or the receive folder keeps growing | Queue length, number of unprocessed files |
| Logs stop dead from a certain time onward | Application logs |
| The screen still responds, but only the background updates have stopped | Cross-checking what the screen shows against the real data |
| The number of worker threads is lower than expected | Diagnostic logs, the thread list in Process Explorer |
A zombie process is not one that has failed to crash; it is one that is alive without doing any work. Preparing signals of liveness in advance, such as the last processing time, the backlog count, and a heartbeat, makes both the continue-or-exit decision and the follow-up investigation considerably easier.
flowchart TB
accTitle: Signals that catch a zombie process
accDescr: Diagram showing that a zombie process is one that is alive without doing any work, and that preparing signals of liveness such as the last processing time, the backlog count, and a heartbeat in advance makes both the decision and the follow-up investigation easier.
z1["Last processing time"] --> z4["Prepare liveness signals in advance"]
z2["Backlog count"] --> z4
z3["heartbeat"] --> z4
z4 --> z5["The continue-or-exit decision gets easier"]
z4 --> z6["Follow-up investigation gets easier"]
Figure 7: Catch a zombie process with signals that show work is happening, not with the fact that nothing crashed.
4. Deciding by Where It Happened
4.1 UI Events
UI events such as a button click, screen navigation, search, or file selection have relatively large room for continuation. There are conditions, however.
Continuation is easier in cases like these.
- The failure happened before loading, and business state has not been touched yet
- Only transient state inside a dialog is broken, and closing the dialog discards it
- The ViewModel or connection can be recreated after the exception
- You can honestly tell the user that this operation failed
Conversely, you should lean toward exiting once things look like this.
- Both the screen and the domain state were partially updated
- Shared state that other screens also read, such as static fields, singletons, or caches, was touched
- After the exception, button enablement or selection state is left over and consistency is unclear
- An unexpected exception occurred on the UI thread, and it is unclear how far rendering or notifications progressed
flowchart TB
accTitle: The dividing line for exceptions in UI operations
accDescr: Diagram showing that an exception in a UI event is easy to continue from when only discardable transient state was touched, and leans toward exiting when shared state that other screens also read or a partial update was involved.
u1["Unexpected exception in a UI operation"] --> q1{"Which state was touched"}
q1 -->|"Only discardable transient state"| u2["Fail just that operation and continue"]
q1 -->|"Shared state or a partial update"| u3["Lean toward exiting"]
Figure 8: UI events leave plenty of room to continue, but touching shared state changes the story.
4.2 Jobs / Requests Processed One at a Time
This is a boundary where continuation is easy.
- One message
- One file
- One HTTP request
- One import job
- One batch item
If units like these are well defined, you can fail just that one item and move on to the next.
There are prerequisites, though.
- The unit of failure is clear from the outside
- Partial changes are tidied up by transactions or compensation
- Running the same processing again does not corrupt the result
- Failures can be routed to a quarantine queue or an error log
4.3 Resident Loops / Monitoring / Queue Processing
This is the worst place to continue carelessly.
For example:
- Reconnection loops
- Monitoring loops
- Queue-consumption loops
- Periodic polling
- Device status monitoring
- Background processing in a tray app
The scary failure mode in this kind of processing is that the parent loop dies from a single unexpected exception while the process alone survives.
Here it pays to split the policy.
- Catch expected exceptions at the boundary of each item’s processing
- If an unexpected exception escapes the parent loop, lean toward terminating the process
flowchart TB
accTitle: Splitting the policy in a resident loop
accDescr: Diagram showing how to split the policy in resident processing - catch expected exceptions at the boundary of each item, and lean toward terminating the process when an unexpected exception escapes the parent loop.
loop1["Resident loop"] --> b1["Boundary of each item"]
loop1 --> b2["Parent loop"]
b1 --> r1["Catch expected exceptions"]
b2 --> r2["An escaped unexpected exception means exit"]
r2 -.-> zb["Prevents the process from surviving on its own"]
Figure 9: Split the policy between the item boundary and the parent loop, and route unexpected exceptions from the parent loop to an exit.
4.4 Startup
Treating a startup failure as “start up anyway and figure it out later” leaves the app running with part of its functionality missing, which makes the cause much harder to isolate later.
- Required configuration cannot be read
- Version migration failed
- A required folder or certificate is missing
- Initialization of a core service failed
- The dependency configuration is broken
In cases like these, exiting as a startup failure is the clearer choice.
4.5 Native Boundaries / COM / P/Invoke / unsafe
This area deserves its own category and a somewhat stricter eye.
- COM
- P/Invoke
- Code beyond C++/CLI
- Vendor SDKs
- Native-side code coming back through callbacks
- Anything involving
unsafe
Lean toward exiting especially when you see any of these.
AccessViolationException- Symptoms suggesting heap corruption or a double free
- Handle anomalies, signs of use-after-free
- Sudden death at a callback boundary
flowchart TB
accTitle: Handling failures at native boundaries
accDescr: Diagram showing that at native boundaries such as COM and P/Invoke you should lean toward exiting once you see AccessViolationException, symptoms suggesting heap corruption, or sudden death at a callback boundary, and that the boundary itself deserves its own stricter category.
n4["AccessViolationException"] --> n3["Lean toward exiting"]
n5["Symptoms of heap corruption or a double free"] --> n3
n6["Sudden death at a callback boundary"] --> n3
n3 -.-> n2["Treat native boundaries as their own stricter category"]
Figure 10: When these symptoms show up at a native boundary, drop the assumption of continuing and lean toward exiting.
5. Conditions Under Which Continuing Is Acceptable
Summarized, the conditions under which continuation is acceptable look like this. The premise is that most of them hold at the same time.
| Condition | Meaning |
|---|---|
| The unit of failure is clear | You know what to discard: one operation, one screen, one job, one connection |
| State can be discarded | It can be disposed and recreated, or treated as never applied |
| Shared state is protected | The contamination does not spread to other features |
| External side effects can be accounted for | You know whether it was sent, not sent, or safe to resend |
| You can be honest with the user | You can display that this operation failed |
| It is observable | Logs, metrics, and dumps allow follow-up investigation |
6. Conditions Under Which Exiting Is Better
Conversely, if any of these apply, lean toward exiting.
- You do not know what was changed partway through
- Shared mutable state was touched and consistency cannot be determined
- Lifetime management of locks, queues, threads, or monitoring loops is broken
- Duplicated, missing, or half-done external side effects cannot be accounted for
- Startup or initialization of core infrastructure failed
- Native boundaries or memory corruption are suspect
At this level, engineering for an easy recovery after crashing pays off more than engineering a graceful continuation.
flowchart TB
accTitle: What pays off when exiting is the right lean
accDescr: Diagram showing that at a level where the exit-leaning conditions apply, engineering for an easy recovery after crashing pays off more than engineering a graceful continuation.
j1["The exit-leaning conditions apply"] --> j2["Engineering a graceful continuation"]
j1 --> j3["Engineering for an easy recovery after crashing"]
j2 -.-> j4["Barely pays off at this level"]
j3 -.-> j5["This is the one that pays off"]
Figure 11: In exit-leaning situations, invest in ease of recovery rather than in tricks for continuing.
7. Recommendations by Typical Pattern
The decision table in 3.1 is written in terms of conditions, so applying it to a real situation can leave you hesitating. The table below re-applies those conditions to situations you run into often. When in doubt, the fastest route is to check the conditions in 3.1 first, then look for the closest row here.
flowchart TB
accTitle: How to use the tables when in doubt
accDescr: Diagram showing the order to follow when you are unsure how to apply the criteria to a situation - first check the conditions in the decision table in 3.1, then look for the closest row in the table in this section.
m1["Unsure how to apply it to a situation"] --> m2["Check the conditions in the decision table in 3.1"]
m2 --> m3["Find the closest row in the table in this section"]
Figure 12: Reading from the table of conditions to the table of situations, in that order, keeps you from getting stuck.
| Pattern | Recommendation | Reason |
|---|---|---|
| A nonexistent path was specified via the file-open button | Continue, failing only that operation | The state damage is local |
| Only one row of a CSV import was malformed | Continue with one row failed or one file failed | The unit of failure is easy to contain |
An unexpected NullReferenceException occurred midway through saving a screen |
Recreate the screen, leaning toward exit | It is unclear how much of the ViewModel or business state changed |
| One queue message violated a business rule | Continue, failing only that message | It can be routed to a quarantine queue |
| The parent queue-consumption loop died from an unexpected exception | Lean toward exiting the process | The lifetime of the entire worker is broken |
| Required configuration cannot be read at startup | Exit as a startup failure | A half-initialized start is more dangerous |
An AccessViolationException around a vendor SDK callback |
Lean toward immediate exit | The possibility of memory corruption cannot be ignored |
| Only a non-essential telemetry send failed | Disable just that feature and continue | The fault domain can be separated from the main functionality |
8. Common Anti-Patterns
8.1 catch (Exception) That Just Logs and Continues
This is quite dangerous. It hides the cause while keeping the broken state alive.
8.2 Trying to Recover in the Last-Chance Unhandled-Exception Handler
AppDomain.UnhandledException, Application.ThreadException, DispatcherUnhandledException, and the like are useful as the place to record things last, but they are not magic recovery points.
8.3 Casually Retrying When External Side Effects Are Involved
If you retry device commands, email sends, billing, file moves, or DB updates without any guarantee that running the same operation again is safe, double execution becomes the dominant problem instead.
flowchart TB
accTitle: Double execution caused by a casual retry
accDescr: Diagram showing that retrying an operation with external side effects without any guarantee that running it again is safe makes double execution the dominant problem.
y1["An operation with external side effects failed"] --> y2["Retry without re-execution safety"]
y2 --> y3["Double execution becomes the dominant problem"]
y1 -.-> y4["Device commands, billing, sends, and the like"]
Figure 13: A retry without re-execution safety invites double execution.
8.4 Keeping the UI Alive After the Monitoring Loop Died
An app that only looks alive while doing no work appears normal to users, which is exactly why it goes unnoticed for so long. Make sure your monitoring can pick up the zombie symptoms from 3.4.
8.5 Saying “We Don’t Want It to Crash” Without Designing for Crashes
If you do not want it to crash, there are things to put in place first.
- Automatic restart
- Session restore
- Saving intermediate results
- Re-execution safety
- Fault-domain isolation
9. Points to Sort Out at Implementation Time
9.1 Push catch Sites to Boundaries
Rather than catching everything in deep layers, it is easier to keep things organized by catching at places where a unit of failure can be defined, such as:
- UI operation boundaries
- Per-request boundaries
- Per-job boundaries
- Per-connection boundaries
- The process boundary
For an import that processes one item at a time, for instance, the catch goes inside the loop. That is the unit of failure.
// C# / .NET 8. A loop that processes one item at a time, confining the unit of failure inside the loop.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
public sealed record ImportItem(string Id, string Payload);
public sealed record ImportFailure(string Id, string ExceptionType, string Message);
public sealed record ImportSummary(int Succeeded, IReadOnlyList<ImportFailure> Failed);
public interface IImportStore
{
/// <summary>
/// Throw ImportItemException for a failure of a single item (validation error,
/// duplicate, malformed format, and so on). Anything else is not a problem with
/// this one item, so let it propagate out as-is.
/// </summary>
Task SaveAsync(ImportItem item, CancellationToken cancellationToken);
}
/// <summary>A single-item failure that can be discarded so processing moves on.</summary>
public sealed class ImportItemException(string message, Exception? inner = null)
: Exception(message, inner);
public sealed class ImportRunner(IImportStore store, ILogger<ImportRunner> logger)
{
public async Task<ImportSummary> RunAsync(
IReadOnlyList<ImportItem> items,
CancellationToken cancellationToken)
{
int succeeded = 0;
List<ImportFailure> failed = [];
foreach (ImportItem item in items)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await store.SaveAsync(item, cancellationToken);
succeeded++;
}
catch (ImportItemException ex)
{
// The state for one item can be discarded, so record it and move to the next one.
//
// Do not turn this into catch (Exception). Swallowing NullReferenceException
// or OutOfMemoryException as if it were just bad data means the unexpected
// exceptions that 4.3 decided should stop the whole host never reach the
// parent loop. You would keep writing the remaining items long after the
// state stopped being trustworthy
logger.LogError(ex, "Import failed. ItemId={ItemId}", item.Id);
failed.Add(new ImportFailure(item.Id, ex.GetType().Name, ex.Message));
}
}
return new ImportSummary(succeeded, failed);
}
}
Conversely, the matching decision is that the parent loop driving this work must not swallow anything. As described in 4.3, the worst shape is a dead parent loop with only the process left alive, so unexpected exceptions are wired up to stop the host.
// The resident loop. A stop request exits normally; any other unexpected exception stops the whole host.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
public interface IImportQueue
{
Task<IReadOnlyList<ImportItem>> DequeueBatchAsync(CancellationToken cancellationToken);
}
public sealed class ImportWorker(
IImportQueue queue,
ImportRunner runner,
IHostApplicationLifetime lifetime,
ILogger<ImportWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
using PeriodicTimer timer = new(TimeSpan.FromSeconds(10));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
IReadOnlyList<ImportItem> batch = await queue.DequeueBatchAsync(stoppingToken);
ImportSummary summary = await runner.RunAsync(batch, stoppingToken);
logger.LogInformation(
"Batch finished. Succeeded={Succeeded} Failed={Failed}",
summary.Succeeded,
summary.Failed.Count);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// A stop request is the normal path. Finish quietly here.
}
catch (Exception ex)
{
// A broken parent loop means the lifetime of the whole worker is broken.
// Do not swallow it and create a state where only the process is alive;
// stop and hand over to the restart.
logger.LogCritical(ex, "Worker loop failed unexpectedly. Stopping the host.");
// StopApplication is a request to shut down cleanly. On its own, a supervisor
// that only restarts on failure (service recovery actions, systemd's
// Restart=on-failure, a container restart policy) cannot tell it apart from
// finishing the work and exiting normally, so the worker never comes back up.
//
// Setting Environment.ExitCode is not enough either. When running as a Windows
// service, a clean host shutdown reports SERVICE_STOPPED to the SCM, and
// ExitCode is not reflected in the service's exit state, so recovery actions
// do not run. Terminate the process with a non-zero exit code
Environment.Exit(1);
}
}
}
The key point is the use of Environment.Exit(1). IHostApplicationLifetime.StopApplication() is a request for a normal shutdown, so when you run as a Windows service, SERVICE_STOPPED is reported to the SCM. The process’s Environment.ExitCode is not reflected in the service’s exit state, so the recovery actions configured in the service properties (Restart the Service) do not run. The official worker service tutorial says the same thing: the default BackgroundServiceExceptionBehavior.StopHost stops cleanly, so Windows service management does not restart it, and making recovery actions take effect requires calling Environment.Exit with a non-zero exit code (see the references in section 11).
Environment.Exit ends the current process, so write out any logs you want before the crash above this line. If you use a buffering logger, flush it here. Conversely, if you only ever target systemd or containers rather than a Windows service, setting Environment.ExitCode and then shutting down with StopApplication() is also treated as a failure by the supervisor. Choose based on where the app will run.
It is also worth noting that the role of catch differs inside and outside the loop. The inner one records the unit of failure; the outer one ends the lifetime. Mix them up and either a single failed item takes down the app, or the process survives after the worker has died.
flowchart TB
accTitle: The roles of the inner and outer catch
accDescr: Diagram showing that the catch inside the loop records the unit of failure while the catch outside it ends the lifetime, and that mixing the two makes a single failed item take down the app or lets the process survive after the worker has died.
c1["catch inside the loop"] --> c2["Record the unit of failure"]
c3["catch outside the loop"] --> c4["End the lifetime"]
c2 -.-> ng1["Mixed up, one failed item takes down the app"]
c4 -.-> ng2["Mixed up, only the process is left alive"]
Figure 14: The role of catch changes inside and outside the loop, and mixing them up breaks things in both directions.
9.2 Separate Expected from Unexpected Exceptions
- Expected: validation, not found, timeout, cancel, business-rule violations
- Unexpected: broken assumptions, escapes from parent loops, native-boundary failures, signs of memory corruption
9.3 Keep Shared State Small
The larger your shared mutable state, the harder the continuation decision becomes. Conversely, the more you can confine state inside one screen, one session, or one worker, the easier it is to confine failures as well.
9.4 Move Dangerous Work to a Separate Process
For anything where you do not want a crash to spread, such as COM, ActiveX, vendor SDKs, unsafe code, heavy image processing, or external device control, putting it in a separate process pays off considerably.
9.5 Unhandled-Exception Handlers Are for “Recording,” Not “Recovery”
- Exception details
- The operation context
- The last important log entries
- Configuration, version, connection targets
- A path to collecting dumps
Getting these in place and prioritizing a setup where you can dig in after the crash leads to better stability in the end.
In WPF, a handler for recording only needs to be about this size.
// App.xaml.cs in WPF (.NET 8). The handlers are for recording, not recovery.
using System;
using System.IO;
using System.Reflection;
using System.Threading.Tasks;
using System.Windows;
namespace SampleApp;
public partial class App : Application
{
private static readonly string CrashLogPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"SampleApp",
"crash.log");
protected override void OnStartup(StartupEventArgs e)
{
// Register the handlers before base.OnStartup(e).
// base.OnStartup raises the Startup event, so if a subscriber throws,
// handlers written after it are not registered yet, and you end up in the
// worst shape of all: the app crashed at startup and not a single log line remains.
// A failure in startup itself is exactly what you want recorded, so hook up first.
// Exceptions that reached the top of the UI thread unhandled
DispatcherUnhandledException += (_, args) =>
{
Record("DispatcherUnhandledException", args.Exception);
// Setting args.Handled = true lets you continue, but whether you should
// is decided by the conditions in section 5.
// If you cannot tell, record it and leave the default behavior (exit) in place.
args.Handled = false;
};
// The last notification, including threads other than the UI thread.
// Nothing can be stopped here, so this is for recording only.
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
Record("AppDomain.UnhandledException", args.ExceptionObject as Exception);
// Exceptions from Tasks that were collected without ever being awaited
TaskScheduler.UnobservedTaskException += (_, args) =>
{
Record("UnobservedTaskException", args.Exception);
args.SetObserved();
};
// Only after everything is hooked up, proceed to the default startup (raising the Startup event)
base.OnStartup(e);
}
private static void Record(string source, Exception? exception)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(CrashLogPath)!);
string version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
string text = string.Join(
Environment.NewLine,
$"[{DateTimeOffset.Now:O}] {source}",
$"version={version} os={Environment.OSVersion} user={Environment.UserName}",
exception?.ToString() ?? "(no exception object)",
string.Empty);
File.AppendAllText(CrashLogPath, text);
}
catch
{
// Even if recording fails, do not block the shutdown path.
}
}
}
The point is not to try to fix state inside the handler. All you do here is leave behind the material that lets you chase the same symptom next time.
flowchart TB
accTitle: Flow of a record-only handler
accDescr: Diagram showing the WPF flow of registering the handlers before base.OnStartup and only then proceeding with startup, and of doing nothing inside the handler but leaving behind the material that lets you chase the same symptom next time.
w1["Register the handlers first"] --> w2["Proceed to base.OnStartup"]
w2 --> w3["Record the unhandled exception"]
w3 -.-> w4["Do not try to fix the state"]
Figure 15: Finish registration before startup runs, and do nothing but record inside the handler.
9.6 Do Not Over-Trust the WPF / WinForms Unhandled-Exception Events
In WPF, setting Handled = true in DispatcherUnhandledException does let you keep running after an unhandled exception.
In Windows Forms, on the main UI thread, Application.ThreadException and the SetUnhandledExceptionMode setting let you choose how the app stops.
But whether you can keep running and whether the conditions for recovery are met are separate questions.
9.7 Use Environment.FailFast Only When Cleaning Up Is the More Dangerous Option
The FailFast that appears in the flowchart in 3.1 is Environment.FailFast. The official documentation describes its behavior as follows.
- Terminates the process without running any in-flight
try/finallyblocks or finalizers - On Windows, writes the message you passed to the Windows application event log and creates a dump of the application before terminating
- The message and exception information are also included in the error report sent to Microsoft through Windows Error Reporting
- Calling it under the Visual Studio debugger produces an
ExecutionEngineExceptionand raises the fatalExecutionEngineError managed debugging assistant
Skipping finally is not a shortcoming; it is the purpose of this API. Running cleanup code while the state is broken can write the broken content straight into a file or a database. The documentation says the same: use FailFast rather than Environment.Exit when the app state is unrecoverably corrupted and running try / finally blocks or finalizers would damage resources.
The split works out like this.
| Situation | What to choose |
|---|---|
| The invariants are broken and running cleanup is the more dangerous option | Environment.FailFast |
| The state is healthy and you want to clean up before finishing | A normal shutdown path (IHostApplicationLifetime.StopApplication and the like) |
| You simply want to return an exit code and finish | Environment.Exit, or returning from Main |
In code, you use it somewhere like this.
// C# / .NET 8. The point where a broken invariant on shared state is detected.
// From here on, no cleanup code can be trusted.
if (cache.Count != store.Count)
{
Environment.FailFast(
$"Invariant broken: cache={cache.Count} store={store.Count}",
new InvalidOperationException("Cache and store are out of sync."));
}
Since a dump is collected automatically, putting which invariant broke and with what values into the message you pass to FailFast makes the later investigation considerably easier. Conversely, calling FailFast for an expected failure such as bad input or a network error is overkill. That case belongs back with the unit-of-failure discussion in 9.1.
flowchart TB
accTitle: Why FailFast skips cleanup
accDescr: Diagram showing that because running cleanup code while the state is unrecoverably corrupted risks writing the broken content out, Environment.FailFast terminates without running try or finally blocks or finalizers, and on Windows leaves an event log entry and a dump.
f1["An invariant is broken"] --> f2["FailFast terminates immediately"]
f2 --> f3["No finally blocks and no finalizers run"]
f2 --> f4["A dump and an event log entry are left (Windows)"]
f3 -.-> f5["Prevents the broken content from being written out"]
Figure 16: By skipping cleanup, FailFast prevents the broken state from being written out.
10. Summary
When an unexpected exception occurs, the question to ask is not “can this exception be caught” but whether the app’s state can still be trusted afterward.
As a decision sequence, this is usually enough.
- Can the failed unit be discarded?
- Can shared state be restored or recreated?
- Can external side effects be accounted for?
- Can the health of memory, threads, and native boundaries be trusted?
If you are confident in all four, you can continue. If you are not, lean toward exiting.
flowchart TB
accTitle: The decision sequence from the summary
accDescr: Diagram showing the decision flow of asking in turn whether the failed unit can be discarded, whether shared state can be restored, whether external side effects can be accounted for, and whether health including native boundaries can be trusted, continuing when all four are confident and leaning toward exiting otherwise.
g1["Can the failed unit be discarded"] --> g2["Can shared state be restored"]
g2 --> g3["Can external side effects be accounted for"]
g3 --> g4["Can the health be trusted"]
g4 -->|"Confident in all four"| g5["Continuing is possible"]
g4 -->|"Not confident"| g6["Lean toward exiting"]
Figure 17: Answer the four questions in order, and choose to continue only when all of them are a confident yes.
Especially for long-running apps, monitoring apps, services, and device integration, there are plenty of situations where staying alive broken is more dangerous than crashing honestly.
Exception handling is not the art of never crashing. It is designing so that failures stay small, the app stops honestly when broken, and recovery is easy.
11. References
- .NET: Best practices for exceptions
- .NET: Create a Windows Service using BackgroundService (the default
BackgroundServiceExceptionBehavior.StopHoststops the host cleanly, so Windows service management does not restart it; making recovery actions take effect requires callingEnvironment.Exitwith a non-zero exit code) - .NET: System.Exception
- .NET: StackOverflowException
- .NET: System.AccessViolationException
- .NET: Environment.FailFast
- .NET: AppDomain.UnhandledException
- WPF: Application.DispatcherUnhandledException
- Windows Forms: Application.SetUnhandledExceptionMode
- .NET: Exceptions in Managed Threads
- .NET: TaskScheduler.UnobservedTaskException
- Windows: NTSTATUS Values - MS-ERREF
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
Where Should catch and Logging Go in Exception Handling?
To avoid broad catches in deep helpers, duplicate logs at every layer, and result-mapping that hides root causes, we organize the respons...
A Minimum Security Checklist for Windows App Development
A checklist-style guide to the security basics for WPF / WinForms / WinUI / C++ / C# business apps: privileges, signing, updates, secrets...
Code Design for Business Systems — Deciding Product and Customer Codes, and Check Digits
A practical guide to deciding the code scheme for a business system, including product and customer codes. Covers a decision table for me...
An Introduction to ADRs (Architecture Decision Records) — The Minimal Way to Record 'Why We Designed It This Way' on a Small Team
Code never explains why it was written that way. We cover how to use an ADR (Architecture Decision Record) — one decision, one Markdown f...
When Not to Move a Windows App to the Web: A Decision Table and the Practical Answer of Splitting
Requests to move in-house Windows apps to the web are increasing, but for apps built around device integration, local file processing, of...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
Where This Topic Connects
This article connects naturally to the following service pages.
Technical Consulting & Design Review
This topic covers exception-handling policy, fault boundaries, restart strategy, and criteria for deciding whether to continue, so it pairs well with technical consulting and design reviews.
Bug Investigation & Root Cause Analysis
Working out whether to continue or exit after an unexpected exception—including state corruption and external side effects—maps naturally onto bug investigation and root-cause analysis.
Frequently Asked Questions
Common questions about the topic of this article.
- Is it wrong to swallow an unexpected exception with catch (Exception) and carry on?
- Just logging and carrying on is dangerous in most cases, because it hides the cause and makes it easy to keep broken state alive. Continuing is acceptable only when three things hold together: you can discard the failed unit, you can restore shared state, and you can account for external side effects. The axis of the decision is not 'can this exception be caught' but 'can the app's state still be trusted afterward'.
- Which exceptions should trigger an immediate exit?
- Exceptions that call the health of the entire process into question, such as StackOverflowException, AccessViolationException, and a severe OutOfMemoryException, are safer not to treat as something you can continue from. With StackOverflowException the call stack has collapsed, and AccessViolationException means illegal access to protected memory, so memory corruption is suspect. Failures originating at COM, P/Invoke, or vendor SDK callbacks are also strongly exit-leaning, because safety is hard to judge from the managed side alone.
- Under what conditions is it acceptable to keep the app running after an exception?
- The premise is that most of these conditions hold at the same time: the unit of failure is clear (you know what to discard, such as one operation, one screen, one job, or one connection), state can be discarded and recreated, contamination does not spread to shared state, external side effects can be accounted for, you can honestly tell the user that this operation failed, and logs and metrics allow follow-up investigation. When the processing boundary is clear, as with one UI operation or one import job, continuing is sometimes possible. Conversely, a partial update of shared state, a parent loop, startup code, and failures at native boundaries all lean toward exiting.
- Can I continue by setting Handled = true in WPF's DispatcherUnhandledException?
- Continuing after an unhandled exception is possible in itself, but being able to continue and being safe to continue are different problems. Handlers such as AppDomain.UnhandledException and DispatcherUnhandledException are useful as the place to record things last, but they are not magic recovery points. Getting exception details, operation context, and a path to collecting dumps in place, so that you can investigate after the crash, leads to better stability in the end. Long-running services and monitoring apps in particular are often safer, and easier to diagnose, when they crash and get restarted than when they limp along half-broken.