Tabla de decisión: terminar o continuar ante una excepción inesperada

· Actualizado el: · · Desarrollo en Windows, Manejo de excepciones, Diseño, C# / .NET, Confiabilidad

Descargar la lista de verificación en Excel con hojas en japonés e inglés

Este archivo tiene dos hojas, Checklist-ja y Checklist-en, y descompone los criterios de decisión de este artículo en 27 puntos agrupados en 4 categorías: condiciones para continuar / condiciones para terminar / enfoque de implementación / orden de decisión. Las columnas Status y Notes quedan vacías, de modo que se puede usar tal cual como lista de verificación para registrar la respuesta a incidentes o para revisiones de diseño.

Cuando se habla de excepciones inesperadas, es fácil caer en pensar en una disyuntiva simple: «¿dejamos que la aplicación se cierre, o la capturamos con catch y seguimos?». Sin embargo, en la práctica, plantear la cuestión como esa disyuntiva resulta un poco tosco.

Lo que de verdad importa observar es si se puede confinar el alcance de lo que posiblemente se dañó.

  • ¿Basta con que falle únicamente esa operación?
  • ¿Basta con reinicializar solo esa pantalla, conexión o worker?
  • ¿Ya resulta dudosa la integridad de todo el proceso?

Mirarlo en este orden facilita bastante la organización de las ideas.

Este artículo toma como referencia aplicaciones de Windows en C# / .NET, aplicaciones residentes, servicios de Windows y herramientas de integración con equipos, y resume en forma de tabla de decisión las condiciones bajo las cuales se puede continuar y aquellas en las que conviene terminar cuando ocurre una excepción inesperada.

1. Primero, la conclusión

  • Capturar con catch (Exception), tragarse el error y continuar suele ser peligroso.
  • Solo conviene continuar cuando se cumplen tres condiciones a la vez: se puede descartar la unidad que falló, se puede revertir el estado compartido y se pueden explicar los efectos externos.
  • Cuando el límite del procesamiento es claro -una operación de la interfaz, una entrada, un trabajo- a veces se puede continuar.
  • Por el contrario, si están de por medio un estado compartido y modificable, un bucle residente, el hilo principal, el proceso de inicio, un límite nativo o indicios de corrupción de memoria, conviene inclinarse hacia terminar.
  • Ante excepciones que ponen en duda la «salud de todo el proceso», como StackOverflowException, AccessViolationException u OutOfMemoryException, es más seguro no partir de la idea de continuar.
  • WPF y Windows Forms ofrecen la posibilidad de capturar una excepción no controlada y seguir en apariencia, pero poder continuar y poder continuar con seguridad son cosas distintas.
  • En servicios de larga duración y aplicaciones de monitoreo, sobrevivir a medio romper suele ser menos seguro y más difícil de diagnosticar que caer y reiniciarse.

En definitiva, el eje de la decisión es si se puede recuperar el invariante.

1.1 Términos usados en este artículo

Antes de leer la tabla de decisión, conviene fijar de antemano cinco términos que aparecerán una y otra vez.

Término Significado en este artículo
Invariante Una promesa sobre el estado que debe cumplirse siempre, antes y después del procesamiento. Por ejemplo, «el total de las líneas coincide con el valor agregado», «el contenido de la caché y de la base de datos están alineados» o «toda conexión abierta figura en la lista de gestión». Si esto se rompe y el sistema sigue funcionando, todo el procesamiento posterior se vuelve sospechoso
Unidad de fallo El alcance que se puede descartar por completo cuando algo falla: una operación, una pantalla, un trabajo, una conexión, etc.
Efecto externo Un cambio que ya salió del proceso: una actualización en la base de datos, una escritura en un archivo, el envío de un correo, el envío de un comando a un equipo, etc. Es algo que un catch no puede deshacer
Subsistema Una unidad que se puede detener y reinicializar como conjunto: una conexión, una pantalla, un worker, un proceso hijo, etc.
FailFast Se refiere a Environment.FailFast. Es una API que termina el proceso de inmediato sin ejecutar ni el try / finally ni los finalizadores; el detalle se trata en el apartado 9.7

2. Qué entendemos aquí por “excepción inesperada”

2.1 Diferenciar lo previsto de lo imprevisto

Antes que nada, una excepción poco frecuente no es lo mismo que una excepción inesperada.

Por ejemplo, aunque ocurran con poca frecuencia, las siguientes situaciones se pueden considerar previstas.

  • El usuario seleccionó un archivo que no existe
  • El destino de la comunicación tuvo un tiempo de espera agotado temporalmente
  • Una línea del CSV que se está importando estaba corrupta
  • Una operación de cancelación generó OperationCanceledException
  • Se quiere que solo ese procesamiento falle por violar una regla de negocio

