Lista de verificación para manejar procesos secundarios de forma segura en aplicaciones de Windows

· Actualizado el: · · Windows, Process, Job Object, IPC, C++, .NET, C#

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

Herramientas de conversión, actualizadores, workers de análisis, CLI externas, PowerShell, ffmpeg, utilidades internas. Las aplicaciones de Windows dependen de procesos secundarios con más facilidad de lo que se imagina.

Sin embargo, lo que suele fallar no es si el proceso se pudo iniciar.

  • El padre se cae, pero el proceso secundario queda activo
  • Solo sobreviven los procesos nietos
  • stdout / stderr se bloquean y WaitForExit nunca regresa
  • El watchdog muere junto con lo que supervisa
  • Se cree haber terminado todo con Kill(entireProcessTree: true), pero solo termina antes la observación

La clave para manejar procesos secundarios de forma segura en Windows no es elegir la API de inicio, sino decidir quién posee el árbol de procesos y diseñar el procedimiento de cierre junto con la E/S.

En este artículo organizamos Job Object, la propagación del cierre, la entrada/salida estándar y el watchdog como un único diseño integral.

Términos usados en este artículo

Antes de continuar, repasamos brevemente, línea por línea, los términos que aparecen en inglés.

Término Significado en una línea
process tree Árbol de procesos. La familia completa que incluye el proceso secundario iniciado por el padre y los procesos nietos que ese hijo, a su vez, inicia
graceful shutdown Cierre coordinado. La forma de pedir “termine, por favor” para que el otro proceso haga su propia limpieza antes de finalizar por sí mismo. Es lo opuesto a la terminación forzada
I/O completion port El mecanismo de Windows para notificar la finalización de operaciones de E/S asíncronas. Al asociarlo a un Job Object, se pueden recibir notificaciones de inicio y terminación de procesos
message pump El bucle de mensajes. El mecanismo mediante el cual un hilo con ventana extrae y procesa continuamente los mensajes del sistema operativo. Si esto se detiene, la pantalla se congela
heartbeat Una señal que el proceso secundario emite periódicamente para confirmar que sigue activo. Se usa para detectar el estado en que un proceso “está vivo pero no avanza”
restart budget El presupuesto de reinicios. El límite de cuántas veces se permite reiniciar dentro de un período determinado. Se mantiene para detener un crash loop
drain Drenar. Leer por completo la salida acumulada en el pipe para evitar que el lado que escribe se bloquee

Panorama general

Antes de entrar en detalle, mostramos en un solo diagrama la relación entre los actores involucrados.

Panorama general de la gestión segura de procesos secundarios en WindowsDiagrama que muestra un Job Object con JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE que contiene a la aplicación padre, un hijo helper.exe y dos procesos nietos, y un watchdog colocado fuera del Job que detecta la terminación mediante un exit handle, detecta bloqueos mediante heartbeat y recrea el Job dentro del restart budget.Job Object con JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSEdetecta la terminación mediante exit handledetecta bloqueos (hang) mediante heartbeatrecrea dentro del restart budgetAplicación padre / worker principalpropietario final del job handleHijo helper.exeNieto converter.exeNieto ffmpeg.exewatchdogse coloca fuera del Job

Hay dos puntos clave que observar aquí:

  • El límite del Job es el límite del árbol de procesos. Como se agrupa por pertenencia al Job y no por la vida o muerte del padre, aunque aumenten los procesos nietos no se produce ninguna omisión en la recuperación
  • Solo el watchdog está fuera del Job. Si se lo incluyera dentro, se lo limpiaría junto con lo que supervisa

1. La conclusión primero

Antes de entrar en detalle, enumeramos solo lo que más resultado da en la práctica.

  • Si quiere vincular el ciclo de vida del árbol de procesos secundarios a la vida o muerte del padre, el punto de referencia es Job Object
  • La solicitud de terminación a una consola y la recuperación del árbol de procesos son cosas distintas
    • Lo primero corresponde al process group y a GenerateConsoleCtrlEvent
    • Lo segundo corresponde a Job Object
  • Si quiere incluir el proceso en el Job desde el momento de su creación, lo más directo es un diseño que use STARTUPINFOEX y PROC_THREAD_ATTRIBUTE_JOB_LIST
  • Lo básico es drenar la salida estándar y el error estándar en paralelo
  • Si usa stdin, diseñe también el paso de cerrarlo al terminar de escribir para transmitir el EOF
  • Es más seguro colocar el watchdog fuera del Job de lo que supervisa
  • Kill(entireProcessTree: true) de .NET es útil como API para detener procesos de forma explícita, pero no sustituye un diseño que incluya la recuperación automática ante una caída del padre o el graceful shutdown

2. Qué es lo peligroso

La implementación para iniciar un proceso secundario suele poder escribirse, al principio, en unas 10 líneas más o menos. Pero lo que falla está fuera de esas 10 líneas.

  • Después de que el padre se cae, el hijo y el nieto siguen activos
  • El helper inicia a su vez otro helper, y el código se conforma con esperar solo al hijo directo
  • Uno de los dos, stdout o stderr, se bloquea y tanto el padre como el hijo terminan esperándose mutuamente
  • Se espera en el hilo de la UI y se congelan tanto la pantalla como COM
  • El watchdog comparte el mismo destino que lo que supervisa, y cae junto con él ante una anomalía

Lo importante aquí es que “la gestión de procesos secundarios” no es una cuestión de una sola API.

Conviene pensar por separado, como mínimo, en estos cuatro puntos, para tener una visión clara.

  1. Quién posee el árbol de procesos
  2. Cómo se solicita el cierre coordinado
  3. Cómo se hace fluir la entrada/salida estándar
  4. Cómo se supervisan la terminación anómala y los bloqueos

