skip to content

How would you make a PHP CLI queue-consumer daemon finish its current job and exit cleanly when its process manager sends SIGTERM?

level: seniorimportance: should knowfreq 33%

answer

  1. the handler only sets a flag
  2. pcntl_async_signals(true) before the loop
  3. check the flag between jobs
  4. short blocking-pop timeouts
  5. grace period, then SIGKILL

basics

~20 s

Enable 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 s

Turn 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
<?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

for a junior

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.

for a middle

Explain why the flag is checked between jobs, how blocking calls delay handlers, and why SIGINT deserves the same handler as SIGTERM.

for a senior

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.

for a principal

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