Estos son casos en los que se puede decidir de antemano, en el diseño, cómo tratar ese fallo.

Por otro lado, las excepciones inesperadas que trata principalmente este artículo son de este tipo.

  • Se rompió una premisa del propio código y surgió NullReferenceException o InvalidOperationException
  • Se lanzó una excepción a mitad de la actualización de un estado compartido y resulta dudoso hasta dónde se aplicó
  • El bucle principal de monitoreo o de procesamiento de mensajes se cayó
  • Apareció una anomalía en el límite con COM, P/Invoke o un SDK de terceros
  • El propio diagnóstico de salud del proceso está en rojo, como con AccessViolationException o StackOverflowException

En resumen, son casos en los que «no se sabe si se puede seguir confiando en el estado de la aplicación después de que ocurrió esta excepción».

2.2 Parece una disyuntiva, pero en realidad hay tres opciones

Lo que complica esta discusión es pensar que «continuar» es una sola cosa.

En la práctica, suele dividirse en tres niveles.

Opción Significado
Continuar dejando fallar solo esa operación Se mantiene la pantalla, pero se trata como fallo únicamente el guardado o la importación de ese momento
Continuar deteniendo solo el subsistema Se reinicializa únicamente la conexión, la pantalla, el worker o el proceso hijo
Terminar el proceso No se puede acotar el alcance del daño al estado, así que se parte de la base de reiniciar

Aunque se hable de «continuar con la aplicación», no pesa lo mismo seguir como si nada hubiera pasado que aislar la parte dañada y continuar.

3. La tabla de decisión que hay que mirar primero

3.1 Panorama general

Empezar por esta tabla ya permite fijar, a grandes rasgos, la orientación a seguir.

Situación Primera opción Motivo
Falla una sola entrada, una sola operación de pantalla o un solo trabajo, y se puede descartar el estado Tender a continuar Porque se puede confinar la unidad de fallo
Tras la excepción se puede descartar y reconstruir el objeto o la conexión implicados Tender a reinicializar el subsistema Porque se puede localizar el alcance del daño
Se actualizó el estado compartido a medias y no se sabe hasta dónde se reflejó Tender a terminar Porque puede que el invariante se haya roto
Los efectos externos (base de datos, archivo, comando a un equipo, etc.) quedaron a medias y no se puede explicar la duplicación o la falta de reflejo Tender a terminar Porque no se puede saber si el mundo exterior sigue coherente
El bucle de monitoreo, el bucle de reconexión o el bucle principal de procesamiento de mensajes se cayó por una excepción inesperada Tender a terminar Porque si solo muere una parte de la funcionalidad en silencio, es fácil que el proceso se zombifique
Falló el proceso de inicio, la carga de configuración, la composición de DI o la inicialización de una dependencia obligatoria Tender a terminar por fallo de inicio Porque arrancar a medias es más peligroso
Hay AccessViolationException, StackOverflowException, un OutOfMemoryException grave o indicios de corrupción por el lado nativo Tender a terminar de inmediato Porque la salud de todo el proceso resulta dudosa
El procesamiento peligroso está aislado en otro proceso y el proceso padre permanece intacto El padre continúa, se reinicia el hijo Porque el área de fallo ya está separada
Árbol de decisión para continuar o terminar ante una excepción inesperadaDiagrama de flujo que evalúa primero si hay indicios de corrupción de memoria, agotamiento de la pila o agotamiento fatal de recursos, orientando a terminar o hacer FailFast; si no los hay, evalúa si se puede descartar la unidad que falló, si se puede revertir o reinicializar el estado compartido, y si se pueden explicar los efectos externos, para decidir entre terminar, detener el subsistema o continuar con solo esa operación como falloNoNoNoNoExcepción inesperada¿Hay indicios de corrupción de memoria, agotamiento de la pila o agotamiento crítico de recursos?Terminar / FailFast / reiniciar¿Se puede descartar la unidad que falló?Tender a terminar¿Se puede revertir o reinicializar el estado compartido?Detener el subsistema o terminar¿Se pueden explicar los efectos externos?Continuar tratando solo esa operación como fallo

El indicio de «corrupción de memoria» de la primera bifurcación del diagrama se trata en detalle en el apartado 3.4, y el FailFast de la parte superior derecha, en el 9.7.

3.2 Qué revisar antes que el tipo de excepción

No conviene decidir de inmediato basándose solo en el tipo de excepción. Antes conviene confirmar estos puntos.