3. No mezclar el rol de cada mecanismo

process handle, process group y Job Object parecen similares, pero cumplen roles distintos.

Mecanismo Rol principal Escenario adecuado Lo que no cubre por sí solo
process handle Esperar la terminación de un proceso y obtener el exit code Esperar la finalización de una herramienta de un solo uso La recuperación de procesos nietos
process group Propagar Ctrl+Break a la consola El cierre coordinado de un hijo de consola El cleanup ante una caída del padre, los procesos secundarios con GUI
Job Object Agrupar el árbol de procesos, aplicar límites y terminarlo todo junto Árboles de worker, actualizadores, cadenas de helper El “guardar y luego cerrar” propio de cada aplicación

process group es un mecanismo que decide a dónde enviar una señal de consola, no un mecanismo para limpiar todo el árbol cuando el padre muere. Job Object, en cambio, es el mecanismo del propio Windows para administrar un grupo de procesos como una sola unidad.

3.1 Tabla de correspondencia por lenguaje

Este artículo mezcla contenido de Win32 y de .NET. Para que pueda seguir solo la columna de su lenguaje, presentamos antes la tabla de correspondencia.

Qué se quiere hacer Win32 / C++ .NET / C#
Iniciar un proceso CreateProcessW Process.Start
Crear un Job y aplicar límites CreateJobObjectW + SetInformationJobObject Invocar la misma API mediante P/Invoke. La biblioteca estándar no incluye un wrapper para Job Object
Incluir en el Job desde el momento de la creación STARTUPINFOEX + PROC_THREAD_ATTRIBUTE_JOB_LIST Igual que a la izquierda. No se puede especificar desde ProcessStartInfo
Incluir en el Job después de iniciado AssignProcessToJobObject Invocar la misma API mediante P/Invoke, pasando Process.Handle
Esperar la terminación WaitForSingleObject Process.WaitForExit; de forma asíncrona, WaitForExitAsync(desde .NET 5)
Obtener el exit code GetExitCodeProcess Process.ExitCode
Leer stdout / stderr Crear un pipe anónimo y leerlo en otro hilo RedirectStandardOutput y BeginOutputReadLine
Pedirle a un hijo con GUI que se cierre Enviar WM_CLOSE Process.CloseMainWindow
Enviar Ctrl+Break a un hijo de consola CREATE_NEW_PROCESS_GROUP + GenerateConsoleCtrlEvent No hay una API equivalente, se requiere P/Invoke
Forzar la terminación de todo el árbol TerminateJobObject, o cerrar el último job handle Process.Kill(entireProcessTree: true)(desde .NET Core 3.0), o el mismo P/Invoke anterior
Esperar la terminación de muchos hijos RegisterWaitForSingleObject / SetThreadpoolWait El evento Process.Exited, o WaitForExitAsync

Lo que queda claro aquí es que, solo en lo relativo a Job Object, incluso en .NET se termina llamando directamente a la API de Win32. Lo que .NET ofrece de forma nativa se limita a operaciones sobre un único proceso.

4. Usar Job Object como punto de referencia

Lo más potente de Job Object es que permite agrupar el árbol de procesos no según “de quién es hijo”, sino según “a qué Job pertenece”. Los procesos hijos que un proceso incluido en un Job crea mediante CreateProcess quedan incluidos en ese mismo Job de forma predeterminada.

Además, al añadir JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, todos los procesos asociados al Job se terminan cuando se cierra el último job handle.

4.1 Cuatro puntos clave para empezar

1. Si quiere limpiar todo el árbol al terminar el padre, use KILL_ON_JOB_CLOSE

Esta es la base para manejar helpers y workers en una aplicación de Windows. Un diseño que llame explícitamente a TerminateJobObject también es válido, pero si quiere que el cleanup quede ligado al ciclo de vida del padre incluso ante una terminación anómala, KILL_ON_JOB_CLOSE es la opción más clara.

2. No añada BREAKAWAY a la ligera

JOB_OBJECT_LIMIT_BREAKAWAY_OK y JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK parecen convenientes, pero también pueden hacer que una parte del árbol que usted creía poder limpiar se escape de él. Salvo que exista una intención deliberada, no añadir breakaway reduce la tasa de incidentes.

3. Si quiere incluir el proceso en el Job desde su creación, use PROC_THREAD_ATTRIBUTE_JOB_LIST

También es posible vincularlo después con AssignProcessToJobObject. Sin embargo, en los casos donde quiere dar por sentada la pertenencia al Job desde el momento mismo del inicio, es más correcto especificar el Job en la creación usando STARTUPINFOEX y PROC_THREAD_ATTRIBUTE_JOB_LIST.

4. No deje ambiguo quién posee el job handle

KILL_ON_JOB_CLOSE surte efecto cuando se cierra el último handle. Dicho de otro modo, si el job handle se duplica hacia otro proceso o se hereda sin intención, el cleanup no ocurrirá como se esperaba aunque el padre muera. Debe decidir de antemano quién es el propietario final del job handle.

4.2 Job Object también sirve para la observabilidad, pero las notificaciones no son infalibles

Job Object cuenta con un mecanismo para asociar un I/O completion port y recibir notificaciones. Sin embargo, es más seguro no asumir que las notificaciones del completion port están garantizadas al cien por cien en todos los casos.

Por eso, el completion port resulta útil para:

  • Monitoreo
  • Agregación
  • Registro (logging)
  • Métricas

pero es preferible no construir la corrección (correctness) del sistema apoyándose únicamente en él.

4.3 Verlo con el código mínimo

