rasuvaeff / context-cli
Graceful shutdown for PHP CLI workers: OS signals cancel a rasuvaeff/context Context
Requires
- php: 8.3 - 8.5
- ext-pcntl: *
- ext-posix: *
- rasuvaeff/context: ^0.3
Requires (Dev)
- ergebnis/composer-normalize: ^2.51
- friendsofphp/php-cs-fixer: ^3.95
- infection/infection: ^0.35
- maglnet/composer-require-checker: ^4.17
- rasuvaeff/property-testing-testo: ^1.0
- rasuvaeff/rector-named-literals: ^1.0
- rector/rector: ^2.4
- roave/backward-compatibility-check: ^8.0
- testo/bridge-infection: ^0.1.6
- testo/testo: ^0.10.25
- vimeo/psalm: ^6.16
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-15 18:09:40 UTC
README
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.
Requirements
- PHP 8.3+
ext-pcntl,ext-posix(Linux/macOS; not available on Windows)rasuvaeff/context^0.3
Installation
composer require rasuvaeff/context-cli
Limitations
- 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.
Usage
Run a worker under a scope
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.
Manual scope
$scope = SignalScope::listen(Context::background(), signals: [\SIGTERM, \SIGINT, \SIGHUP]); try { work($scope->context()); } finally { $scope->restore(); } $scope->signal(); // the first signal received, or null
Behaviour
| 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().
Kubernetes / systemd
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.
Public API
| 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") |
Security
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.
Examples
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 |
Development
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