Graceful shutdown for PHP CLI workers: SIGTERM/SIGINT cancel a
rasuvaeff/context Context, so a
queue worker or a long-running command finishes its current step and exits
cleanly — on deploy, on Kubernetes pod termination, on Ctrl+C.
Using an AI coding assistant? llms.txt contains a compact API reference you can share with the model. Projects using the llm/skills Composer plugin also get this package's agent skill synced into
.agents/skills/automatically on install.
- PHP 8.3+
ext-pcntl,ext-posix(Linux/macOS; not available on Windows)rasuvaeff/context^0.3
composer require rasuvaeff/context-cli- The first signal stops the work at the next check, not mid-call. The
handler only requests cancellation (
ContextController::requestCancel()); your code sees it at the nextassertActive(),sleep(),iterate()oris*()call. Blocking calls that a signal interrupts —usleep(),sleep(),stream_select(),Context::sleep()— return early; curl withCURLOPT_NOSIGNALand most database drivers finish first, bounded by their own timeouts. - One process, one handler table. Two active scopes cannot own the same
signal (
LogicException); while a scope is active, the previous handler of its signals does not run. - Windows is not supported —
ext-pcntldoes not exist there.
use Rasuvaeff\Context\Cli\SignalScope;
use Rasuvaeff\Context\Context;
use Rasuvaeff\Context\ContextCancelledException;
try {
SignalScope::run(Context::background(), static function (Context $context) use ($queue): void {
while (true) {
$context->assertActive(); // stop between messages
$message = $queue->reserve(timeout: 5);
if ($message === null) {
continue;
}
[$job, $jobController] = $context->withTimeout($message->ttr());
try {
handle($message, $job);
$queue->ack($message);
} finally {
$jobController->cancel();
}
}
});
} catch (ContextCancelledException $stopped) {
$logger->info('worker stopped', ['reason' => $stopped->getPrevious()?->getMessage()]); // "Received SIGTERM"
}run() installs the handlers, passes the scoped context to the callback and
restores the previous handlers afterwards — even when the callback throws.
$scope = SignalScope::listen(Context::background(), signals: [\SIGTERM, \SIGINT, \SIGHUP]);
try {
work($scope->context());
} finally {
$scope->restore();
}
$scope->signal(); // the first signal received, or null| Event | What happens |
|---|---|
| First signal of the set | requestCancel(new SignalReceived($signal)). Nothing else runs in the handler; the next check terminates the context, runs its onTerminate() listeners and throws ContextCancelledException with the SignalReceived as previous |
| Second signal of the set | The scope restores the previous handlers and hands this signal to them: a PHP handler is called, the default disposition terminates the process (Ctrl+C twice stops a stuck worker), an ignored signal stays ignored |
restore() |
Previous handlers and the previous pcntl_async_signals() mode come back, then the scoped context is cancelled (like Go's NotifyContext stop) so it never stays attached to a long-lived parent; a signal cause already received is kept; idempotent |
| Parent cancelled/expired | The scoped context terminates with it (it is $parent->withCancel()) |
SIGKILL, SIGSTOP, duplicates, empty set |
InvalidArgumentException before anything is installed |
| A signal already owned by another active scope | LogicException |
The scope enables pcntl_async_signals(true) while it is active, so signals
are delivered between PHP opcodes without manual pcntl_signal_dispatch().
Both send SIGTERM, wait for the grace period (terminationGracePeriodSeconds
/ TimeoutStopSec), then SIGKILL. The worker stops at the next check, so the
longest step between two checks — one message's TTR — must fit in that period.
| Class | Description |
|---|---|
SignalScope |
listen(Context $parent, list<int> $signals = [SIGTERM, SIGINT]), run(Context $parent, callable(Context): T $work, list<int> $signals), context(), signal(), restore() |
SignalReceived |
Cancellation reason (RuntimeException): signal(): int, name(int): string ("SIGTERM", "signal 14") |
The package installs process-wide signal handlers and restores them; it never runs application code inside a handler. A second signal deliberately falls back to the previous disposition, so an operator can always stop the process.
See examples/ for runnable scripts.
| Script | Shows | Needs server? |
|---|---|---|
worker.php |
Graceful stop on SIGTERM/SIGINT; --stuck shows the second Ctrl+C terminating a worker that never checks |
ext-pcntl, no server |
No PHP/Composer on the host — run in Docker. composer:2 has no ext-pcntl,
so build a local image once:
make image # rasuvaeff/composer-pcntl:local = composer:2 + pcntl
make install
make build
make cs-fix
make test
make test-coverage
make mutation
make release-check