Es más corto que explicarlo con palabras, así que mostramos la forma mínima en ambos lenguajes.

En el lado de C++ son tres pasos: crear el Job → añadir KILL_ON_JOB_CLOSE → especificar el Job en el momento de iniciar el proceso.

// Windows 10 en adelante / C++17. Inicia helper.exe dentro de un Job y limpia todo el árbol cuando termina el padre
#include <windows.h>
#include <memory>
#include <string>

int wmain()
{
    // 0. Fija con una ruta absoluta el archivo que se va a iniciar.
    // Si se pasa nullptr a lpApplicationName y se deja que la búsqueda
    // parta de la primera palabra de la línea de comandos, entre los
    // lugares de búsqueda se incluyen "el directorio actual del proceso
    // padre" y "PATH".
    // Si helper.exe no está en su propia carpeta y alguien coloca un
    // ejecutable con el mismo nombre en un lugar con permisos de
    // escritura, ese ejecutable se ejecutará con los privilegios del padre
    wchar_t modulePath[MAX_PATH]{};
    DWORD moduleLen = GetModuleFileNameW(nullptr, modulePath, MAX_PATH);
    if (moduleLen == 0 || moduleLen >= MAX_PATH)   // se trata también como fallo el caso en que se truncó por MAX_PATH
    {
        return 1;
    }

    std::wstring application(modulePath, moduleLen);
    application.resize(application.find_last_of(L'\\') + 1);   // la carpeta donde está el propio ejecutable
    application += L"helper.exe";

    // 1. Crea el Job y hace que, al cerrarse el último handle, se terminen todos sus procesos
    HANDLE job = CreateJobObjectW(nullptr, nullptr);
    if (job == nullptr)
    {
        return 1;
    }

    JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{};
    limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
    if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits)))
    {
        CloseHandle(job);
        return 1;
    }

    // 2. Crea la lista de atributos para que el proceso pertenezca al Job desde su creación
    SIZE_T attributeSize = 0;
    InitializeProcThreadAttributeList(nullptr, 1, 0, &attributeSize);  // llamada en vacío solo para obtener el tamaño necesario
    auto storage = std::make_unique<BYTE[]>(attributeSize);
    auto attributes = reinterpret_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(storage.get());

    if (!InitializeProcThreadAttributeList(attributes, 1, 0, &attributeSize))
    {
        CloseHandle(job);
        return 1;
    }

    // el valor de job debe mantenerse vivo hasta llamar a DeleteProcThreadAttributeList
    if (!UpdateProcThreadAttribute(attributes, 0, PROC_THREAD_ATTRIBUTE_JOB_LIST,
                                   &job, sizeof(job), nullptr, nullptr))
    {
        DeleteProcThreadAttributeList(attributes);
        CloseHandle(job);
        return 1;
    }

    // 3. Inicia el proceso
    STARTUPINFOEXW startup{};
    startup.StartupInfo.cb = sizeof(startup);
    startup.lpAttributeList = attributes;

    PROCESS_INFORMATION info{};
    // CreateProcessW exige un búfer modificable.
    // También se coloca la misma ruta en argv[0]. Como contiene espacios,
    // siempre debe ir entre comillas
    std::wstring commandLine = L"\"" + application + L"\" --input data.bin";

    BOOL created = CreateProcessW(
        application.c_str(), commandLine.data(), nullptr, nullptr,
        FALSE,                          // limita los handles que se heredan
        EXTENDED_STARTUPINFO_PRESENT,
        nullptr, nullptr,
        &startup.StartupInfo, &info);

    DeleteProcThreadAttributeList(attributes);

    if (!created)
    {
        CloseHandle(job);
        return 1;
    }

    WaitForSingleObject(info.hProcess, INFINITE);

    DWORD exitCode = 0;
    GetExitCodeProcess(info.hProcess, &exitCode);

    CloseHandle(info.hThread);
    CloseHandle(info.hProcess);
    CloseHandle(job);   // El último job handle. Los descendientes que aún queden aquí se terminan todos juntos
    return static_cast<int>(exitCode);
}

Este código respeta dos restricciones documentadas para UpdateProcThreadAttribute. Ambas son fáciles de pasar por alto al leer la documentación.

  • PROC_THREAD_ATTRIBUTE_JOB_LIST solo está disponible en Windows 10 / Windows Server 2016 en adelante. Si necesita cubrir versiones anteriores, debe recurrir a AssignProcessToJobObject
  • El valor pasado a UpdateProcThreadAttribute debe seguir vivo hasta que se llame a DeleteProcThreadAttributeList. Un código que pase una variable local y salga de su ámbito inmediatamente después se rompe

El archivo que se inicia debe indicarse siempre con una ruta absoluta

El paso «0.» del principio, donde se construye la ruta a partir de GetModuleFileNameW, no es una cuestión de estilo, sino una forma de fijar con certeza qué ejecutable se va a ejecutar.

Si se pasa nullptr a lpApplicationName, la primera palabra de la línea de comandos se convierte en el nombre del módulo. Cuando esa palabra no incluye una ruta, Windows busca en este orden:

  1. El directorio desde el que se cargó la aplicación
  2. El directorio actual del proceso padre
  3. El directorio de sistema de 32 bits
  4. El directorio de sistema de 16 bits
  5. El directorio de Windows
  6. Los directorios listados en la variable de entorno PATH

El problema son 2 y 6. Cuando helper.exe no está en 1 —por un despliegue incompleto, una compilación con otra configuración, o restos de una desinstalación—, la búsqueda avanza hasta 2. Si el directorio actual es un lugar con permisos de escritura (se inició directamente desde la carpeta de descargas del usuario, o se usa una carpeta compartida como directorio de trabajo), el helper.exe colocado ahí se ejecuta con los mismos privilegios que el padre. Si el entorno permite modificar PATH, ocurre lo mismo con 6.

