Exit code 143 is one of those numbers you learn once and then see everywhere. It means 128 plus 15, and 15 is SIGTERM, and SIGTERM means something asked your process to leave and your process had no idea how to answer. A Medium piece doing the rounds this week walks through a data engineering team that lost days to exactly this: a nightly PHP batch over roughly two million records, usually two to three hours, managed by a systemd unit, suddenly finishing at 60 percent one night and 50 percent the next. No exception. No fatal. The logs just stop, like someone unplugged the machine. The answer was in journalctl the whole time, in the line about the stopping timeout being reached.
<?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);The fix that article lands on is correct and I would ship it today: pcntl_async_signals(true), register a handler on SIGTERM that flips a boolean, check the boolean between work items, exit 0. That is genuinely a few lines, it is verified behaviour on PHP 8.3, and it converts a mystery into a log entry. My argument is that those lines are the cheapest part of the job and the part everyone stops at. The handler tells you a stop was requested. It does not tell you whether your job can survive being stopped.
Here is the shape of the contract. systemd sends SIGTERM (or whatever you set as KillSignal), waits TimeoutStopSec, which defaults to 90 seconds on most modern installs and was five minutes on some older ones, and then sends SIGKILL. Signal 9 is not negotiable. PHP will not even let you pretend: try pcntl_signal(SIGKILL, ...) and you get a fatal error about installing a signal handler for 9, which is the kernel refusing on your behalf. So the entire game is finishing your cleanup inside the grace window. And that window is a race you have to win every single time a maintenance window fires, a deploy restarts the unit, or RuntimeMaxSec expires.
Which is why the first instinct is usually wrong. Somebody gets bitten, opens the unit file, and turns TimeoutStopSec up to ten minutes. Now the worst case is bounded by the slowest thing you have ever measured, and every server reboot in the fleet waits on it. Meanwhile the batch that used to take two hours takes three because the table grew, and the number you picked in March is quietly wrong by September. You have not made the job resumable. You have bought yourself a bigger window in which to be unlucky, and you have made shutdown slower for everything sharing that box.
The honest counter-argument, and it is a strong one, is that most PHP shops never write a line of PCNTL and are perfectly fine. Laravel's queue:work handles SIGTERM by finishing the current job and exiting. Symfony's messenger:consume does the same. Give them a sane TimeoutStopSec, around 60 seconds for typical message-sized jobs, and you are done. I agree with that for queue traffic. The catch is that framework signal handling promises exactly one thing, that the current work item completes. If a work item is a single message that touches three rows, wonderful. If a work item is your monthly reconciliation loop reading two million records inside one call, the handler waits politely for something that will not finish, and SIGKILL arrives on schedule with exit code 137.
So the design decision that actually matters is how big your smallest safe stopping point is. Chunk the batch into units you would be happy to re-run, write a checkpoint after each one, make the unit idempotent enough that a retry is boring. Then the grace period stops being a bet, because your worker is never more than a few seconds away from a legal exit. That is also what makes RuntimeMaxSec or an application-side max-time restart pleasant rather than terrifying: you get a fresh process on a schedule you chose, with memory reclaimed, and nothing in flight. Small print worth keeping: pcntl_async_signals(true) is not optional decoration. Without it, delivery only happens at dispatch points, so a tight compute loop with no I/O can sail straight through a SIGTERM it never sees.
The last piece is that none of this is observable unless you look. Exit codes are free telemetry and almost nobody graphs them. A 143 in production means a process was asked to stop and could not answer. A 137 outside of your deploy windows usually means the OOM killer got there first, and dmesg will confirm it. Put a counter on both, alert when they move, and you find the broken shutdown path in week one instead of after a quarter of half-finished reports. Send a kill -TERM to your worker on staging before you believe any of this, because the shutdown path is the one code path nobody exercises until it matters.
So I will put the question to you, because I suspect people are split. Where do you draw the line between raising TimeoutStopSec and refactoring the job into chunks? If your longest single unit of work is twenty minutes and rewriting it means touching a reporting pipeline nobody wants to reopen, do you actually rewrite it, or do you set the timeout to thirty minutes and monitor exit codes instead? And has anyone here got shutdown behaviour under test in CI, or is that still a thing we all agree is a good idea and never do?
Comments
No comments yet — be the first.
Open the discussion
No account or password needed — just enter your e-mail and we’ll send you a one-time sign-in link. First time here? You’re set up automatically.
Your rating will be applied automatically after you sign in.
Check your inbox
We’ve sent a sign-in link to …. Open it on this device — this tab will sign you in automatically.
Nothing arrived? Check your spam folder — and mark the mail as "Not spam" so it lands in your inbox next time.