Investigación de un crash en una cámara industrial tras un largo funcionamiento - Edición fuga de handles
· Actualizado el: · Go Komura · Desarrollo en Windows, Investigación de fallos, Cámara industrial, Fuga de handles, Diseño de logs
Cuando una aplicación Windows se cae de repente tras un funcionamiento prolongado, es muy habitual sospechar primero de una fuga de memoria. Sin embargo, en la práctica no son pocos los casos en los que el verdadero culpable es una fuga de handles, que solo termina saliendo a la superficie como un daño secundario varias semanas después.
En este artículo presento un caso en el que investigué un evento en el que una aplicación Windows que controla una cámara industrial se caía de repente tras aproximadamente un mes de funcionamiento continuo. Al avanzar en la identificación, la causa resultó ser una fuga de handles que se producía en el camino de fallo alrededor de la reconexión de la cámara.
En esta primera parte explico qué es una fuga de handles, cómo se identificó este problema y qué logs conviene dejar registrados para evitar que vuelva a ocurrir. En la segunda parte hablaré sobre una base de pruebas de casos anómalos, en el artículo Cómo construir una base de pruebas de casos anómalos en Windows con Application Verifier.
He omitido nombres propios y algunos campos de log, pero el enfoque en sí es bastante común a las aplicaciones de control de equipos en Windows en general.
Índice
- Primero, la conclusión (en pocas palabras)
- Qué es una fuga de handles
- 2.1. Qué es un “handle” en este contexto
- 2.2. Por qué tiende a aparecer solo tras un funcionamiento prolongado
- 2.3. Diferencia con una fuga de memoria
- Caso: una aplicación de control de cámara industrial se cae de repente al cabo de un mes
- 3.1. Los síntomas observados
- 3.2. Los primeros indicadores observados
- 3.3. El punto de fuga que era la causa real
- Cómo se identificó el problema
- 4.1. Comprimir el tiempo sin esperar una reproducción de varios meses
- 4.2. Observar la pendiente del
Handle Count - 4.3. Observar la correspondencia entre
create/openyclose/dispose - 4.4. En una fuga de handles, hay que buscar “dónde se produjo la fuga”, no “dónde se cayó”
- Los logs necesarios para evitar que se repita
- 5.1. El conjunto mínimo que hay que dejar registrado primero
- 5.2. Los logs que se reforzaron en la práctica
- 5.3. Con qué nivel de granularidad registrar
- Guía rápida de cuándo aplicar cada cosa
- Resumen
- Referencias
1. Primero, la conclusión (en pocas palabras)
- En una aplicación de control que solo se cae tras un funcionamiento prolongado, hay que observar siempre no solo
Private Bytes, sino tambiénHandle Count - Una fuga de handles tiende a esconderse no en el camino normal, sino en los caminos de
timeout/reconnect/ fallo a mitad de proceso / early return - La línea donde realmente se produce el crash suele ser el lugar donde ya no se puede crear un nuevo handle, no el lugar donde se produjo la fuga
- Lo primero que se necesita registrar es el contexto de
operation/session, elhandle countdel proceso, la correspondenciaopen/closede cada recurso, y los errores de Win32 / HRESULT / SDK - En lugar de esperar una reproducción que tarda meses, es más rápido repetir miles de veces, en un bucle corto, la conexión, la desconexión, la reconexión y el camino de fallo
- Application Verifier, del que hablo en la segunda parte, es bastante eficaz, pero antes de eso la base consiste en dejar preparados los propios logs para poder rastrear las roturas en el lifetime
En resumen, en este tipo de casos lo primero que hay que hacer no es quedarse mirando el hecho de que “se cayó después de mucho tiempo”, sino dejar preparada la forma de observar cómo crecen los recursos y por dónde pasan los caminos de fallo.
Una fuga de handles, cuando se descubre, ya suele tener la cara de un daño secundario. Por eso, si solo se mira la excepción del instante en que se cae, es fácil terminar caminando en una dirección bastante equivocada.
2. Qué es una fuga de handles
2.1. Qué es un “handle” en este contexto
El handle del que hablo aquí es el identificador que usa un proceso de Windows para referenciar un recurso del sistema operativo. Entre los recursos afectados están, por ejemplo, los siguientes.
| Categoría | Ejemplos |
|---|---|
| Objetos del kernel | event, mutex, semaphore, thread, process, waitable timer |
| Sistemas de E/S | open sobre file, pipe, socket, device |
| Frecuentes en control de equipos | event interno del SDK de la cámara, objetos de espera ligados al registro de callbacks, handles relacionados con el thread de captura de imágenes |
En las aplicaciones de control, el patrón que suele causar más problemas es el de “olvidar cerrar, en el camino de fallo a mitad de proceso, un recurso que se abrió temporalmente para una operación concreta”.
El flujo típico es el siguiente.
- Cada vez que se reconecta, se crea un event
- El registro del callback o el inicio de la captura falla a mitad de camino
- En el success path se cierra, pero en el failure path no se cierra
- Como las pruebas cortas habituales solo recorren el camino de éxito, se pasa por alto
Este tipo de fuga se esconde con bastante normalidad, tanto en las revisiones de código como en producción.
2.2. Por qué tiende a aparecer solo tras un funcionamiento prolongado
Una fuga de handles no siempre rompe algo de forma espectacular de una sola vez. Lo verdaderamente molesto es más bien una fuga con una pendiente pequeña, del tipo en que cada fallo solo pierde un handle.
flowchart LR
accTitle: Cómo un pequeño goteo de handles termina en un crash
accDescr: Diagrama de flujo que muestra cómo, tras un timeout o reconnect ocasional, se crea un Event Handle en el camino de fallo sin llamar a CloseHandle, lo que hace crecer poco a poco el Handle Count hasta que, tras cientos de repeticiones, falla CreateEvent o la apertura del SDK y se produce un crash o detención en otro lugar.
A[Funcionamiento normal] --> B[A veces timeout / reconnect]
B --> C[Se crea un Event Handle en el camino de fallo]
C --> D[No se llama a CloseHandle]
D --> E[El Handle Count aumenta un poco]
E --> F[Se repite cientos de veces]
F --> G[Falla CreateEvent / la apertura del SDK]
G --> H[Crash / detención en otro lugar]
1 réplica de reconnect que solo pierde un handle no provoca nada en pocos minutos. Pero en una aplicación de control de equipos que funciona 24/7, condiciones límite como el timeout, la reinicialización o la recuperación tras una desconexión se repiten muchas veces. Como resultado, el problema adopta ese aspecto extraño de aparecer solo varias semanas después.
Aquí lo importante es que la propia fuga de handles no siempre es la línea donde ocurre el crash. Lo más habitual es que se rompa de alguna de estas formas.
- Falla la API que crea un nuevo event / file / thread
- El SDK no puede crear internamente el recurso que necesita y solo devuelve un código de fallo genérico
- El manejo de errores tras el fallo es superficial y se termina pisando un
nullo un invalid handle, provocando el crash - Aumentan los timeouts y, como resultado, un watchdog o un control de nivel superior termina matando el proceso
Es decir, el punto del crash es “la última víctima”, y no necesariamente “el primer culpable”.
Aquí surge una duda razonable: ¿por qué se cae con apenas unos pocos miles de handles?
Si se mira solo el número, el límite está bastante lejos. El límite teórico de handles de objetos del kernel por proceso es 2^24 (unos 16,77 millones). Sin embargo, como los handles se colocan en el pool de páginas, la cantidad que realmente se puede crear depende de la memoria disponible, y en Windows de 32 bits queda muy por debajo del valor teórico.
En resumen, los casos en los que se llega al límite teórico y se cae son más bien minoritarios. Lo que suele afectar primero es, casi siempre, alguna de las siguientes cosas.
| Qué se topa primero con el límite | Referencia | Cuándo afecta |
|---|---|---|
| Objetos GDI | En teoría, 65.536 por sesión. Además hay un límite predeterminado por proceso, ajustable entre 256 y 65.536 mediante el valor de registro GDIProcessHandleQuota |
Aplicaciones que también tienen una GUI. Es normal toparse con el límite en el orden de unos pocos miles |
| Tablas internas de gestión del SDK | Depende del proveedor | Las tablas de handles o los arreglos de tamaño fijo que el SDK de la cámara mantiene internamente se llenan antes |
| Recursos del kernel como el pool de páginas | Compartidos por toda la máquina | Cuando también se están consumiendo recursos distintos de los handles |
| Espacio de direcciones virtual de un proceso de 32 bits | 2 GB / 3 GB | Lo que afecta no es el handle en sí, sino los búferes que se reservan asociados a él |
Es decir, no vale la lectura de “todavía queda margen hasta el límite, así que no hay problema”. Hay que observar no si se llega al límite, sino si algo que debería volver a su valor no está volviendo. Es más seguro considerar que, en cuanto aparece una pendiente, ya hay una anomalía.
2.3. Diferencia con una fuga de memoria
Ante un fallo tras un funcionamiento prolongado, lo primero es sospechar de una fuga de memoria. Por supuesto, eso en sí es natural, pero a veces es más rápido observar la fuga de handles desde un eje distinto.
| Aspecto | Fuga de memoria | Fuga de handles |
|---|---|---|
| Indicador que se mira primero | Private Bytes, Commit, Working Set |
Handle Count |
| Síntoma típico | Presión de memoria, paging, ralentización, OOM | Fallo de Create* / Open* / inicialización interna del SDK, daño secundario |
| Dónde suele esconderse | Cachés, referencias retenidas, olvido de liberar | Asimetría entre create/open y close/dispose |
| Cómo se ve | La memoria aumenta poco a poco | El handle count aumenta poco a poco y no vuelve a bajar |
Por eso, al analizar un funcionamiento prolongado, si solo se mira la memoria es fácil acabar conduciendo con un solo ojo.
Como mínimo, observar juntos Handle Count y Thread Count facilita mucho más ordenar las ideas.
3. Caso: una aplicación de control de cámara industrial se cae de repente al cabo de un mes
3.1. Los síntomas observados
El evento era sencillo.
- Una aplicación Windows que controla una cámara industrial funciona 24/7
- En condiciones normales funciona con normalidad
- Al cabo de aproximadamente un mes, un día la aplicación se cae de repente
- Al reiniciarla, vuelve a funcionar durante un tiempo
Lo primero que complica las cosas es que “tarda mucho en caerse”. Esperar un mes por cada reproducción es bastante duro como investigación.
Lo que complicaba aún más las cosas era que el lugar donde se caía no era exactamente el mismo cada vez. A veces era justo después de iniciar la reconexión, otras veces al empezar la captura de imágenes, y otras después de que fallara una llamada al SDK.
Con este aspecto, al principio se puede sospechar de cualquiera de las siguientes causas.
- Inestabilidad del lado del SDK de la cámara
- Un fallo temporal originado por la comunicación o la desconexión del dispositivo
- Una fuga de memoria
- Una condición de carrera relacionada con los threads
- Un fallo de inicialización que no aparece en los logs
Es decir, había demasiadas cosas “vagamente sospechosas”.
3.2. Los primeros indicadores observados
Así que lo primero que hice fue observar cómo crecían los recursos del proceso en su conjunto. En este caso, la tendencia observada fue aproximadamente la siguiente.
| Indicador | Tendencia observada | Interpretación |
|---|---|---|
Handle Count |
Aumenta poco a poco tras un reconnect o un timeout y no vuelve a bajar | Se sospecha de una fuga de handles |
Private Bytes |
Hay subidas y bajadas, pero la pendiente de aumento monótono es débil | El culpable principal no tiene por qué ser el heap |
Thread Count |
Prácticamente estable | Es poco probable que haya una fuga de threads |
| Lugar donde se cae | Ligeramente distinto cada vez | Es probable que sea un daño secundario |
En este punto, el foco quedó bastante acotado. Porque resultaba más natural verlo no como “se cae al cabo de un mes”, sino como “algo se va perdiendo poco a poco por el camino, y como resultado se cae al cabo de un mes”.
3.3. El punto de fuga que era la causa real
La causa final fue el olvido de cerrar (close) el event handle creado en el camino de fallo de la inicialización, durante la reconexión de la cámara.
Simplificando el flujo, queda así.
sequenceDiagram
accTitle: Secuencia de la fuga de handles durante la reconexión de la cámara
accDescr: Diagrama de secuencia que muestra cómo, tras crear un CreateEvent y fallar el registro del callback o la SDK, se hace return en el failure path sin llamar a CloseHandle, cómo el Handle Count aumenta poco a poco con cada reconnect, y cómo un CreateEvent u Open posterior termina fallando y provocando un crash como daño secundario.
participant App as Aplicación de control
participant OS as Windows
participant SDK as SDK de la cámara
App->>OS: CreateEvent
App->>SDK: Registro de callback
SDK-->>App: Fallo parcial / timeout
Note over App: Return en el failure path
Note over App: No se llama a CloseHandle
loop Reconnect repetido muchas veces
App->>OS: El Handle Count aumenta poco a poco
end
App->>OS: Siguiente CreateEvent / Open
OS-->>App: Falla
App-->>App: Crash como daño secundario
Como imagen del código, la fuga sería así.
handle = CreateEvent(...)
if (!RegisterCallback(handle))
{
return Error; // Falta CloseHandle(handle)
}
if (!StartAcquisition())
{
return Error; // Aquí también falta el close
}
...
CloseHandle(handle)
La razón por la que es fácil pasarlo por alto en pruebas cortas también es bastante clara.
- En un inicio normal -> finalización normal, se cierra
- El fallo solo ocurre a mitad de un reconnect
- No existe ninguna prueba que recorra ese failure path de forma masiva
- En producción, se va acumulando poco a poco a lo largo de varias semanas
Es decir, la estructura era: “si solo se mira el camino normal no se ve, pero en el camino anómalo la fuga es de lo más normal”.
El enfoque de la corrección no es nada espectacular.
- Acercar las responsabilidades de
create/openyclose/dispose - Desplazar la liberación hacia
finally/ el destructor / el lado del objeto de sesión, de modo que se libere siempre, incluso ante un fallo a mitad de camino - Aclarar la propiedad (ownership) antes y después del registro del callback o del inicio de la captura
- Expresar “quién cierra” no mediante comentarios, sino mediante la responsabilidad del propio código
Como con solo el texto es difícil de entender, dejo también una reescritura del mismo procesamiento.
En C++, se prepara un pequeño tipo RAII que posee el handle, de modo que no quede ningún HANDLE en bruto suelto dentro de la función.
// C++17 / Windows
#include <windows.h>
#include <utility>
class UniqueHandle
{
public:
UniqueHandle() noexcept = default;
explicit UniqueHandle(HANDLE h) noexcept : h_(h) {}
UniqueHandle(const UniqueHandle&) = delete;
UniqueHandle& operator=(const UniqueHandle&) = delete;
UniqueHandle(UniqueHandle&& other) noexcept
: h_(std::exchange(other.h_, nullptr)) {}
UniqueHandle& operator=(UniqueHandle&& other) noexcept
{
if (this != &other)
{
reset(std::exchange(other.h_, nullptr));
}
return *this;
}
~UniqueHandle() { reset(); }
HANDLE get() const noexcept { return h_; }
explicit operator bool() const noexcept { return h_ != nullptr; }
void reset(HANDLE h = nullptr) noexcept
{
if (h_ != nullptr)
{
::CloseHandle(h_);
}
h_ = h;
}
private:
HANDLE h_ = nullptr;
};
Al usar esto, ya no hace falta añadir CloseHandle en el camino de fallo.
// Miembro de CameraSession: UniqueHandle frameReady_;
bool CameraSession::Reconnect()
{
UniqueHandle frameReady{ ::CreateEventW(nullptr, TRUE, FALSE, nullptr) };
if (!frameReady)
{
return false; // La creación en sí falló. No hay nada que cerrar
}
if (!RegisterCallback(frameReady.get()))
{
return false; // Aunque se haga return aquí, el destructor se encarga de cerrarlo
}
if (!StartAcquisition())
{
// Si falla después de completar el registro, hay que anular el
// registro antes de cerrar. Si se sale sin anularlo, el destructor
// llama a CloseHandle, pero el SDK sigue reteniendo el handle
// entregado. En el siguiente frame señalizaría un número ya liberado,
// y si ese número se reutilizó, aparece como un evento ajeno que se activa solo
UnregisterCallback();
return false;
}
// Solo cuando tiene éxito, se transfiere la propiedad al lado de la sesión
frameReady_ = std::move(frameReady);
return true;
}
En C#, como en muchos casos no basta con un simple using, se usa una bandera para saber si se ha podido transferir la propiedad, y solo cuando no se ha podido transferir, se descarta el recurso en finally. Esto es porque, si simplemente se escribe using var, el recurso se acabaría liberando incluso cuando la operación tiene éxito.
// C# / .NET 8
// Campo de CameraSession: private ManualResetEvent? _frameReady;
public bool Reconnect()
{
var frameReady = new ManualResetEvent(false);
var handedOver = false;
var registered = false;
try
{
if (!RegisterCallback(frameReady))
{
return false;
}
registered = true;
if (!StartAcquisition())
{
return false;
}
_frameReady?.Dispose();
_frameReady = frameReady;
handedOver = true;
return true;
}
finally
{
if (!handedOver)
{
// Antes de descartarlo, hay que soltar primero la referencia que
// retiene la parte externa. El SDK conserva el handle entregado
// en el registro, así que invertir el orden hace que se golpee un handle ya liberado
if (registered)
{
UnregisterCallback();
}
frameReady.Dispose();
}
}
}
En ambos casos se hace lo mismo. Se construye una estructura en la que, se salga por donde se salga a mitad de camino, un recurso cuyo propietario no ha quedado decidido siempre acaba descartándose. Es decir, en lugar de que una persona escriba cada vez “si falla, cerrar”, se delega esa responsabilidad al tipo y al finally.
Más que una técnica especial, esto es una forma de organizar el código de manera que incruste en él el ciclo de vida de los recursos.
4. Cómo se identificó el problema
A partir de este capítulo aparecen tal cual algunos términos en inglés propios de la investigación. Antes dejo una breve nota de traducción.
| Término | Equivalente | Significado en este artículo |
|---|---|---|
| baseline | Valor de referencia | El valor en el momento en que termina el calentamiento y las cosas se estabilizan. Se observa la diferencia respecto a este valor |
| leakSlope | Pendiente de la fuga | Cuánto aumenta por cada ciclo. Es un indicador propio que representa la velocidad del aumento |
| structured log | Log estructurado | Un log que, en lugar de frases, expresa campos definidos al estilo key=value. Permite agregarlo mecánicamente más tarde |
| heartbeat | Reporte periódico | Un log que, a intervalos regulares, sigue emitiendo la confirmación de que el proceso sigue vivo junto con los valores de los recursos |
| harness | Arnés de pruebas | Un pequeño programa ejecutable que, en lugar de la aplicación principal, repite únicamente el procesamiento que se quiere probar |
| phase | Fase | Una marca, como OpenStart o ReconnectStart, que indica en qué etapa del procesamiento se encuentra en cada momento |
4.1. Comprimir el tiempo sin esperar una reproducción de varios meses
En este tipo de investigaciones, esperar un mes cada vez es un mal planteamiento. Lo que hay que hacer es recorrer muchas veces, en poco tiempo, el camino sospechoso.
En este caso, comprimí la reproducción haciendo correr un bucle como el siguiente.
flowchart LR
accTitle: Bucle corto para comprimir la reproducción del fallo
accDescr: Diagrama de flujo del bucle usado para comprimir la reproducción, iniciar, abrir la cámara, empezar a capturar, simular un timeout o desconexión, reconectar, reanudar la captura y repetir N veces antes de comprobar la diferencia de recursos al finalizar.
A[Inicio] --> B["Abrir cámara(open)"]
B --> C[Iniciar captura]
C --> D[Timeout / desconexión simulados]
D --> E[Reconexión]
E --> F[Reanudar captura]
F --> G{¿Repetir N veces?}
G -- Sí --> D
G -- No --> H[Verificar la diferencia al finalizar]
El punto clave es dedicar el tiempo no al periodo normal en el que “se está capturando”, sino a las operaciones de ciclo de vida en los límites.
Los escenarios que resultan eficaces en concreto son estos.
- Repetir masivamente
open -> start -> stop -> close - Provocar un timeout de forma intencionada y hacer correr el reconnect
- Provocar un fallo justo después del registro del callback
- Introducir interrupciones de la desconexión, interrupciones de la reconexión y competencias con el shutdown
No hace falta reproducir a la perfección un mes entero de operación real. Más bien, pisar miles de veces el lifetime edge que se sospecha acerca mucho más a la causa.
4.2. Observar la pendiente del Handle Count
Antes de eso, dejo escrito dónde se observa el Handle Count. Si esto no queda claro, toda esta sección se queda en papel mojado.
| Método | Operación | Cuándo conviene |
|---|---|---|
| Administrador de tareas | Abrir la pestaña “Detalles”, hacer clic derecho en el encabezado de columnas → “Seleccionar columnas” → marcar “Identificadores” | Cuando se quiere ver rápido cuántos hay en este momento |
| Process Explorer | Seleccionar el proceso, abrir sus propiedades y ver Handle Count en la pestaña Process Performance. Si se ordena por Type la vista Handles del panel inferior, también se ve el desglose por tipo | Cuando se quiere saber qué tipo de handle está aumentando |
handle.exe |
Obtener en texto el recuento por tipo con handle -s -p CameraApp |
Cuando se quiere dejar registrada una observación puntual en un log |
| PowerShell | Get-Process -Name CameraApp \| Select-Object Name, Id, HandleCount |
Cuando se quiere obtenerlo periódicamente mediante un script |
typeperf |
typeperf "\Process(CameraApp)\Handle Count" -si 60 -sc 1440 -o handles.csv |
Cuando se quiere registrarlo directamente en CSV durante mucho tiempo |
| La propia aplicación | Incrustar GetProcessHandleCount o Process.HandleCount en el log de heartbeat |
Cuando en la máquina de producción solo se puede recopilar el log |
El desglose por tipo y cómo rastrear el aumento de los eventos sin nombre están explicados como procedimiento en Process Explorer / Handle / VMMap en la práctica.
En la investigación de un funcionamiento prolongado, la opción principal es la última de la tabla, “que la propia aplicación lo emita”. Que una persona vigile el Administrador de tareas no es sostenible en un funcionamiento 24/7.
En la investigación de una fuga de handles, a veces mirar solo el valor absoluto no aclara nada. Lo importante es si, tras una operación que debería hacerlo volver a su valor, efectivamente vuelve, y cuántos aumenta por cada cuántas operaciones.
Como forma de observarlo, el siguiente orden suele ser el más claro.
- Fijar el baseline después del calentamiento
- Registrar el
Handle Countdespués de un reconnect / start-stop / close - Observar la diferencia en cada ciclo
- Observar también la pendiente acumulada tras varios ciclos
Por ejemplo, se observa así.
leakSlope =
(currentHandleCount - baselineHandleCount)
/ reconnectCount
Si un valor absoluto de 2000 es mucho o poco varía según la aplicación. Pero si por cada reconnect sube +1 y no vuelve a bajar, eso es bastante sospechoso.
Dejo también una referencia de cómo debería verse el camino normal. Como los valores en sí dependen de cada aplicación, el criterio es la forma.
- Justo después del arranque aumenta. Esto no se tiene en cuenta
- Una vez terminado el calentamiento, debería aumentar y disminuir según la operación, manteniéndose dentro de un rango determinado
- Tras completar un ciclo de
open -> start -> stop -> close, lo normal es que el valor vuelva a ser casi el mismo que antes del ciclo - Si tras 100 ciclos la diferencia con el baseline se mantiene dentro de unas pocas unidades, en principio es saludable
- Por el contrario, si crece limpiamente de forma proporcional al número de ciclos, se está perdiendo esa misma cantidad en cada ciclo, según la pendiente
Lo que hay que observar no es “si es mucho o poco”, sino si vuelve o no vuelve a su valor. Si se confunde este punto, se acaba sospechando de una aplicación sana y perdiendo el tiempo.
El truco aquí es no observar Handle Count de forma aislada, sino registrar junto a él, como mínimo, lo siguiente.
Handle CountPrivate BytesThread CountReconnectCount- En qué phase se está en ese momento
Con esto se puede saber bastante rápido si “está aumentando la memoria”, si “están aumentando los threads” o si “los recursos no vuelven a su valor en cada reconexión”.
4.3. Observar la correspondencia entre create/open y close/dispose
Aunque se detecte que el Handle Count del proceso en su conjunto es sospechoso, con eso solo no se llega hasta el punto de la fuga.
Lo siguiente que hace falta es un log que observe el ciclo de vida de los recursos por pares.
Como imagen, sería un structured log como este.
CameraSession session=421 cameraId=CAM01 phase=ReconnectStart reason=FrameTimeout handleCount=1824 privateBytesMB=418
CameraResource session=421 resourceId=evt-884 kind=Event name=FrameReady action=Create osHandle=0x00000ABC handleCount=1825
CameraResource session=421 resourceId=evt-884 kind=Event name=FrameReady action=Close osHandle=0x00000ABC handleCount=1824
Aquí lo importante es no depender solo de osHandle.
Como el valor del handle de Windows puede reutilizarse más adelante, en el log resulta más fácil de rastrear si al menos se incluye lo siguiente.
sessionIdresourceIdkindaction(Create/Open/Register/Close/Dispose/Unregister)osHandlephase
Haciéndolo así, resulta más fácil encontrar un flujo “cojo”, en el que hay un Create pero no hay un Close.
4.4. En una fuga de handles, hay que buscar “dónde se produjo la fuga”, no “dónde se cayó”
Este punto es bastante importante.
Una fuga de handles suele mostrarse de esta forma.
- Línea donde se cae: fallo de
CreateEvent - La fuga real: desde varios días antes, faltaba
CloseHandleen el failure path
Es decir, la API en la que finalmente se cae es la salida del daño, y no necesariamente la entrada de la causa.
Por eso, como orden de investigación conviene:
- Observar qué recurso sigue aumentando
- Observar en qué límite de operación no vuelve a su valor
- Buscar dónde se ha roto el par
create/openyclose/dispose - Leer el punto del crash al final
Seguir este orden hace que sea mucho más difícil perderse.
5. Los logs necesarios para evitar que se repita
5.1. El conjunto mínimo que hay que dejar registrado primero
Lo que resultó eficaz en esta investigación no fue simplemente aumentar la cantidad de logs. Fue organizar y aumentar “la información que permite llegar más tarde a la causa”.
Como mínimo, conviene dejar registrado lo siguiente.
| Categoría | Campos mínimos deseables | Motivo |
|---|---|---|
| Contexto de la operación | cameraId, sessionId, operationId, reconnectCount, phase |
Para poder vincular en qué operación y en qué repetición ocurrió |
| Recursos del proceso | handleCount, privateBytes, workingSet, threadCount |
Para identificar primero qué está aumentando |
| Ciclo de vida del recurso | action, resourceId, kind, osHandle, owner |
Para poder rastrear el par create/open y close/dispose |
| Resultado de llamadas externas | win32Error, HRESULT, sdkError, timeoutMs |
Para poder comparar más tarde el tipo de fallo |
| Transiciones de estado | OpenStart, OpenDone, ReconnectStart, ReconnectDone, ShutdownStart, etc. |
Para saber en qué phase se rompió el proceso |
| Entorno de ejecución | pid, tid, buildVersion, machineName |
Para poder relacionarlo con el dump, los símbolos y el paquete distribuido |
No digo que esto sea suficiente. Pero, al menos, sin esto es fácil que el log termine siendo uno en el que solo queda registrado el hecho de que “se cayó”.
5.2. Los logs que se reforzaron en la práctica
En este caso, reforcé los logs en las siguientes direcciones.
- Heartbeat periódico
- Emitir
Handle Count/Private Bytes/Thread Count/ReconnectCountcada 1 a 5 minutos
- Emitir
- Log de límites por sesión de cámara
OpenStartCallbackRegisteredAcquisitionStartTimeoutDetectedReconnectStartReconnectDoneCloseStartCloseDone
- Log del ciclo de vida de los recursos
Create/Open/RegisteryClose/Dispose/Unregisterde event / thread / file / timer / SDK registration token
- Normalización de errores
- No limitarse solo al mensaje de la excepción; emitir a la vez
win32Error,HRESULT,sdkErroryphase
- No limitarse solo al mensaje de la excepción; emitir a la vez
Lo importante es no cambiar el formato del log entre el caso de éxito y el de fallo. Si el caso anómalo pasa a tener un formato distinto, luego resulta difícil de agregar.
5.3. Con qué nivel de granularidad registrar
Aquí es habitual caer en “por ahora, sacarlo todo como INFO”. Pero si se hace eso, más tarde, al leerlo, aparece un muro de logs. Esto resulta bastante duro.
Como granularidad, el siguiente reparto es, más o menos, realista.
- Monitorización periódica
Handle Count,Private Bytes,Thread Count,ReconnectCount
- Límites de operación
- start / done / fail de la sesión
- Límites de recursos
create/open/registeryclose/dispose/unregister
- Detalle en caso anómalo
- error code, stack, disparador de captura de dump
Normalmente no hace falta un log detallado de cada frame. Más bien, un log en el que se pueda leer “qué responsabilidad abrió y qué responsabilidad cerró” resulta más eficaz frente a fallos de larga duración.
6. Guía rápida de cuándo aplicar cada cosa
- Solo se cae al cabo de varios días o semanas
- Primero, incluir un heartbeat de
Handle Count/Private Bytes/Thread Count
- Primero, incluir un heartbeat de
- Hay retry / reconnect / shutdown
- Antes que nada, crear un harness que recorra masivamente solo esos límites
- Se usa mucho native SDK / P/Invoke / Win32
- Merece bastante la pena aplicar el Application Verifier de la segunda parte
- También convive una GUI
- Conviene observar, además de
Handle Count, tambiénGDI Objects/USER Objects
- Conviene observar, además de
- Con solo la excepción del instante en que se cae no se entiende nada
- Es más rápido preparar primero un structured log de operation / session / resource lifecycle
Este último punto es bastante importante. En la investigación de fallos, más que la técnica de análisis en sí, con frecuencia lo que decide la partida es si se ha dejado preparada la forma de poder observarlo.
7. Resumen
En una aplicación que solo se cae tras un funcionamiento prolongado, hay que observar no solo la memoria, sino también el Handle Count. Una fuga de handles tiende a esconderse no en el camino normal, sino en el failure path del camino anómalo, y el punto del crash suele ser la salida del daño secundario, no el punto donde se produjo la fuga. En cuanto a cómo leer los síntomas, al final todo se reduce a estos tres puntos.
Como prevención de recurrencia, conviene acercar las responsabilidades de create/open y close/dispose, dejar registrados logs con contexto por sesión/operación, y registrar tanto los recursos del proceso como el ciclo de vida de los recursos. En las pruebas, sin esperar a una reproducción de meses, hay que hacer correr en un bucle corto el timeout / reconnect / shutdown, y establecer como condición de aprobación no solo “que no se rompa”, sino también “que se pueda rastrear cuando se rompe”. Esta combinación fue lo que resultó eficaz en este caso. En la segunda parte usaré Application Verifier para adelantar y hacer aflorar tipos de rotura difíciles de manifestar, como la falta de memoria o las anomalías de handles.
En una aplicación de control, es importante que el camino normal funcione correctamente, pero que, cuando algo se rompe, se pueda saber “qué ha pasado” resulta bastante eficaz en una operación a largo plazo.
Una fuga de handles es precisamente el tipo de fallo en el que esa diferencia se nota. En lugar de mirar solo el instante en que ocurre, observarlo a través de cómo crece, de los límites y del par de responsabilidades hace que resulte mucho más fácil de rastrear.
Segunda parte: Cómo construir una base de pruebas de casos anómalos en Windows con Application Verifier
8. Referencias
- Función GetProcessHandleCount (processthreadsapi.h)
- Propiedad Process.HandleCount (System.Diagnostics)
- Kernel Objects - Win32 apps
- GDI Objects - Win32 apps
- typeperf - Windows Commands
- Process Explorer / Handle / VMMap en la práctica ── rastrear cuelgues, fugas y “el archivo está en uso” desde el estado del instante presente
- Segunda parte: Cómo construir una base de pruebas de casos anómalos en Windows con Application Verifier
Artículos relacionados
Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.
Base de pruebas de casos anómalos en Windows con Application Verifier
Qué es Application Verifier y cómo usarlo, junto con Handles, Heaps, Low Resource Simulation y !htrace, para crear una base de pruebas de...
Cómo interpretar los códigos de error de Windows — la estructura de tres capas de Win32, HRESULT y NTSTATUS
Antes de buscar 0x80004005, descompóngalo. Explicamos la estructura de tres capas Win32/HRESULT/NTSTATUS, el patrón 0x8007xxxx y cómo inv...
¿Qué representa realmente el «uso de memoria» de Windows? — Cómo interpretar correctamente Working Set, Private Bytes, Commit y el archivo de paginación
La memoria de Task Manager, Working Set, Private Bytes y Commit no son el mismo valor. Explica la memoria virtual y física de Windows y q...
Process Explorer / Handle / VMMap en la práctica ── cómo rastrear cuelgues, fugas y «archivo en uso» desde el estado actual
Segunda guía práctica de Sysinternals: cómo investigar lentitud, archivos en uso y cuelgues con Process Explorer, handle.exe y VMMap.
El manejo de incidentes no termina con la recuperación ── el modelo de postmortem (prevención de recurrencia) para equipos de desarrollo pequeños
Si un incidente se cierra con «arreglarlo y disculparse», se repite. Adaptamos el postmortem blameless a equipos pequeños: plantilla de u...
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.
Investigación de fallos y problemas prolongados
Fallos intermitentes, diagnóstico de comunicaciones, bloqueos prolongados y pruebas de rutas de error.
Casos de estudio relacionados
Estos casos muestran un enfoque parecido para analizar, priorizar o rediseñar.
Cómo vinculamos un fallo tras una ejecución prolongada con una fuga de handles
Caso práctico sobre cómo mejores puntos de observación y registros convirtieron un fallo mensual en una investigación concreta de una fuga de handles.
Servicios relacionados con este tema
El artículo está directamente relacionado con los siguientes servicios.
Investigación de fallos y causas
La identificación de fallos que solo ocurren tras un funcionamiento prolongado encaja muy bien con un servicio de investigación de fallos y análisis de causas.
Desarrollo de aplicaciones para Windows
Si se busca revisar el diseño de la aplicación Windows, incluyendo el diseño de logs y la observabilidad en producción, esto también conecta con una consultoría de desarrollo de aplicaciones Windows.
Preguntas frecuentes
Preguntas habituales en las consultas sobre el tema del artículo.
- ¿Qué es una fuga de handles?
- Es cuando un proceso de Windows olvida cerrar los handles que usa para referenciar recursos del sistema operativo, como event, mutex, file o socket, y el Handle Count sigue aumentando sin parar. En particular, es habitual olvidar cerrar un recurso abierto temporalmente para una operación concreta cuando el flujo falla a mitad de camino, por ejemplo en un timeout, un reconnect o un early return, y como las pruebas cortas habituales solo recorren el camino de éxito, este patrón se pasa por alto con facilidad.
- ¿Cómo se distingue una fuga de memoria de una fuga de handles?
- El indicador que hay que observar es distinto. En una fuga de memoria, Private Bytes o Commit aumentan poco a poco, mientras que en una fuga de handles es el Handle Count el que aumenta poco a poco y no vuelve a bajar. Al analizar un funcionamiento prolongado, si solo se mira la memoria es fácil acabar conduciendo con un solo ojo, así que lo básico es observar también el Handle Count y el Thread Count al mismo tiempo. Si además convive una interfaz gráfica, conviene mirar también GDI Objects / USER Objects.
- ¿Por qué, cuando hay una fuga de handles, la aplicación solo se cae tras un funcionamiento prolongado?
- Porque una fuga con una pendiente pequeña, en la que cada fallo solo pierde un handle, no provoca nada en pocos minutos, pero en un funcionamiento 24/7 las condiciones límite como el timeout o la reconexión se repiten muchas veces y el efecto se acumula a lo largo de semanas. Al final, en el momento en que falla la API que crea un nuevo event, file o thread, el problema sale a la superficie como un daño secundario. También es importante tener en cuenta que el punto donde se produce el crash suele ser la última víctima, no el lugar donde se originó la fuga.
- ¿Cómo se debería investigar una fuga de handles?
- Sin esperar a una reproducción que tarda meses, se comprime el tiempo repitiendo miles de veces, en un bucle corto, las operaciones de ciclo de vida sospechosas, como open -> start -> stop -> close, o el timeout y la reconexión. Se fija un baseline después del calentamiento, se observa la diferencia y la pendiente del Handle Count en cada ciclo, se busca dónde se ha roto la correspondencia entre create/open y close/dispose con un structured log que incluya sessionId, resourceId y action, y solo al final se lee el punto donde ocurrió el crash; seguir este orden reduce las probabilidades de perderse.
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.