La propia documentación de Microsoft dedica una sección independiente a este punto, bajo «Security Remarks» (observaciones de seguridad), y advierte explícitamente que «para evitar este problema, no pase NULL en lpApplicationName». En esa misma sección aparece también el conocido ejemplo de que, si no se encierra entre comillas una ruta con espacios, podría terminar ejecutándose C:\Program.exe. Por eso, en el código, la línea de comandos también va entre comillas dobles.

Lo mismo ocurre con ProcessStartInfo en C#. Cuando UseShellExecute = false, .NET arma una única línea de comandos a partir de FileName y los argumentos, y pasa null a lpApplicationName, de modo que si solo se indica el nombre del archivo, ocurre exactamente la misma búsqueda descrita arriba. Pase una ruta absoluta construida a partir de AppContext.BaseDirectory.

Incluso en un entorno donde parezca que «ese tipo de error de despliegue nunca ocurrirá», el costo de escribirlo bien es prácticamente nulo. En el código que inicia procesos secundarios, básicamente no hay ninguna razón para indicar el ejecutable con un nombre relativo.

.NET no tiene un wrapper para Job Object, así que hay que recurrir a P/Invoke. La definición de las estructuras parece larga, pero en realidad solo se llaman dos funciones.

// .NET 8 / C# 12. Crea el Job, añade KILL_ON_JOB_CLOSE e incluye en él un proceso ya iniciado
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;

internal static class KillOnCloseJob
{
    private const int JobObjectExtendedLimitInformation = 9;
    private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000;

    [StructLayout(LayoutKind.Sequential)]
    private struct JOBOBJECT_BASIC_LIMIT_INFORMATION
    {
        public long PerProcessUserTimeLimit;
        public long PerJobUserTimeLimit;
        public uint LimitFlags;
        public nuint MinimumWorkingSetSize;
        public nuint MaximumWorkingSetSize;
        public uint ActiveProcessLimit;
        public nuint Affinity;
        public uint PriorityClass;
        public uint SchedulingClass;
    }

    [StructLayout(LayoutKind.Sequential)]
    private struct IO_COUNTERS
    {
        public ulong ReadOperationCount;
        public ulong WriteOperationCount;
        public ulong OtherOperationCount;
        public ulong ReadTransferCount;
        public ulong WriteTransferCount;
        public ulong OtherTransferCount;
    }

    [StructLayout(LayoutKind.Sequential)]
    private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
    {
        public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
        public IO_COUNTERS IoInfo;
        public nuint ProcessMemoryLimit;
        public nuint JobMemoryLimit;
        public nuint PeakProcessMemoryUsed;
        public nuint PeakJobMemoryUsed;
    }

    [DllImport("kernel32.dll", SetLastError = true)]
    private static extern SafeJobHandle CreateJobObjectW(IntPtr attributes, IntPtr name);

    [DllImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool SetInformationJobObject(
        SafeJobHandle job, int infoClass, ref JOBOBJECT_EXTENDED_LIMIT_INFORMATION info, uint infoSize);

    [DllImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool AssignProcessToJobObject(SafeJobHandle job, IntPtr process);

    /// <summary>Crea el Job. El handle devuelto debe permanecer abierto durante toda la vida de la aplicación.</summary>
    public static SafeJobHandle Create()
    {
        var job = CreateJobObjectW(IntPtr.Zero, IntPtr.Zero);
        if (job.IsInvalid)
        {
            throw new InvalidOperationException($"Error al ejecutar CreateJobObject. code={Marshal.GetLastWin32Error()}");
        }

        var info = default(JOBOBJECT_EXTENDED_LIMIT_INFORMATION);
        info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;

        var size = (uint)Marshal.SizeOf<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>();
        if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, ref info, size))
        {
            // No se debe dejar tal cual un Job a medio configurar: se creó
            // pero no se pudo configurar. Si el código que llama captura el
            // error de inicialización y reintenta, cada intento filtra un
            // handle de kernel (la versión en C++ de arriba llama a
            // CloseHandle en esta misma ruta)
            var error = Marshal.GetLastWin32Error();
            job.Dispose();
            throw new InvalidOperationException($"Error al ejecutar SetInformationJobObject. code={error}");
        }

        return job;
    }

    public static void Add(SafeJobHandle job, Process process)
    {
        if (!AssignProcessToJobObject(job, process.Handle))
        {
            throw new InvalidOperationException($"Error al ejecutar AssignProcessToJobObject. code={Marshal.GetLastWin32Error()}");
        }
    }
}

// Si se mantiene como un IntPtr sin envolver, en la ruta donde falla la
// inicialización nadie llega a cerrarlo. Con un SafeHandle basta con
// llamar a Dispose una vez en la ruta de fallo
internal sealed class SafeJobHandle : SafeHandleZeroOrMinusOneIsInvalid
{
    // El marshaller lo genera como valor de retorno de P/Invoke, así que debe poder crearse sin argumentos
    private SafeJobHandle() : base(ownsHandle: true) { }

    protected override bool ReleaseHandle() => CloseHandle(handle);