Punto a observar Qué confirmar
Dónde ocurrió Si fue en un evento de la interfaz, en un trabajo individual, en el bucle principal, en el proceso de inicio o en un límite nativo
Hasta dónde avanzó Si a mitad de camino cambiaron el estado de la memoria, la base de datos, un archivo o el estado de un equipo
Alcance posible del daño Si afecta solo a ese objeto, a toda la pantalla o a todo el proceso
Si se puede revertir Si se puede descartar y reconstruir, o revertir mediante una transacción
Efectos externos Si ya se envió o no, si una ejecución doble es segura, si se puede compensar
Monitoreo y reinicio Si existe un reinicio automático o una vía de recuperación después de terminar

3.3 Excepciones de alto riesgo

No hace falta repasar en detalle todos los tipos de excepción, pero hay algunas que no conviene abordar con la idea de continuar.

Excepción / indicio Primera opción Motivo para tenerlo en cuenta
StackOverflowException Tender a terminar de inmediato La pila de llamadas está rota y es difícil suponer una recuperación normal
AccessViolationException Tender a terminar de inmediato Es un acceso indebido a memoria protegida, lo que hace sospechar de un límite nativo o de corrupción de memoria
OutOfMemoryException Tender a terminar El propio procesamiento de recuperación, que necesita reservar más memoria, tiende a volverse inestable
NullReferenceException / InvalidOperationException inesperados Depende del contexto, pero tiende a terminar Es una premisa propia rota, y puede quedar un cambio a medias
Excepción inesperada que se escapó del bucle principal Tender a terminar Existe el riesgo de que el núcleo de la funcionalidad haya muerto mientras el proceso sigue en pie
Anomalía originada en un callback de COM, P/Invoke o un SDK de terceros De terminar de inmediato a fuertemente inclinado a terminar Es difícil evaluar la seguridad mirando solo el lado managed

3.4 En qué nos basamos para hablar de “indicios de corrupción de memoria” y “zombificación”

Estos dos términos pueden parecer expresiones intuitivas, pero en realidad se basan en observaciones concretas.

Primero, los síntomas que hacen sospechar de corrupción de memoria.

Qué se observa Dónde comprobarlo
El proceso se cae con el código de excepción 0xc0000005 (infracción de acceso) o 0xc0000374 (corrupción del heap) Visor de eventos > Registros de Windows > Aplicación, en «Error de la aplicación» (ID de evento 1000)
El lugar donde se cae cambia cada vez. Se cae en código que no se tocó justo antes Registros, pila de llamadas del volcado
Solo se cae al tocar un objeto o un handle que ya se había liberado Pasos para reproducirlo, volcado
Se cae en el procesamiento de liberación del lado nativo (free / delete / la liberación de COM) Pila de llamadas del volcado (por ejemplo, se cae dentro de ntdll.dll)
Un valor que no se tocó aparece modificado. El resultado cambia con la misma entrada Registros de entrada y salida, comparación de resultados al reejecutar

0xc0000005 es STATUS_ACCESS_VIOLATION y 0xc0000374 es STATUS_HEAP_CORRUPTION; ambos son valores definidos en la lista de NTSTATUS de Microsoft. En particular, la corrupción del heap no se manifiesta en el instante en que se produce el daño, sino en el siguiente punto que toca el heap ya dañado, así que el lugar donde se cae no es necesariamente el culpable. Si aparecen síntomas de este tipo, es más seguro asumir que capturar la excepción por el lado managed y continuar no tiene sentido.

A continuación, los síntomas que hacen sospechar de zombificación.

Qué se observa Dónde comprobarlo
El proceso sigue vivo, pero la hora del último procesamiento no se actualiza Registro de la hora del último procesamiento, heartbeat
Solo aumenta la cantidad de elementos acumulados en una cola o en una carpeta de recepción Longitud de la cola, número de archivos sin procesar
Los registros se detienen de golpe a partir de cierto momento Registro de la aplicación
La pantalla se puede operar, pero la actualización interna está detenida Cotejo entre lo mostrado en pantalla y los datos reales
El número de hilos worker es menor de lo esperado Registros de diagnóstico, lista de hilos de Process Explorer

La zombificación no consiste en que el proceso no se haya caído, sino en que sigue vivo sin estar trabajando. Preparar de antemano «indicadores de que el sistema está funcionando» -como la hora del último procesamiento, la cantidad acumulada o el heartbeat- facilita bastante tanto la decisión de continuar o terminar como la investigación posterior.

4. Decidir según dónde ocurre

4.1 Eventos de la interfaz de usuario

Eventos de la interfaz como hacer clic en un botón, cambiar de pantalla, buscar o seleccionar un archivo tienen, relativamente, un margen amplio para poder continuar. No obstante, hay condiciones.

