El código de salida 143 es uno de esos números que aprendes una vez y luego ves en todas partes. Significa 128 más 15, y 15 es SIGTERM, y SIGTERM significa que algo le pidió a tu proceso que se marchara y tu proceso no tenía ni idea de cómo responder. Un artículo de Medium que circuló esta semana repasa el caso de un equipo de data engineering que perdió días con exactamente esto: un batch nocturno en PHP sobre unos dos millones de registros, normalmente de dos a tres horas, gestionado por una unit de systemd, que de repente terminaba al 60 por ciento una noche y al 50 por ciento la siguiente. Sin excepción. Sin fatal. Los logs simplemente se cortan, como si alguien hubiera desenchufado la máquina. La respuesta estuvo todo el tiempo en journalctl, en esa línea que avisa de que se alcanzó el timeout de parada.

graceful-worker.php
<?php
declare(strict_types=1);

pcntl_async_signals(true);
$shouldExit = false;

pcntl_signal(SIGTERM, function () use (&$shouldExit) {
    $shouldExit = true;
});

while (!$shouldExit) {
    // one atomic chunk of work, then a checkpoint
    sleep(1);
}

exit(0);

El arreglo al que llega ese artículo es correcto y yo lo llevaría a producción hoy mismo: pcntl_async_signals(true), registrar un manejador para SIGTERM que levante un booleano, comprobar ese booleano entre unidades de trabajo y salir con exit 0. Son de verdad unas pocas líneas, es comportamiento verificado en PHP 8.3, y convierte un misterio en una entrada de log. Mi argumento es que esas líneas son la parte más barata del trabajo y también el punto donde todo el mundo se detiene. El manejador te dice que se pidió una parada. No te dice si tu job puede sobrevivir a que lo paren.

Este es el contrato, en su forma más desnuda. systemd envía SIGTERM (o lo que hayas configurado en KillSignal), espera TimeoutStopSec, que por defecto son 90 segundos en la mayoría de instalaciones modernas y eran cinco minutos en algunas más antiguas, y después manda SIGKILL. La señal 9 no se negocia. PHP ni siquiera te deja fingir: prueba pcntl_signal(SIGKILL, ...) y te llevas un error fatal por intentar instalar un manejador para la 9, que es el kernel negándose en tu nombre. Así que todo el juego consiste en terminar tu limpieza dentro de la ventana de gracia. Y esa ventana es una carrera que tienes que ganar absolutamente todas las veces que salta una ventana de mantenimiento, que un deploy reinicia la unit o que expira RuntimeMaxSec.

Por eso el primer instinto suele ser el equivocado. A alguien le muerde el problema, abre el fichero de la unit y sube TimeoutStopSec a diez minutos. Ahora el peor caso queda acotado por la cosa más lenta que hayas medido nunca, y cada reinicio de servidor de la flota se queda esperándola. Mientras tanto, el batch que tardaba dos horas tarda tres porque la tabla creció, y el número que elegiste en marzo ya está silenciosamente mal en septiembre. No has hecho que el job sea reanudable. Te has comprado una ventana más grande dentro de la cual tener mala suerte, y has hecho que el apagado sea más lento para todo lo que comparte esa máquina.

La contraargumentación honesta, y es fuerte, es que la mayoría de tiendas PHP no escriben una sola línea de PCNTL y viven perfectamente. queue:work de Laravel maneja SIGTERM terminando el job actual y saliendo. messenger:consume de Symfony hace lo mismo. Dales un TimeoutStopSec razonable, alrededor de 60 segundos para jobs del tamaño típico de un mensaje, y listo. En tráfico de colas estoy de acuerdo con eso. El matiz es que el manejo de señales del framework promete exactamente una cosa: que la unidad de trabajo actual se completa. Si una unidad es un único mensaje que toca tres filas, maravilloso. Si una unidad es tu bucle mensual de conciliación leyendo dos millones de registros dentro de una sola llamada, el manejador espera educadamente algo que no va a terminar, y SIGKILL llega puntual con código de salida 137.

Así que la decisión de diseño que de verdad importa es cuál es el punto de parada seguro más pequeño que tienes. Trocea el batch en unidades que te daría igual volver a ejecutar, escribe un checkpoint después de cada una y haz la unidad lo bastante idempotente como para que un reintento resulte aburrido. Entonces el periodo de gracia deja de ser una apuesta, porque tu worker nunca está a más de unos segundos de una salida legal. Eso es también lo que convierte RuntimeMaxSec, o un reinicio por max-time desde la aplicación, en algo agradable en lugar de aterrador: obtienes un proceso fresco en un calendario que elegiste tú, con la memoria recuperada y nada a medias. Letra pequeña que conviene guardar: pcntl_async_signals(true) no es un adorno opcional. Sin eso, la entrega solo ocurre en los puntos de dispatch, así que un bucle de cómputo apretado y sin I/O puede atravesar tranquilamente un SIGTERM que nunca llega a ver.

La última pieza es que nada de esto es observable si no miras. Los códigos de salida son telemetría gratis y casi nadie los grafica. Un 143 en producción significa que a un proceso le pidieron parar y no supo responder. Un 137 fuera de tus ventanas de deploy suele significar que el OOM killer llegó antes, y dmesg te lo confirmará. Ponle un contador a los dos, alerta cuando se muevan, y encontrarás el camino de apagado roto en la primera semana en lugar de después de un trimestre de informes a medio hacer. Manda un kill -TERM a tu worker en staging antes de creerte nada de esto, porque el camino de apagado es el único que nadie ejercita hasta que importa.

Así que te lanzo la pregunta, porque sospecho que la gente está dividida. ¿Dónde pones tú la línea entre subir TimeoutStopSec y refactorizar el job en trozos? Si tu unidad de trabajo más larga es de veinte minutos y reescribirla implica tocar un pipeline de reporting que nadie quiere volver a abrir, ¿la reescribes de verdad, o pones el timeout en treinta minutos y monitorizas los códigos de salida? ¿Y alguien por aquí tiene el comportamiento de apagado cubierto por tests en CI, o eso sigue siendo una de esas cosas que todos consideramos buena idea y nunca hacemos?