    [DllImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool CloseHandle(IntPtr handle);
}

El código que lo invoca es este. Si se llama solo a Create y se olvida Add, se llega al estado más difícil de notar: existe el Job, pero no tiene ningún hijo dentro.

// Mantenga el job handle en un campo o similar y no lo cierre hasta que
// la aplicación termine. Como tiene KILL_ON_JOB_CLOSE, en el instante en
// que se cierra, todos los hijos del Job se terminan.
// No le añada using aquí (los hijos morirían en cuanto se saliera del ámbito)
SafeJobHandle job = KillOnCloseJob.Create();

try
{
    // Pase el archivo que va a iniciar con una ruta absoluta. Si solo pasa
    // el nombre del archivo, el directorio actual y PATH entran entre los
    // lugares donde busca CreateProcess
    string helperPath = Path.Combine(AppContext.BaseDirectory, "helper.exe");

    var startInfo = new ProcessStartInfo(helperPath, "--input data.bin")
    {
        UseShellExecute = false,
        CreateNoWindow = true,
    };

    using var child = Process.Start(startInfo)
        ?? throw new InvalidOperationException("No se pudo iniciar helper.exe.");

    try
    {
        KillOnCloseJob.Add(job, child);   // Si olvida esto, el Job queda vacío
    }
    catch (Exception assignFailed)
    {
        // Add puede fallar, por ejemplo, si el lado del padre tiene
        // límites de Job incompatibles. En ese momento helper.exe ya está
        // en ejecución. El Dispose de `using` solo descarta el wrapper de
        // Process; no termina el proceso del sistema operativo, y como el
        // Job está vacío, tampoco lo resuelve job.Dispose(). Aquí hay que
        // detenerlo uno mismo y esperar a que termine
        try
        {
            if (!child.HasExited)
            {
                child.Kill(entireProcessTree: true);
            }

            // Kill solicita la terminación y regresa de inmediato. Si se
            // lanza throw sin esperar, puede llegar a coexistir con un
            // segundo helper.exe creado al reintentar la inicialización
            child.WaitForExit();
        }
        catch (Exception killFailed)
        {
            // No haber podido detenerlo es más grave que el fallo de Add.
            // Si se ignora, se continúa dejando atrás "un hijo que ni
            // entró al Job ni se detuvo"
            throw new AggregateException(
                "No se pudo asignar al Job y tampoco se pudo detener helper.exe.",
                assignFailed, killFailed);
        }

        throw;
    }
}
catch
{
    // Si falla tanto el inicio como la incorporación al Job, este Job ya
    // no se va a usar. Si se sale sin cerrarlo, cada reintento de
    // inicialización deja un handle de kernel más. En este punto el Job
    // está vacío (o el hijo ya se detuvo en el catch anterior), así que
    // cerrarlo no deja nada pendiente que se vea afectado
    job.Dispose();
    throw;
}

Sin embargo, esta versión en .NET tiene un intervalo entre el inicio del proceso y su incorporación al Job. Si durante ese intervalo el hijo llega a crear un nieto, ese nieto nace fuera del Job. La versión en C++ usa PROC_THREAD_ATTRIBUTE_JOB_LIST precisamente para eliminar ese intervalo. Si va a lidiar con un helper que crea nietos, vale la pena llegar hasta el P/Invoke con STARTUPINFOEX también en .NET.

5. Diseñar la propagación del cierre con un protocolo y un timeout

La terminación de un proceso secundario no se resuelve con una sola llamada a una API de kill. Lo menos propenso a fallos es seguir este proceso en tres pasos:

  1. Solicitar el cierre coordinado
  2. Esperar con un timeout corto
  3. Por último, forzar la terminación de todo el Job

Con este orden, se conserva la ruta de cierre normal y, al mismo tiempo, se puede recuperar el proceso si se queda colgado.

5.1 Hijo con GUI

Si el proceso secundario tiene GUI, en .NET CloseMainWindow equivale a enviar el mensaje de cierre. Sin embargo, esto es una solicitud de terminación, no una terminación forzada. Por eso, el flujo más natural es:

  • CloseMainWindow
  • Esperar un tiempo determinado
  • Si no funciona, forzar la terminación de todo el Job

5.2 Hijo de consola

En un hijo de consola no se puede usar el mensaje de cierre de la GUI. En este caso se usan el process group y las señales de consola.

El flujo consiste en iniciar el proceso con CREATE_NEW_PROCESS_GROUP y enviar CTRL_BREAK_EVENT mediante GenerateConsoleCtrlEvent. Aquí importa lo siguiente:

  • CTRL_C_EVENT no se presta bien para limitarse a un grupo concreto
  • Solo los procesos que comparten la consola pueden recibir la señal
  • Usar CREATE_NEW_PROCESS_GROUP también cambia el significado de CTRL+C

5.3 Worker / hijo headless

Un worker o un hijo headless a menudo no tiene ni GUI ni consola. En este caso es más seguro contar con un protocolo de terminación propio para el proceso secundario.

