How would you make a PHP CLI queue-consumer daemon finish its current job and exit cleanly when its process manager sends SIGTERM?
answer
- the handler only sets a flag
- pcntl_async_signals(true) before the loop
- check the flag between jobs
- short blocking-pop timeouts
- grace period, then SIGKILL
basics
~20 sEnable pcntl_async_signals(true), register SIGTERM (and SIGINT) handlers that only set a stop flag, check the flag between jobs, keep blocking waits short, and exit(0) after the current job is acknowledged — within the manager's grace period before SIGKILL.
solid answer
~40 sTurn on `pcntl_async_signals(true)` and register a handler for `SIGTERM` (plus `SIGINT` for Ctrl-C) that does one thing: set `$shouldStop = true`. The consumer loop checks the flag at the top of each iteration, so a job that has started runs to completion and is acknowledged, and then the loop breaks and the script calls `exit(0)`. Keep the queue's blocking pop on a short timeout, because a handler cannot run while a C-level blocking call is waiting. Never `exit()` inside the handler, which would abandon the job halfway. Finally, respect the process manager's grace period: it sends SIGKILL after it, which no handler can catch, so long jobs must be idempotent or checkpointed so a redelivery is safe.
code
php · 26 lines<?php
declare(strict_types=1);
if (!extension_loaded('pcntl')) {
fwrite(STDERR, "pcntl missing: no graceful shutdown\n");
exit(1);
}
pcntl_async_signals(true);
$shouldStop = false;
$onStop = function (int $signo) use (&$shouldStop): void {
$shouldStop = true; // decide later, at a safe point
};
pcntl_signal(SIGTERM, $onStop);
pcntl_signal(SIGINT, $onStop);
while (!$shouldStop) {
$job = $queue->pop(timeoutSeconds: 5); // short, so the flag is seen
if ($job === null) {
continue;
}
$handler->handle($job);
$queue->ack($job);
}
exit(0);go deeper
Recall that SIGTERM is the polite stop request, that PHP needs pcntl_async_signals(true) for handlers to run, and that the handler should just set a flag.
Explain why the flag is checked between jobs, how blocking calls delay handlers, and why SIGINT deserves the same handler as SIGTERM.
Walk through the whole stop sequence with the manager's grace period and SIGKILL, and show how short timeouts, bounded jobs and idempotent processing keep shutdown safe.
Frame the trade-off between longer stop timeouts, which slow deploys, and job idempotency or checkpointing, which costs design effort but makes any kill survivable.
## The problem A **queue consumer** is a long-running PHP CLI script: it pulls a job from a queue, processes it, acknowledges it, and repeats. Process managers — a container runtime, a systemd unit, a supervisor program — stop such a process by sending **SIGTERM**, waiting a grace period, and then sending **SIGKILL**. Without a handler, SIGTERM's default action ends the PHP process immediately: whatever job was half-done stays half-done, and PHP's shutdown functions and destructors do not run. A clean shutdown means catching SIGTERM, finishing the job in hand, and leaving before the grace period ends. ## The pattern 1. **Enable dispatch.** Call `pcntl_async_signals(true)` before the loop; otherwise the PHP handler is queued and never runs. 2. **Register minimal handlers.** `pcntl_signal(SIGTERM, $onStop)` and `pcntl_signal(SIGINT, $onStop)`, where `$onStop` only sets a flag (and perhaps writes a log line). 3. **Check the flag at a safe point.** The loop condition is `while (!$shouldStop)`, so the check happens between jobs, never inside one. 4. **Bound every wait.** Use a short timeout on the blocking pop (a few seconds) so control returns to PHP often enough to notice the flag. 5. **Leave explicitly.** After the loop, close connections, write a final log line and `exit(0)` so the manager records a clean stop. ## Why the handler must not do the work It is tempting to put `exit()` or cleanup code in the handler itself. With asynchronous dispatch the handler runs **between any two opcodes** of the main code — perhaps after the database write and before the acknowledgement. Exiting there loses the acknowledgement and the job is redelivered; running cleanup there may close a connection the interrupted code is about to use. Setting a flag moves the decision to a point you chose. | Handler does | Effect | |---|---| | sets `$shouldStop = true` | the current job completes and is acknowledged; the loop exits at the next check | | calls `exit()` | the job stops wherever it was; destructors run mid-operation | | nothing (no handler installed) | the process dies at once with the default action | ## What can still delay shutdown - **Blocking C calls.** Handlers run between opcodes, so a blocking pop with a 60-second timeout, a slow HTTP call or a long query finishes first. With `restart_syscalls` at its default `true`, many interrupted system calls are restarted rather than abandoned. - **Long jobs.** If one job routinely runs longer than the grace period, the SIGKILL arrives mid-job no matter how good the handler is. Either split the work, raise the manager's stop timeout, or make the job idempotent so the redelivered copy is harmless. - **`sleep()` between polls.** A signal cuts `sleep()` short and it returns the seconds left, so an idle consumer that sleeps reacts quickly. ## Related signals and limits - **SIGINT** is what Ctrl-C sends in a terminal; handling it the same way makes local runs behave like production. - **SIGKILL** cannot be caught; registering a handler for it fails with a warning and `false`. The grace period is the only protection against it. - **Memory and job-count limits.** Many consumers also stop themselves after N jobs or when `memory_get_usage()` passes a threshold, relying on the manager to start a fresh process. The same flag and the same exit path serve both cases. - **Forked pools.** A parent that forked children should forward SIGTERM to each child with `posix_kill($pid, SIGTERM)` and then reap them, rather than exiting and leaving them running. ## Verifying it - Run the consumer, start a slow job, and send `kill -TERM <pid>`: the log should show the job completing and then a clean stop, and the exit code should be 0. - Send the signal while idle: the consumer should stop within one poll timeout. - Check that the extension is loaded (`extension_loaded('pcntl')`) at start-up, and fail loudly if not, because without pcntl the script silently loses graceful shutdown.
- Why not call exit() directly inside the SIGTERM handler?With async signals the handler runs between any two opcodes, so exiting there can stop a job after its side effect but before its acknowledgement; the queue then redelivers a job that was partly done. Setting a flag lets the loop finish the job and acknowledge it before leaving.
- The consumer handles SIGTERM correctly, yet the container still kills it mid-job. What is going on?The process manager sends SIGKILL once its grace period expires, and SIGKILL cannot be caught. Either the current job runs longer than the grace period or a long blocking call delayed the handler. Shorten waits, raise the stop timeout, split long jobs, or make them idempotent so redelivery is safe.
A cashier told the shop is closing finishes scanning the customer already at the till, takes the payment, and then closes the lane instead of walking off mid-sale; the manager only switches the lights off if the cashier is still there long after closing time.
saying these in an interview costs you the question
- Registering pcntl_signal() alone is enough; no dispatch setting is needed
- The SIGTERM handler should call exit() right away
- A good enough handler can also intercept SIGKILL
- Async signals interrupt a blocking queue pop immediately
- Without a handler, PHP still runs destructors and shutdown functions on SIGTERM