Investigación de un crash en una cámara industrial tras un largo funcionamiento - Edición fuga de handles

· Actualizado el: · · 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

  1. Primero, la conclusión (en pocas palabras)
  2. 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
  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
    • 3.2. Los primeros indicadores observados
    • 3.3. El punto de fuga que era la causa real
  4. 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/open y close/dispose
    • 4.4. En una fuga de handles, hay que buscar “dónde se produjo la fuga”, no “dónde se cayó”
  5. 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
  6. Guía rápida de cuándo aplicar cada cosa
  7. Resumen
  8. 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én Handle 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, el handle count del proceso, la correspondencia open/close de 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.

Cómo un pequeño goteo de handles termina en un crashDiagrama 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.Funcionamiento normalA veces timeout / reconnectSe crea un Event Handle en el camino de falloNo se llama a CloseHandleEl Handle Count aumenta un pocoSe repite cientos de vecesFalla CreateEvent / la apertura del SDKCrash / 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 null o 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í.

Secuencia de la fuga de handles durante la reconexión de la cámaraDiagrama 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.SDK de la cámaraWindowsAplicación de controlSDK de la cámaraWindowsAplicación de controlReturn en el failure pathNo se llama a CloseHandleloop[Reconnect repetido muchas veces]CreateEventRegistro de callbackFallo parcial / timeoutEl Handle Count aumenta poco a pocoSiguiente CreateEvent / OpenFallaCrash 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/open y close/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.

Bucle corto para comprimir la reproducción del falloDiagrama 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.NoInicioAbrir cámara(open)Iniciar capturaTimeout / desconexión simuladosReconexiónReanudar captura¿Repetir N veces?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.

  1. Fijar el baseline después del calentamiento
  2. Registrar el Handle Count después de un reconnect / start-stop / close
  3. Observar la diferencia en cada ciclo
  4. 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 Count
  • Private Bytes
  • Thread Count
  • ReconnectCount
  • 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.

  • sessionId
  • resourceId
  • kind
  • action(Create/Open/Register/Close/Dispose/Unregister)
  • osHandle
  • phase

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 CloseHandle en 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:

  1. Observar qué recurso sigue aumentando
  2. Observar en qué límite de operación no vuelve a su valor
  3. Buscar dónde se ha roto el par create/open y close/dispose
  4. 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.

  1. Heartbeat periódico
    • Emitir Handle Count / Private Bytes / Thread Count / ReconnectCount cada 1 a 5 minutos
  2. Log de límites por sesión de cámara
    • OpenStart
    • CallbackRegistered
    • AcquisitionStart
    • TimeoutDetected
    • ReconnectStart
    • ReconnectDone
    • CloseStart
    • CloseDone
  3. Log del ciclo de vida de los recursos
    • Create/Open/Register y Close/Dispose/Unregister de event / thread / file / timer / SDK registration token
  4. Normalización de errores
    • No limitarse solo al mensaje de la excepción; emitir a la vez win32Error, HRESULT, sdkError y phase

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/register y close/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
  • 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én GDI Objects / USER Objects
  • 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

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.

Estos casos muestran un enfoque parecido para analizar, priorizar o rediseñar.

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

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.

Volver al blog