  • Enviar quit por stdin
  • Enviar un comando shutdown mediante named pipe, socket o RPC
  • Transmitir la solicitud de detención mediante un event object

La separación en la que, desde el punto de vista de Windows, Job Object se encarga del cleanup del árbol, y desde el punto de vista de la aplicación, el pipe o stdin se encargan del cierre coordinado, resulta menos propensa a fallos.

6. No bloquear la entrada/salida estándar

6.1 Drenar stdout / stderr en paralelo

Esta es la primera regla básica. Hay que drenar stdout y stderr en paralelo. Leer todo un flujo antes de pasar al otro tiende a bloquearse.

Los pipes de Windows no tienen un búfer infinito. Si el proceso secundario escribe una gran cantidad de datos en stderr y el padre solo lee stdout, es normal que el hijo se detenga en write y el padre se detenga esperando la terminación.

Representado en un diagrama, toma esta forma.

Bloqueo de la E/S estándar por no drenar stderrDiagrama de secuencia que muestra cómo el padre sigue leyendo solo stdout mientras el hijo llena el búfer del pipe de stderr, el hijo queda bloqueado en write y el padre queda bloqueado esperando la terminación, de modo que WaitForExit nunca regresa.Proceso secundarioPipe de stderrPipe de stdoutProceso padreProceso secundarioPipe de stderrPipe de stdoutProceso padreEl búfer del pipe se llena por completowrite no regresa, el hijo se detiene aquíNo llega nada porque el hijo está detenidoEl padre queda esperando lectura y el hijo esperando escritura, WaitForExit tampoco regresaSigue leyendo solo stdoutEscribe un pocoSe pudo leerEscribe una gran cantidad de advertenciasIntenta escribir másIntenta leer la continuación

Como el lugar donde todo se detiene no es ni el padre ni el hijo, sino el pipe, la causa no aparece reflejada en ningún log de ninguno de los dos lados. La falta de una sola línea, «no se está leyendo stderr», se convierte directamente en un bloqueo.

Si se reciben stdout y stderr con manejadores independientes y se avanza en la lectura de cada uno por separado, este círculo no llega a formarse. En .NET, esto se ve así:

// .NET 8 / C# 12. Drena stdout y stderr en paralelo y espera hasta terminar de leer toda la salida
using System;
using System.ComponentModel;   // Win32Exception
using System.Diagnostics;
using System.IO;
using System.Text;

// Pase el archivo que va a iniciar con una ruta absoluta (el motivo se explica en la sección de Job Object)
string helperPath = Path.Combine(AppContext.BaseDirectory, "helper.exe");

var startInfo = new ProcessStartInfo(helperPath, "--input data.bin")
{
    UseShellExecute = false,        // Obligatorio si se va a usar la redirección
    RedirectStandardOutput = true,
    RedirectStandardError = true,
    CreateNoWindow = true,
};

using var process = new Process { StartInfo = startInfo };

var stdout = new StringBuilder();
var stderr = new StringBuilder();

// No se debe leer un flujo por completo antes que el otro. Se reciben ambos mediante eventos
process.OutputDataReceived += (_, e) =>
{
    if (e.Data is not null)
    {
        stdout.AppendLine(e.Data);
    }
};

process.ErrorDataReceived += (_, e) =>
{
    if (e.Data is not null)
    {
        stderr.AppendLine(e.Data);
    }
};

process.Start();
process.BeginOutputReadLine();   // Con solo registrar el manejador no empieza a leer. Hay que llamar siempre a ambos
process.BeginErrorReadLine();

if (!process.WaitForExit(30_000))
{
    // Aquí se decide "dejar de esperar", no se sustituye el cleanup
    try
    {
        process.Kill(entireProcessTree: true);
    }
    catch (Exception ex) when (ex is Win32Exception or InvalidOperationException)
    {
        // Existe una condición de carrera en la que el hijo termina por sí
        // mismo justo "después" de que expiran los 30 segundos de espera.
        // En .NET, un Kill durante el procesamiento de la terminación
        // produce Win32Exception ("The process is terminating."); en .NET
        // Framework, un Kill sobre un proceso ya terminado produce
        // InvalidOperationException.
        // Si ya terminó, esto no es un fallo, así que se absorbe y se
        // continúa hacia el TimeoutException de abajo. Si todavía está
        // vivo, es que de verdad no se pudo detener, así que se vuelve a
        // lanzar tal cual
        if (!process.HasExited)
        {
            throw;
        }
    }
    // No se absorbe AggregateException (no se pudo detener a parte de los
    // descendientes). Eso es, en sí mismo, "el árbol no quedó limpio", así
    // que se deja propagar hacia afuera

    // Kill solicita la terminación y regresa de inmediato. Si aquí se
    // lanza throw sin esperar, el hijo puede seguir vivo en el momento en
    // que se ejecuta el Dispose de using, y entonces no se cumple que
    // "se lanzó la excepción de timeout = el árbol quedó limpio"
    process.WaitForExit();

    throw new TimeoutException("helper.exe no terminó dentro de los 30 segundos.");
}

// Aunque el WaitForExit con timeout devuelva true, el procesamiento
// asíncrono de la salida puede no haber terminado todavía. Se llama de
// nuevo a WaitForExit sin argumentos y se espera hasta terminar de leer
// toda la salida.
process.WaitForExit();

Console.WriteLine($"exit code : {process.ExitCode}");
Console.WriteLine($"stdout    : {stdout.Length} caracteres");
Console.WriteLine($"stderr    : {stderr.Length} caracteres");

En el límite del timeout siempre existe una condición de carrera. En el breve instante que transcurre entre que WaitForExit(30_000) devuelve false y se llama a Kill, el hijo puede terminar por sí mismo. En ese caso, Kill no tiene éxito ── en .NET se produce un Win32Exception durante el procesamiento de la terminación («The process is terminating.»), y en .NET Framework se produce un InvalidOperationException sobre un proceso ya terminado. Si se deja pasar esto sin más, en lugar del TimeoutException que debía lanzarse, se propaga un fallo de limpieza. Quien llama recibe «salió un error confuso» en vez de «se agotó el tiempo», y además se salta el WaitForExit() final que termina de leer la salida. Como se muestra arriba, primero hay que confirmar con HasExited si el proceso realmente terminó, y solo entonces absorber la excepción. Si todavía sigue vivo, es que de verdad no se pudo detener, así que se vuelve a lanzar tal cual. Tampoco se absorbe el AggregateException que lanza Kill(entireProcessTree: true) (cuando no se pudo detener a parte de los descendientes): eso es, en sí mismo, «el árbol no quedó limpio», precisamente el estado que esta sección intenta evitar.

El WaitForExit() final no es un olvido que quedó sin borrar, sino algo obligatorio. La documentación de WaitForExit(int) indica que, cuando la salida estándar se redirige a un manejador de eventos asíncrono, es posible que el procesamiento de la salida no haya terminado en el momento en que regresa esta sobrecarga, y recomienda llamar a WaitForExit() sin argumentos después de recibir true. Si se omite esto, el fallo se manifiesta de una forma difícil de reproducir: solo faltan los últimos fragmentos de la salida.

6.2 Si usa stdin, diseñe también hasta el EOF

Poder escribir en stdin y que el hijo pueda terminar no son la misma cosa.