Es más fácil continuar en casos como estos.

  • El fallo ocurrió antes de la carga y todavía no se tocó el estado de negocio
  • Solo se dañó un estado temporal dentro de un diálogo, que se descarta al cerrar la pantalla
  • Después de la excepción se puede reconstruir el ViewModel o la conexión
  • Se le puede decir honestamente al usuario que «esta operación falló»

Por el contrario, en estos casos conviene inclinarse hacia terminar.

  • Se actualizaron a medias tanto la pantalla como el estado de dominio
  • Se tocó un estado compartido que también ven otras pantallas, como algo static, un singleton o una caché
  • Después de la excepción solo quedan restos del estado activo o de selección de los botones, y no se sabe si sigue siendo coherente
  • Ocurrió una excepción inesperada en el hilo de la interfaz y resulta dudoso hasta dónde avanzó el renderizado o las notificaciones

4.2 Trabajos o solicitudes que se procesan uno por uno

Este es un límite en el que resulta relativamente fácil continuar.

  • Un mensaje
  • Un archivo
  • Una solicitud HTTP
  • Un trabajo de importación
  • Un elemento de un lote

Cuando este tipo de unidad es clara, se puede dar por fallido solo ese elemento y seguir con el siguiente.

Ahora bien, hay premisas que deben cumplirse.

  • La unidad de fallo es clara desde fuera
  • Los cambios a medio camino se pueden alinear con una transacción o una compensación
  • El procesamiento tiene la propiedad de que ejecutarlo de nuevo no rompe el resultado
  • El fallo se puede desviar a una cola de aislamiento o a un registro de errores

4.3 Bucles residentes, monitoreo y procesamiento de colas

Este es el lugar donde más problemas causa continuar de forma descuidada.

Por ejemplo:

  • Bucle de reconexión
  • Bucle de monitoreo
  • Bucle de consumo de colas
  • Sondeo periódico (polling)
  • Monitoreo del estado de un equipo
  • Procesamiento residente de una aplicación de bandeja del sistema

Lo peligroso de este tipo de procesamiento es que el bucle principal muere por una sola excepción inesperada y solo el proceso sobrevive.

Aquí conviene separar la política según el nivel.

  • Capturar las excepciones previstas en el límite del procesamiento de cada elemento
  • Si una excepción inesperada se escapa del bucle principal, inclinarse hacia terminar el proceso

4.4 Procesos de inicio

Si un fallo durante el arranque se resuelve con un «por ahora que arranque y ya pensaremos», la aplicación sigue funcionando con parte de su funcionalidad ausente, y después resulta difícil aislar la causa.

  • No se puede leer una configuración obligatoria
  • Falló la migración de versión
  • Falta una carpeta o un certificado obligatorio
  • Falló la inicialización de un servicio central
  • La composición de dependencias está rota

En estos casos, resulta más claro terminar considerándolo un fallo de arranque.

4.5 Límites nativos, COM, P/Invoke y unsafe

Este es un caso aparte que conviene mirar con algo más de rigor.

  • COM
  • P/Invoke
  • Lo que hay detrás de C++/CLI
  • SDK de terceros
  • Código del lado nativo que vuelve mediante un callback
  • Procesamiento que incluye unsafe

En particular, si aparece algo de esto, conviene inclinarse hacia terminar.

  • AccessViolationException
  • Síntomas que hacen sospechar de corrupción del heap o de un doble free
  • Anomalías en handles, indicios de acceso tras liberación (use-after-free)
  • Una muerte repentina en el límite de un callback

5. Condiciones para poder continuar

Las condiciones para poder continuar se resumen así. La premisa es que se cumplan, más o menos todas a la vez.

Condición Significado
La unidad de fallo es clara Se sabe qué se descarta: una operación, una pantalla, un trabajo, una conexión, etc.
El estado se puede descartar Se puede desechar y reconstruir, o tratar como no reflejado
El estado compartido queda protegido La contaminación no se propaga a otras funciones
Se pueden explicar los efectos externos Se sabe si se envió, si no se envió o si se puede reenviar
Se le puede informar honestamente al usuario Se puede mostrar que «esta operación falló»
Se puede monitorear El seguimiento posterior es posible con registros, métricas y volcados

6. Condiciones en las que conviene terminar

Por el contrario, si se cumple alguna de estas condiciones, conviene inclinarse hacia terminar.

  • No se sabe qué se llegó a modificar a medio camino
  • Se tocó un estado compartido y modificable, y no se puede saber si sigue siendo coherente
  • Se rompió la gestión del ciclo de vida de un lock, una cola, un hilo o un bucle de monitoreo
  • No se puede explicar una duplicación, una omisión o un estado a medias en un efecto externo
  • Falló el proceso de inicio o la inicialización de una infraestructura central
  • Se sospecha de un límite nativo o de corrupción de memoria

