Tabla de decisión: terminar o continuar ante una excepción inesperada
· Actualizado el: · Go Komura · 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,AccessViolationExceptionuOutOfMemoryException, 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ó
NullReferenceExceptionoInvalidOperationException - 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
AccessViolationExceptionoStackOverflowException
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 |
flowchart TD
accTitle: Árbol de decisión para continuar o terminar ante una excepción inesperada
accDescr: Diagrama 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 fallo
A["Excepción inesperada"] --> B{"¿Hay indicios de corrupción de memoria, agotamiento de la pila o agotamiento crítico de recursos?"}
B -- "Sí" --> Z["Terminar / FailFast / reiniciar"]
B -- "No" --> C{"¿Se puede descartar la unidad que falló?"}
C -- "No" --> Y["Tender a terminar"]
C -- "Sí" --> D{"¿Se puede revertir o reinicializar el estado compartido?"}
D -- "No" --> X["Detener el subsistema o terminar"]
D -- "Sí" --> E{"¿Se pueden explicar los efectos externos?"}
E -- "No" --> X
E -- "Sí" --> W["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/finallyen 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
ExecutionEngineExceptiony 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.
- ¿Se puede descartar la unidad que falló?
- ¿Se puede revertir o reconstruir el estado compartido?
- ¿Se pueden explicar los efectos externos?
- ¿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
- .NET: Best practices for exceptions
- .NET: Create a Windows Service using BackgroundService(el comportamiento predeterminado
BackgroundServiceExceptionBehavior.StopHostdetiene el host de forma limpia, por lo que la administración de servicios de Windows no lo reinicia; para que la operación de recuperación surta efecto es necesario llamar aEnvironment.Exitcon un código de salida distinto de cero) - .NET: System.Exception
- .NET: StackOverflowException
- .NET: System.AccessViolationException
- .NET: Environment.FailFast
- .NET: AppDomain.UnhandledException
- WPF: Application.DispatcherUnhandledException
- Windows Forms: Application.SetUnhandledExceptionMode
- .NET: Exceptions in Managed Threads
- .NET: TaskScheduler.UnobservedTaskException
- Windows: NTSTATUS Values - MS-ERREF
Artículos relacionados
Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.
¿Dónde deben ir el catch y el log en el manejo de excepciones?
Organiza los límites para capturar excepciones, el log principal y quién decide la recuperación, evitando el catch amplio, los logs dupli...
Diseño de códigos en sistemas empresariales ── Cómo definir códigos de producto y cliente, y el dígito de control
Guía práctica para diseñar códigos de producto y cliente en sistemas empresariales: código significativo frente a secuencial, fórmulas de...
Requisitos mínimos de un logger propio y checklist de pruebas de integración
Para que el log de diagnóstico de una aplicación propia sea confiable, organizamos UTF-8 JSON Lines, los campos obligatorios, flush, rota...
Cómo trazar el límite entre pruebas unitarias y pruebas de integración
Analizamos el límite entre pruebas unitarias y de integración: lógica pura, formato, cableado, entorno y tiempo, en una tabla de decisión...
Diseño para conservar registros y volcados de memoria cuando falla una aplicación de Windows
Explica cómo combinar el registro habitual, el marcador final de fallo, WER LocalDumps y un proceso de vigilancia para no perder pistas d...
Temas relacionados
Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.
Temas técnicos de Windows
Portal sobre desarrollo de Windows, investigación de fallos y aprovechamiento de activos existentes.
Servicios relacionados con este tema
El artículo está directamente relacionado con los siguientes servicios.
Consultoría técnica y revisión de diseño
Es un tema que encaja bien con la consultoría técnica y la revisión de diseño, ya que se trata de definir la política de manejo de excepciones, los límites de fallo, la estrategia de reinicio y los criterios para decidir si se puede continuar.
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.