A Decision Table for Whether to Exit or Continue After an Unexpected Exception

· Updated: · · 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.

The order for checking what may be brokenDiagram 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.Can you fail just that operationReinitialize only a screen or connectionIs 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, and OutOfMemoryException, 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.

The three conditions for continuingDiagram 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.The failed unit can be discardedAll three hold, so continuing is acceptableShared state can be restoredExternal side effects can be accounted forThe 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.

Continue or terminate decisions for unexpected exceptionsDiagram 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 recoveryrequiresrequiresrequiresmay causenot recommended forrecommended forrecommended forrecommended formay causerecommended forimplementsimplementsmay causerecommended forrequiresnot recommended forimplementsnot recommended forincompatible withpreventsverified byUnexpected Exception (Broken Assumption)InvariantContinue processingUnit of FailureExternal Side Effectcatch (Exception) Swallowing AntipatternZombie Process (Alive but Stalled)Process TerminationStackOverflowExceptionAccessViolationExceptionEnvironment.FailFastOutOfMemoryExceptionMemory Corruption SymptomsEnvironment.ExitNative Interop BoundaryBackgroundServiceIHostApplicationLifetime.StopApplicationWindows ServiceUnhandled Exception HandlerState Recovery in Exception Handlers

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 OperationCanceledException was 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 NullReferenceException or InvalidOperationException was 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 AccessViolationException or StackOverflowException

In other words, these are the cases where you no longer know whether the app’s state can still be trusted after the exception.

The line between expected and unexpectedDiagram 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.YesNoAn exception was thrownCan the handling be decided up front in designExpected (may be infrequent)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.

The same continuation carries different weightDiagram 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.Continue running the appCarry on as if nothing happenedContinue after isolating the broken partThe same continuation carries different weight

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
YesNoNoYesNoYesNoYesUnexpected exceptionSigns of memory corruption / stack exhaustion / fatal resource exhaustion?Exit / FailFast / restartCan the failed unit be discarded?Lean toward exitingCan shared state be rolled back / reinitialized?Stop the subsystem or exitCan external side effects be accounted for?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.

The gap between where heap corruption happens and where it crashesDiagram 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.The heap is corrupted somewhereNothing crashes at that momentThe next code to touch it crashesThe 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.

Signals that catch a zombie processDiagram 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.Last processing timePrepare liveness signals in advanceBacklog countheartbeatThe continue-or-exit decision gets easierFollow-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
The dividing line for exceptions in UI operationsDiagram 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.Only discardable transient stateShared state or a partial updateUnexpected exception in a UI operationWhich state was touchedFail just that operation and continueLean 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
Splitting the policy in a resident loopDiagram 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.Resident loopBoundary of each itemParent loopCatch expected exceptionsAn escaped unexpected exception means exitPrevents 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
Handling failures at native boundariesDiagram 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.AccessViolationExceptionLean toward exitingSymptoms of heap corruption or a double freeSudden death at a callback boundaryTreat 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.

What pays off when exiting is the right leanDiagram 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.The exit-leaning conditions applyEngineering a graceful continuationEngineering for an easy recovery after crashingBarely pays off at this levelThis 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.

How to use the tables when in doubtDiagram 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.Unsure how to apply it to a situationCheck the conditions in the decision table in 3.1Find 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.

Double execution caused by a casual retryDiagram showing that retrying an operation with external side effects without any guarantee that running it again is safe makes double execution the dominant problem.An operation with external side effects failedRetry without re-execution safetyDouble execution becomes the dominant problemDevice 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.

The roles of the inner and outer catchDiagram 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.catch inside the loopRecord the unit of failurecatch outside the loopEnd the lifetimeMixed up, one failed item takes down the appMixed 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.

Flow of a record-only handlerDiagram 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.Register the handlers firstProceed to base.OnStartupRecord the unhandled exceptionDo 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 / finally blocks 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 ExecutionEngineException and 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.

Why FailFast skips cleanupDiagram 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.An invariant is brokenFailFast terminates immediatelyNo finally blocks and no finalizers runA dump and an event log entry are left (Windows)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.

  1. Can the failed unit be discarded?
  2. Can shared state be restored or recreated?
  3. Can external side effects be accounted for?
  4. 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.

The decision sequence from the summaryDiagram 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.Confident in all fourNot confidentCan the failed unit be discardedCan shared state be restoredCan external side effects be accounted forCan the health be trustedContinuing is possibleLean 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

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

This article connects naturally to the following service pages.

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.

Author Profile

Profile page for the article author.

Go Komura

Representative of KomuraSoft LLC

Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.

Back to the Blog