En este nivel, resulta más eficaz facilitar la recuperación tras terminar que buscar la forma de continuar sin problemas.

7. Recomendaciones según patrones típicos

La tabla de decisión del apartado 3.1 está escrita en forma de condiciones, así que al aplicarla a un caso concreto a veces surgen dudas. Esta tabla vuelve a aplicar esas condiciones a situaciones que se ven con frecuencia. Si hay dudas, lo más rápido es primero confirmar las condiciones en 3.1 y después buscar en esta tabla la fila más parecida.

Patrón Recomendación Motivo
El botón de abrir archivo apunta a una ruta que no existe Continuar dejando fallar solo esa operación El daño al estado es localizado
Una sola línea de la importación de CSV estaba corrupta Continuar con el fallo de esa línea o de ese archivo Es fácil confinar la unidad de fallo
Durante el guardado de una pantalla surgió un NullReferenceException inesperado De recrear la pantalla a tender a terminar Resulta dudoso hasta dónde cambió el ViewModel o el estado de negocio
Un mensaje de la cola violaba una regla de negocio Continuar dejando fallar solo ese mensaje Se puede desviar a una cola de aislamiento
El bucle principal de consumo de la cola se cayó por una excepción inesperada Tender a terminar el proceso El ciclo de vida de todo el worker está roto
Al arrancar no se puede leer una configuración obligatoria Terminar por fallo de arranque Arrancar a medias es más peligroso
AccessViolationException en torno a un callback de un SDK de terceros Tender a terminar de inmediato No se puede descartar la posibilidad de corrupción de memoria
Falló únicamente el envío de telemetría no esencial Deshabilitar solo esa función y continuar Se puede separar la función principal del área de fallo

8. Errores frecuentes

8.1 Usar catch (Exception), registrar y seguir sin más

Esto es bastante peligroso, porque oculta la causa y facilita que un estado dañado se prolongue en el tiempo.

8.2 Intentar recuperarse en el manejador de excepciones no controladas final

AppDomain.UnhandledException, Application.ThreadException o DispatcherUnhandledException son útiles como el último lugar donde registrar lo ocurrido, pero no son un punto mágico de recuperación.

8.3 Reintentar sin cuidado cuando hay efectos externos

Si se reintenta un comando a un equipo, el envío de un correo, un cobro, el movimiento de un archivo o una actualización en la base de datos sin que exista seguridad para volver a ejecutar el mismo procesamiento, lo que pasa a primer plano es un incidente de ejecución doble.

8.4 Dejar la interfaz en pie cuando el bucle de monitoreo ha muerto

Una aplicación que solo está viva en apariencia, sin realizar su trabajo, se ve normal para el usuario, y precisamente por eso su detección se retrasa. Conviene que el lado de monitoreo pueda captar si están apareciendo los síntomas de zombificación descritos en 3.4.

8.5 Decir “no quiero que se cierre” sin haber diseñado para el cierre

Si no se quiere que la aplicación se cierre, hay cosas que deben incorporarse antes de llegar a ese punto.

  • Reinicio automático
  • Restauración de sesión
  • Guardado de resultados parciales
  • Seguridad para reejecutar
  • Aislamiento del área de fallo

9. Puntos a organizar durante la implementación

9.1 Situar el catch en los límites naturales

En lugar de capturar cualquier cosa en una capa profunda,

  • El límite de una operación de la interfaz
  • El límite de una solicitud
  • El límite de un trabajo
  • El límite de una conexión
  • El límite del proceso

resulta más fácil organizar las cosas si se captura en un lugar donde se pueda definir la unidad de fallo, como los anteriores.

Por ejemplo, en una importación que procesa un elemento a la vez, el catch se coloca dentro del bucle. Esa es la unidad de fallo.

// C# / .NET 8. Bucle que procesa un elemento a la vez, donde la unidad de fallo queda confinada dentro del propio bucle.
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>
    /// Un fallo de un solo elemento (error de validación, duplicado, formato incorrecto, etc.)
    /// se lanza como ImportItemException. Cualquier otro caso no es "el problema de este elemento",
    /// así que debe dejarse pasar tal cual hacia fuera.
    /// </summary>
    Task SaveAsync(ImportItem item, CancellationToken cancellationToken);
}

/// <summary>Fallo de un solo elemento que se puede descartar y seguir con el siguiente.</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)
            {
                // El estado de un solo elemento se puede descartar, así que se registra y se pasa al siguiente.
                //
                // No convertir esto en catch (Exception). Si se tragan hasta un
                // NullReferenceException o un OutOfMemoryException como "simplemente datos
                // incorrectos", la excepción inesperada que en 4.3 decidimos "detener todo el
                // host" nunca llega al bucle principal. Se seguiría escribiendo el resto de los
                // elementos incluso después de que el estado dejara de ser confiable
                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);
    }
}