  • Se escribe la entrada y no se cierra después
  • El padre cree que «ya lo entregó todo»
  • El hijo cree que «todavía va a llegar más» y sigue esperando

Puede darse esta situación. Si va a usar stdin, es necesario diseñar también el paso de cerrarlo al terminar de escribir para transmitir el EOF.

6.3 Cierre siempre los extremos del pipe que no se usan

Si no se cierran los extremos sin uso, tanto en el lado del padre como en el del hijo, el EOF no se propaga y la condición de terminación se rompe. Es algo simple, pero en la práctica es un incidente bastante frecuente.

6.4 No deje ambiguo el manejo de UseShellExecute=false y la herencia de handles

Si va a usar la redirección de la entrada/salida estándar, en .NET se da por sentado que UseShellExecute=false. También en Win32 es más seguro limitar en lo posible qué se hereda. Dejar bInheritHandles=TRUE y heredarlo todo puede causar fugas de handles inesperadas.

7. Coloque el watchdog “fuera”

Lo más importante al incorporar un watchdog es no incluirlo en el mismo Job que aquello que supervisa. Si el worker se cae y usted quiere reiniciarlo, no tiene sentido que el encargado de reiniciarlo muera junto con él.

7.1 Basar la supervisión de la terminación en wait handles

Cuando un proceso termina, pasa al estado signaled. Por eso, la supervisión de la terminación no debería necesitar, en principio, un bucle de polling que revise HasExited cada 100 ms.

En Win32, lo correcto es:

  • WaitForSingleObject
  • WaitForMultipleObjects
  • RegisterWaitForSingleObject
  • SetThreadpoolWait

Si se manejan varios hijos, un enfoque basado en wait handles resulta más natural que el polling por temporizador.

7.2 No esperar indefinidamente en el hilo de la UI

WaitForSingleObject(INFINITE) es cómodo, pero si se usa en un hilo con ventana, es fácil que detenga el message pump. En el hilo de la UI, en un hilo de apartamento COM, o en cualquier hilo con message pump, es más seguro pensar primero dónde colocar la espera.

7.3 Un watchdog de bloqueos necesita heartbeat

Para un watchdog de terminación (exit watchdog), un process handle basta. Pero un watchdog de bloqueos (hang watchdog) es distinto.

  • Está congelado con la CPU al 100%
  • Está en un deadlock
  • El event loop sigue vivo pero no hay progreso
  • Está detenido esperando una entrada

Este tipo de estados no se pueden determinar únicamente con «si el proceso está vivo». Por eso, si quiere detectar también los bloqueos, necesita una verificación de vida a nivel de aplicación, como:

  • heartbeat
  • una secuencia de progreso
  • la marca de tiempo del último trabajo completado con éxito
  • una sonda de salud (health probe)

7.4 Coloque al encargado de reiniciar fuera de lo que supervisa

En la práctica, son comunes estos dos patrones:

  • La aplicación padre solo inicia un helper de forma temporal
    • El padre posee el Job y recupera el árbol del helper al terminar
  • Se mantiene un worker activo durante mucho tiempo y se quiere reiniciarlo si se cae
    • Un proceso o servicio watchdog externo crea un Job por cada generación del worker

En el segundo caso, el diseño resulta más estable si se separan el árbol de procesos del worker y la autoridad para reiniciar.

7.5 Mantenga la política de reinicio como un presupuesto (budget)

Al incorporar un watchdog, lo siguiente que puede empezar es un crash loop.

  • Se reinicia de inmediato
  • Vuelve a caerse de inmediato
  • Solo se genera una gran cantidad de logs

Para evitarlo, conviene mantener un restart budget con:

  • backoff
  • un límite de número de reinicios dentro de un período determinado
  • detenerse y notificar cuando hay fallos consecutivos

8. Configuraciones recomendadas según el patrón típico

Escenario Configuración recomendada
Una aplicación de escritorio inicia un helper de CLI de un solo uso 1 inicio = 1 Job. Añadir KILL_ON_JOB_CLOSE y drenar stdout / stderr en paralelo. Al cancelar: cierre coordinado → timeout → kill del Job
El helper, a su vez, inicia procesos nietos Dar por sentado el uso de Job Object y no permitir breakaway. Si se quiere fijarlo desde el inicio, usar PROC_THREAD_ATTRIBUTE_JOB_LIST
Un service / watchdog supervisa durante mucho tiempo un árbol de worker El watchdog es un process / service externo. Crear un Job por cada generación del worker y supervisar con exit handle + heartbeat
Se quiere detener con cuidado una herramienta de consola Iniciar con CREATE_NEW_PROCESS_GROUP y hacer el cierre coordinado con CTRL_BREAK_EVENT. Después, kill del Job con timeout
Se quiere cerrar un helper con GUI CloseMainWindow / el equivalente a WM_CLOSE → timeout → kill del Job
Se quieren supervisar muchos procesos secundarios En vez de multiplicar hilos bloqueantes, usar RegisterWaitForSingleObject / SetThreadpoolWait

Lo más importante aquí es separar el mecanismo de cierre coordinado (graceful shutdown) del mecanismo de cleanup.

9. Lo que no se debe hacer

Reunimos aquí, en un formato listo para usar directamente en una revisión, los puntos de atención mencionados en cada capítulo. Se presentan juntos «qué ocurre» y «dónde está explicado», de modo que desde la fila que le llame la atención pueda volver al cuerpo del artículo.

Lo que no se debe hacer Qué ocurre Sección
Creer que basta con Kill(entireProcessTree: true) para resolver también el graceful shutdown o la recuperación ante una caída del padre Solo funciona cuando se detiene de forma explícita. Se pierden la recuperación cuando el padre se cae y la ruta que permite al hijo hacer su propia limpieza Sección 5
Heredar todo dejando bInheritHandles=TRUE Handles no deseados pasan al hijo, lo que causa fugas de handles y fallos en la propagación del EOF 6.4
Leer stdout por completo antes de leer stderr El otro pipe se llena, el padre queda esperando lectura y el hijo esperando escritura, y todo se detiene 6.1
No cerrar el extremo del pipe que no se usa El EOF no se propaga y la condición de terminación del lado que lee no llega a cumplirse 6.3
Llamar a WaitForSingleObject(INFINITE) en el hilo de la UI Se detiene el message pump y se congelan la pantalla y COM 7.2
Incluir el watchdog en el mismo Job que aquello que supervisa Al limpiar lo supervisado, el encargado de reiniciar desaparece junto con ello Sección 7
Usar 259 como un exit code normal GetExitCodeProcess devuelve STILL_ACTIVE, es decir, 259, mientras el proceso sigue en ejecución. Si un hijo termina normalmente con el código 259, se le da por erróneamente en ejecución aunque ya haya terminado 7.1
Tomar las notificaciones del completion port del Job como la única fuente de verdad Las notificaciones están pensadas para monitoreo y agregación; construir sobre ellas la corrección del sistema deja huecos 4.2

10. Resumen

Al manejar procesos secundarios de forma segura en una aplicación de Windows, lo que más resultado da es esta organización.

Quién posee el árbol de procesos Cómo se transmite la solicitud de terminación Cómo se hace fluir por completo la entrada/salida estándar Dónde se coloca el watchdog

Decida primero estos cuatro puntos.

Sobre esa base, dicho de forma resumida:

  • El punto de referencia para el cleanup del árbol es Job Object
  • El cierre coordinado se separa según sea GUI, consola o worker
  • El diseño de la E/S estándar incluye el drenaje en paralelo y llegar hasta el EOF
  • El watchdog se coloca fuera de lo que supervisa, y se observa con wait handles y heartbeat en lugar de polling

CreateProcess o Process.Start son, en sí mismos, solo la puerta de entrada. Lo que realmente influye en la tasa de incidentes es dónde recae la responsabilidad del cierre y hacer fluir la E/S hasta el final.

11. Referencias

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

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

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

Investigación de fallos y causas

Los incidentes operativos difíciles de reproducir, como procesos secundarios que quedan huérfanos tras la caída del padre, un stdout que se bloquea o un watchdog que cae junto con lo que supervisa, suelen mejorar al revisar el diseño de la gestión de procesos.

Preguntas frecuentes

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

¿Por qué quedan procesos secundarios activos después de que el proceso padre se cae?
Porque el process handle o el process group por sí solos no tienen ningún mecanismo para recuperar el árbol de procesos cuando el padre se bloquea. Si quiere vincular el ciclo de vida del árbol de procesos secundarios a la vida o muerte del padre, el punto de referencia es Job Object. Al añadir JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, todos los procesos que pertenecen al Job se terminan cuando se cierra el último job handle, de modo que el cleanup queda ligado al ciclo de vida del padre incluso ante una terminación anómala.
¿Por qué WaitForExit nunca regresa?
Lo más probable es que el pipe de la salida estándar o del error estándar esté bloqueado. Los pipes de Windows no tienen un búfer infinito, así que si el proceso secundario escribe una gran cantidad de datos en stderr mientras el padre solo lee stdout, el hijo se detiene en la escritura y el padre se detiene esperando la terminación. Lo básico es drenar stdout y stderr en paralelo; una implementación que lee todo un flujo antes de pasar al otro se bloquea con facilidad. Además, si no se cierra el extremo no utilizado del pipe, el EOF no se propaga y la condición de terminación se rompe.
¿No basta con Kill(entireProcessTree: true) de .NET?
No es suficiente. Es útil como API para detener procesos de forma explícita, pero no sustituye un diseño que incluya la recuperación automática ante una caída del padre o el cierre coordinado (graceful shutdown). Lo menos propenso a fallos es un proceso en tres pasos: solicitar el cierre coordinado, esperar con un timeout corto y, por último, forzar la terminación de todo el Job. El medio para el cierre coordinado depende del tipo de proceso secundario: CloseMainWindow para un hijo con GUI, CREATE_NEW_PROCESS_GROUP y CTRL_BREAK_EVENT para un hijo de consola, y un protocolo de terminación vía stdin o pipe para un worker.
¿Dónde debería colocarse el proceso watchdog?
Lo más importante es no incluirlo en el mismo Job que el proceso que supervisa. Si el worker se cae y usted quiere reiniciarlo, no tiene sentido que el encargado de reiniciarlo muera junto con él. Cuando un worker se mantiene activo durante mucho tiempo, es más estable que un proceso o servicio watchdog externo cree un Job por cada generación del worker. La supervisión de la terminación debe basarse en wait handles en lugar de polling, y si además necesita detectar bloqueos (hangs), conviene combinarla con una verificación de vida a nivel de aplicación, como un heartbeat.

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