En cambio, la decisión complementaria es no tragarse la excepción en el bucle principal que ejecuta este procesamiento. Como se explicó en 4.3, la peor forma posible es que el bucle principal muera y solo el proceso quede en pie, así que la excepción inesperada debe llevar a detener el host.

// Lado del bucle residente. La solicitud de detención se trata como una salida normal;
// cualquier otra excepción inesperada detiene todo el 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)
        {
            // Una solicitud de detención es el caso normal. Aquí se termina en silencio.
        }
        catch (Exception ex)
        {
            // El bucle principal se rompió = el ciclo de vida de todo el worker está roto.
            // En lugar de tragarse el error y dejar "solo el proceso con vida", se detiene y se
            // pasa el control al reinicio.
            logger.LogCritical(ex, "Worker loop failed unexpectedly. Stopping the host.");

            // StopApplication es una solicitud para "plegarse de forma normal". Con esto solo,
            // el lado de monitoreo que reinicia únicamente ante un fallo (las operaciones de
            // recuperación del servicio, el Restart=on-failure de systemd, la política de
            // reinicio de un contenedor) no puede distinguirlo de "el trabajo terminó y el
            // proceso finalizó normalmente", y el proceso nunca vuelve a levantarse.
            //
            // Tampoco basta con establecer Environment.ExitCode. Si se ejecuta como un
            // servicio de Windows, cuando el host se detiene normalmente se reporta
            // SERVICE_STOPPED al SCM, y el ExitCode no se refleja en el estado de terminación
            // del servicio, por lo que la operación de recuperación no se activa. Se termina
            // el proceso con un código de salida distinto de cero
            Environment.Exit(1);
        }
    }
}

El punto clave es el uso de Environment.Exit(1). IHostApplicationLifetime.StopApplication() es una solicitud de detención normal, así que si la aplicación se ejecuta como un servicio de Windows, al SCM se le reporta SERVICE_STOPPED. Como el Environment.ExitCode del proceso no se refleja en el estado de terminación del servicio, la operación de recuperación configurada en las propiedades del servicio (“reiniciar el servicio”) no se activa. El propio tutorial oficial del servicio worker deja por escrito que el comportamiento predeterminado BackgroundServiceExceptionBehavior.StopHost “detiene todo de forma limpia”, por lo que la administración de servicios de Windows no lo reinicia, y que para que la operación de recuperación surta efecto hace falta llamar a Environment.Exit con un código de salida distinto de cero (véanse las referencias del apartado 11).

Environment.Exit termina el proceso actual, así que cualquier registro que se quiera dejar antes de la caída debe escribirse por completo antes de esta línea. Si se usa un logger con buffer, hay que intercalar un flush. En cambio, si el destino no es un servicio de Windows sino solo systemd o un contenedor, también basta con establecer Environment.ExitCode y luego plegarse con StopApplication(), porque el lado de monitoreo lo trata como un fallo igualmente. Elija según el entorno en el que se ejecute.

Conviene tener presente además que el papel del catch cambia entre el interior y el exterior del bucle. El interior registra la unidad de fallo; el exterior termina el ciclo de vida. Si se confunden estos dos papeles, la aplicación puede caerse por el fallo de un solo elemento, o, a la inversa, el proceso puede sobrevivir aunque el worker haya muerto.

9.2 Separar las excepciones previstas de las imprevistas

  • Previstas: validación, no encontrado, tiempo de espera agotado, cancelación, violación de una regla de negocio
  • Inesperadas: ruptura de una premisa, fuga desde el bucle principal, anomalía en un límite nativo, indicios de corrupción de memoria

9.3 Reducir el estado compartido

Cuanto mayor es el estado compartido y modificable, más difícil resulta decidir si continuar. A la inversa, cuanto más se pueda confinar dentro de una sola pantalla, una sola sesión o un solo worker, más fácil será también confinar el fallo.

9.4 Aislar el procesamiento peligroso en otro proceso

Para COM, ActiveX, un SDK de terceros, unsafe, procesamiento pesado de imágenes, control de equipos externos y similares, cuando no se quiere que el daño se extienda si algo se cae, aislarlo en otro proceso resulta bastante eficaz.

9.5 El manejador de excepciones no controladas es para registrar, no para recuperar

  • Información de la excepción
  • Contexto de la operación
  • Registros importantes justo anteriores
  • Configuración, versión, destino de conexión
  • Vía para capturar un volcado

Reunir estos elementos y priorizar una forma que permita investigar a fondo después de la caída resulta, al final, más estable.

En WPF, un manejador dedicado a registrar basta con una forma como esta.

// App.xaml.cs de WPF (.NET 8). El manejador se usa para "registrar", no para "recuperar".
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)
    {
        // El registro de los manejadores se coloca antes de base.OnStartup(e).
        // base.OnStartup dispara el evento Startup, así que si el suscriptor de ese
        // evento lanza una excepción, los manejadores escritos después todavía no
        // estarían registrados, lo que produce el peor de los casos: "se cayó al
        // arrancar y no quedó ni una línea de registro". Como precisamente el fallo
        // del propio proceso de arranque es lo que se quiere registrar, se colocan antes.

        // Excepción que llega sin controlar en el hilo de la interfaz
        DispatcherUnhandledException += (_, args) =>
        {
            Record("DispatcherUnhandledException", args.Exception);

            // Con args.Handled = true se puede continuar, pero si conviene continuar se
            // decide con las condiciones del capítulo 5. Si no se puede decidir, se
            // registra y se deja el comportamiento predeterminado (terminar) a cargo.
            args.Handled = false;
        };

        // Última notificación, que incluye también lo que ocurre fuera del hilo de la
        // interfaz. Aquí no se puede detener nada, así que es solo para registrar.
        AppDomain.CurrentDomain.UnhandledException += (_, args) =>
            Record("AppDomain.UnhandledException", args.ExceptionObject as Exception);

        // Excepción de una Task recogida sin haber sido esperada (await)
        TaskScheduler.UnobservedTaskException += (_, args) =>
        {
            Record("UnobservedTaskException", args.Exception);
            args.SetObserved();
        };

        // Una vez registrado todo esto, se pasa al proceso de arranque predeterminado
        // (el disparo del evento Startup)
        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
        {
            // Aunque falle el registro, no se detiene el procesamiento de cierre.
        }
    }
}

El punto clave es no intentar arreglar el estado dentro del manejador. Lo único que se hace aquí es dejar el material necesario para poder rastrear el mismo fenómeno la próxima vez.

9.6 No confiar demasiado en los eventos de excepción no controlada de WPF / WinForms

En WPF, poner Handled = true en DispatcherUnhandledException permite, en sí mismo, seguir funcionando después de una excepción no controlada. En Windows Forms, en el hilo principal de la interfaz también se puede elegir la forma de detenerse según la configuración de Application.ThreadException o de SetUnhandledExceptionMode.

Pero que se pueda continuar de esa forma y que estén dadas las condiciones de recuperación son cuestiones distintas.

9.7 Environment.FailFast solo cuando “limpiar” es más peligroso

El FailFast que aparece en el diagrama de flujo de 3.1 se refiere a Environment.FailFast. El comportamiento descrito en la documentación oficial es el siguiente.

  • Termina el proceso sin ejecutar ni el try / finally en curso ni los finalizadores
  • En Windows, escribe el mensaje recibido en el registro de eventos de aplicación de Windows, crea un volcado de la aplicación y luego termina
  • El mensaje y la información de la excepción también se incluyen en el reporte de errores a Microsoft a través de Windows Error Reporting
  • Si se llama bajo el depurador de Visual Studio, se convierte en ExecutionEngineException y se dispara el managed debugging assistant fatalExecutionEngineError

No ejecutar finally no es un defecto, sino el propósito de esta API. Si se ejecuta código de limpieza cuando el estado ya está dañado, es posible que ese contenido dañado termine escribiéndose tal cual en un archivo o en la base de datos. La propia documentación explica que, cuando el estado de la aplicación está dañado de forma irreparable y ejecutar el try / finally o los finalizadores acabaría dañando los recursos, se debe usar FailFast en lugar de Environment.Exit.

La elección entre uno y otro queda así.

Situación Qué elegir
El invariante está roto y ejecutar la limpieza es más peligroso Environment.FailFast
El estado es sano y se quiere terminar después de limpiar Un proceso de terminación normal (por ejemplo, IHostApplicationLifetime.StopApplication)
Solo se quiere terminar devolviendo un código de salida Environment.Exit, o un return desde Main

Traducido a código, se usa en un lugar como este.

// C# / .NET 8. Punto donde se detecta que el invariante del estado compartido se rompió.
// A partir de aquí, ya no se puede confiar en ningún procesamiento de limpieza.
if (cache.Count != store.Count)
{
    Environment.FailFast(
        $"Invariant broken: cache={cache.Count} store={store.Count}",
        new InvalidOperationException("Cache and store are out of sync."));
}

Como el volcado se captura automáticamente, incluir en el mensaje que se pasa a FailFast qué invariante se rompió y con qué valores facilita bastante la investigación posterior. En cambio, llamar a FailFast ante un fallo previsto, como un error de entrada o un error de comunicación, es excesivo. Ahí se vuelve a la idea de la unidad de fallo tratada en 9.1.

10. Resumen

Lo que hay que observar cuando ocurre una excepción inesperada no es «¿se puede capturar esta excepción?», sino si se puede seguir confiando en el estado de la aplicación después de esto.

Como orden de decisión, en general basta con esto.

  1. ¿Se puede descartar la unidad que falló?
  2. ¿Se puede revertir o reconstruir el estado compartido?
  3. ¿Se pueden explicar los efectos externos?
  4. ¿Se puede confiar en la salud de la memoria, los hilos y los límites nativos?

Si hay confianza en estos cuatro puntos, se puede continuar. Si no la hay, conviene inclinarse hacia terminar.

En particular, en aplicaciones de larga duración, aplicaciones de monitoreo, servicios e integraciones con equipos, hay bastantes situaciones en las que seguir viva y dañada resulta más peligroso que caer sin más.

El manejo de excepciones no es «la técnica para no caerse». Es un diseño que reduce el alcance del daño, que se detiene honestamente cuando algo se rompe, y que facilita la recuperación.

11. Referencias

Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.

Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.

El artículo está directamente relacionado con los siguientes servicios.

Investigación de fallos y causas

El proceso de determinar, tras una excepción inesperada, si conviene continuar o terminar -incluyendo el daño al estado y los efectos externos- se presta bien a un trabajo de investigación de fallos y análisis de causas.

Preguntas frecuentes

Preguntas habituales en las consultas sobre el tema del artículo.

¿No se debe capturar una excepción inesperada con catch (Exception) y simplemente continuar?
Registrar el error y seguir adelante suele ser peligroso, porque oculta la causa y facilita que un estado dañado se prolongue en el tiempo. Solo conviene continuar cuando se cumplen tres condiciones a la vez: se puede descartar la unidad que falló, se puede revertir el estado compartido y se pueden explicar los efectos externos. El eje de la decisión no es «¿se puede capturar esta excepción?», sino «¿se puede seguir confiando en el estado de la aplicación después de esto?».
¿Ante qué excepciones conviene terminar de inmediato?
Ante excepciones que ponen en duda la salud de todo el proceso -como StackOverflowException, AccessViolationException o un OutOfMemoryException grave- es más seguro no partir de la idea de continuar. En StackOverflowException la pila de llamadas está rota, y en AccessViolationException se sospecha corrupción de memoria por un acceso indebido a memoria protegida. Las anomalías que se originan en un callback de COM, P/Invoke o un SDK de terceros también inclinan con fuerza hacia terminar, porque desde el lado managed es difícil evaluar si la situación sigue siendo segura.
¿Bajo qué condiciones puede la aplicación continuar aunque se produzca una excepción?
El requisito general es que se cumplan, más o menos todas a la vez, estas condiciones: la unidad de fallo es clara (se sabe qué se descarta: una operación, una pantalla, un trabajo, una conexión, etc.), el estado se puede descartar y reconstruir, la contaminación no se propaga al estado compartido, se pueden explicar los efectos externos, se le puede decir honestamente al usuario que «esta operación falló» y el seguimiento posterior es posible con registros y métricas. Cuando el límite del procesamiento es claro -como una operación de la interfaz o un trabajo de importación de un solo elemento- a veces se puede continuar. En cambio, una actualización a medias del estado compartido, el bucle principal, el proceso de inicio o una anomalía en el límite nativo inclinan hacia terminar.
¿Se puede continuar poniendo Handled = true en DispatcherUnhandledException de WPF?
Es posible seguir funcionando después de una excepción no controlada, pero poder continuar y poder continuar con seguridad son cosas distintas. Manejadores como AppDomain.UnhandledException o DispatcherUnhandledException son útiles como el último lugar donde registrar lo ocurrido, pero no son un punto mágico de recuperación. En la práctica resulta más estable priorizar reunir la información de la excepción, el contexto de la operación y una vía para capturar volcados, de modo que se pueda investigar después de la caída. En particular, en servicios de larga duración y aplicaciones de monitoreo, sobrevivir a medio romper suele ser menos seguro y más difícil de diagnosticar que caer y reiniciarse.

Perfil del autor

Página de presentación del autor del artículo.

Go Komura

Representante de KomuraSoft LLC

Especializado en desarrollo de software para Windows, consultoría técnica e investigación de fallos, sobre todo en proyectos con sistemas existentes y errores difíciles de reproducir.

Volver al blog