Skip to content

Commit de622b4

Browse files
authored
Merge pull request #55 from patchlevel/stop-on-sigint
Stop worker on SIGINT as well as SIGTERM
2 parents 022eecb + 55e0ada commit de622b4

9 files changed

Lines changed: 174 additions & 15 deletions

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@
55
# Worker
66

77
A small library to build stable, long-running workers that terminate gracefully when limits are exceeded
8-
or a SIGTERM signal is received. Perfect for daemonized console commands running under
8+
or a SIGTERM/SIGINT signal is received. Perfect for daemonized console commands running under
99
Docker, Kubernetes, supervisor or systemd, where the process manager restarts the worker after it exits.
1010

1111
## Features
1212

1313
* Configurable run, memory and time [limits](https://patchlevel.dev/docs/worker/latest/getting-started#limits)
14-
* [Graceful shutdown](https://patchlevel.dev/docs/worker/latest/getting-started#graceful-shutdown-on-sigterm) on SIGTERM
14+
* [Graceful shutdown](https://patchlevel.dev/docs/worker/latest/getting-started#graceful-shutdown) on SIGTERM and SIGINT
1515
* Extensible via [events and custom listeners](https://patchlevel.dev/docs/worker/latest/events)
1616
* [PSR-3 logging](https://patchlevel.dev/docs/worker/latest/getting-started#logging) of the worker lifecycle
1717
* Plays well with [Symfony and Laravel console commands](https://patchlevel.dev/docs/worker/latest/integration)

‎docs/events.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ each carrying the worker instance:
1111

1212
All [limits](getting-started.md#limits) are implemented as event subscribers
1313
(`StopWorkerOnIterationLimitListener`, `StopWorkerOnMemoryLimitListener`,
14-
`StopWorkerOnTimeLimitListener`, `StopWorkerOnSigtermSignalListener`),
14+
`StopWorkerOnTimeLimitListener`, `StopWorkerOnSignalListener`),
1515
so you can add your own stop conditions the same way:
1616

1717
```php

‎docs/getting-started.md‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ stopping always happens *between* iterations, so your job is never interrupted h
3232
## Limits
3333

3434
All options are optional. Without limits the worker runs until it is stopped
35-
via `$stop()`, `$worker->stop()` or a SIGTERM signal.
35+
via `$stop()`, `$worker->stop()` or a SIGTERM/SIGINT signal.
3636

3737
| Option | Type | Description |
3838
|---------------|----------|---------------------------------------------------------------------------------------------------------------------------------|
@@ -51,11 +51,11 @@ Internally every limit is implemented as an event listener.
5151
You can add your own stop conditions the same way, see [events & listeners](events.md).
5252
:::
5353

54-
## Graceful shutdown on SIGTERM
54+
## Graceful shutdown
5555

56-
If the `pcntl` extension is available, the worker automatically registers a SIGTERM handler.
57-
When the process receives SIGTERM (e.g. from `docker stop`, a Kubernetes pod shutdown or supervisor),
58-
the worker finishes the current iteration and then exits cleanly.
56+
If the `pcntl` extension is available, the worker automatically registers a handler for SIGTERM and SIGINT.
57+
When the process receives SIGTERM (e.g. from `docker stop`, a Kubernetes pod shutdown or supervisor)
58+
or SIGINT (e.g. pressing `Ctrl+C`), the worker finishes the current iteration and then exits cleanly.
5959

6060
This makes the worker a good fit for process managers that send SIGTERM and restart the process,
6161
e.g. to roll out a new version or to keep long-running processes fresh.
@@ -64,6 +64,9 @@ e.g. to roll out a new version or to keep long-running processes fresh.
6464
Without `ext-pcntl` this feature is not available.
6565
:::
6666

67+
If you need to react to other signals, register the `StopWorkerOnSignalListener` with your own list of signals
68+
on a custom event dispatcher, see [events & listeners](events.md).
69+
6770
## Sleep
6871

6972
`run()` takes a sleep timer in milliseconds (default: `1000`):
@@ -78,7 +81,7 @@ the next iteration starts immediately. Pass `0` to disable sleeping entirely.
7881
## Logging
7982

8083
The worker logs its lifecycle (start, iteration timings, sleep, stop reason) to the given PSR-3 logger.
81-
Iteration details use the `debug` level; stop reasons (limit exceeded, SIGTERM received) use `info`.
84+
Iteration details use the `debug` level; stop reasons (limit exceeded, signal received) use `info`.
8285

8386
With the `ConsoleLogger` from the [Symfony command example](integration.md#symfony), run the command with `-v`
8487
to see stop reasons or `-vvv` to see everything.

‎docs/introduction.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
# Worker
22

33
A small library to build stable, long-running workers that terminate gracefully when limits are exceeded
4-
or a SIGTERM signal is received. Perfect for daemonized console commands running under
4+
or a SIGTERM/SIGINT signal is received. Perfect for daemonized console commands running under
55
Docker, Kubernetes, supervisor or systemd, where the process manager restarts the worker after it exits.
66

77
It was extracted from the [event-sourcing](https://github.com/patchlevel/event-sourcing) library into a separate package.
88

99
## Features
1010

1111
* Configurable run, memory and time [limits](getting-started.md#limits)
12-
* [Graceful shutdown](getting-started.md#graceful-shutdown-on-sigterm) on SIGTERM
12+
* [Graceful shutdown](getting-started.md#graceful-shutdown) on SIGTERM and SIGINT
1313
* Extensible via [events and custom listeners](events.md)
1414
* [PSR-3 logging](getting-started.md#logging) of the worker lifecycle
1515
* Plays well with [Symfony and Laravel console commands](integration.md)

‎src/DefaultWorker.php‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
use Patchlevel\Worker\Event\WorkerStoppedEvent;
1111
use Patchlevel\Worker\Listener\StopWorkerOnIterationLimitListener;
1212
use Patchlevel\Worker\Listener\StopWorkerOnMemoryLimitListener;
13-
use Patchlevel\Worker\Listener\StopWorkerOnSigtermSignalListener;
13+
use Patchlevel\Worker\Listener\StopWorkerOnSignalListener;
1414
use Patchlevel\Worker\Listener\StopWorkerOnTimeLimitListener;
1515
use Psr\Log\LoggerInterface;
1616
use Psr\Log\NullLogger;
@@ -102,7 +102,7 @@ public static function create(
102102
$eventDispatcher = new EventDispatcher();
103103
}
104104

105-
$eventDispatcher->addSubscriber(new StopWorkerOnSigtermSignalListener($logger));
105+
$eventDispatcher->addSubscriber(new StopWorkerOnSignalListener(logger: $logger));
106106

107107
if (isset($options['runLimit'])) {
108108
$eventDispatcher->addSubscriber(
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\Worker\Listener;
6+
7+
use Patchlevel\Worker\Event\WorkerStartedEvent;
8+
use Psr\Log\LoggerInterface;
9+
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
10+
11+
use function function_exists;
12+
use function pcntl_async_signals;
13+
use function pcntl_signal;
14+
15+
use const SIGINT;
16+
use const SIGTERM;
17+
18+
final class StopWorkerOnSignalListener implements EventSubscriberInterface
19+
{
20+
/** @param list<int>|null $signals defaults to SIGTERM and SIGINT */
21+
public function __construct(
22+
private readonly array|null $signals = null,
23+
private readonly LoggerInterface|null $logger = null,
24+
) {
25+
}
26+
27+
public function onWorkerStarted(WorkerStartedEvent $event): void
28+
{
29+
pcntl_async_signals(true);
30+
31+
foreach ($this->signals ?? [SIGTERM, SIGINT] as $signal) {
32+
pcntl_signal($signal, function (int $signal) use ($event): void {
33+
$this->logger?->info('Worker received signal {signal}', ['signal' => $signal]);
34+
$event->worker->stop();
35+
});
36+
}
37+
}
38+
39+
/** @return array<class-string, string> */
40+
public static function getSubscribedEvents(): array
41+
{
42+
if (!function_exists('pcntl_signal')) {
43+
return [];
44+
}
45+
46+
return [WorkerStartedEvent::class => 'onWorkerStarted'];
47+
}
48+
}

‎src/Listener/StopWorkerOnSigtermSignalListener.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
use const SIGTERM;
1616

17+
/** @deprecated since 1.6, use StopWorkerOnSignalListener instead */
1718
final class StopWorkerOnSigtermSignalListener implements EventSubscriberInterface
1819
{
1920
public function __construct(

‎tests/Unit/DefaultWorkerTest.php‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
use Patchlevel\Worker\Event\WorkerStartedEvent;
1111
use Patchlevel\Worker\Listener\StopWorkerOnIterationLimitListener;
1212
use Patchlevel\Worker\Listener\StopWorkerOnMemoryLimitListener;
13-
use Patchlevel\Worker\Listener\StopWorkerOnSigtermSignalListener;
13+
use Patchlevel\Worker\Listener\StopWorkerOnSignalListener;
1414
use Patchlevel\Worker\Listener\StopWorkerOnTimeLimitListener;
1515
use Patchlevel\Worker\Tests\ReturnCallback;
1616
use PHPUnit\Framework\Attributes\CoversClass;
@@ -142,7 +142,7 @@ public function testOptions(): void
142142

143143
$invokationCount = $this->exactly(4);
144144
$invokationParameters = [
145-
[new StopWorkerOnSigtermSignalListener($logger)],
145+
[new StopWorkerOnSignalListener(logger: $logger)],
146146
[new StopWorkerOnIterationLimitListener(10, $logger)],
147147
[new StopWorkerOnMemoryLimitListener(Bytes::parseFromString('10KB'), $logger)],
148148
[new StopWorkerOnTimeLimitListener(20, $logger)],
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\Worker\Tests\Unit\Listener;
6+
7+
use Patchlevel\Worker\DefaultWorker;
8+
use Patchlevel\Worker\Listener\StopWorkerOnSignalListener;
9+
use PHPUnit\Framework\Attributes\CoversClass;
10+
use PHPUnit\Framework\Attributes\RequiresFunction;
11+
use PHPUnit\Framework\TestCase;
12+
use Psr\Log\LoggerInterface;
13+
use Symfony\Component\EventDispatcher\EventDispatcher;
14+
15+
use function getmypid;
16+
use function pcntl_signal;
17+
use function posix_kill;
18+
19+
use const SIG_DFL;
20+
use const SIGINT;
21+
use const SIGTERM;
22+
use const SIGUSR1;
23+
24+
#[CoversClass(StopWorkerOnSignalListener::class)]
25+
#[RequiresFunction('pcntl_signal')]
26+
#[RequiresFunction('posix_kill')]
27+
final class StopWorkerOnSignalListenerTest extends TestCase
28+
{
29+
protected function tearDown(): void
30+
{
31+
pcntl_signal(SIGTERM, SIG_DFL);
32+
pcntl_signal(SIGINT, SIG_DFL);
33+
pcntl_signal(SIGUSR1, SIG_DFL);
34+
}
35+
36+
public function testStopOnSigtermByDefault(): void
37+
{
38+
$logger = $this->createMock(LoggerInterface::class);
39+
$logger
40+
->expects($this->once())
41+
->method('info')
42+
->with('Worker received signal {signal}', ['signal' => SIGTERM]);
43+
44+
$calls = $this->runWorkerAndSendSignal(new StopWorkerOnSignalListener(logger: $logger), SIGTERM);
45+
46+
self::assertSame(1, $calls);
47+
}
48+
49+
public function testStopOnSigintByDefault(): void
50+
{
51+
$logger = $this->createMock(LoggerInterface::class);
52+
$logger
53+
->expects($this->once())
54+
->method('info')
55+
->with('Worker received signal {signal}', ['signal' => SIGINT]);
56+
57+
$calls = $this->runWorkerAndSendSignal(new StopWorkerOnSignalListener(logger: $logger), SIGINT);
58+
59+
self::assertSame(1, $calls);
60+
}
61+
62+
public function testStopOnCustomSignal(): void
63+
{
64+
$logger = $this->createMock(LoggerInterface::class);
65+
$logger
66+
->expects($this->once())
67+
->method('info')
68+
->with('Worker received signal {signal}', ['signal' => SIGUSR1]);
69+
70+
$calls = $this->runWorkerAndSendSignal(new StopWorkerOnSignalListener([SIGUSR1], $logger), SIGUSR1);
71+
72+
self::assertSame(1, $calls);
73+
}
74+
75+
/**
76+
* Sends the signal during the first job run. The job stops the worker
77+
* itself after 5 runs, so more than 1 run means the signal was ignored.
78+
*/
79+
private function runWorkerAndSendSignal(StopWorkerOnSignalListener $listener, int $signal): int
80+
{
81+
$eventDispatcher = new EventDispatcher();
82+
$eventDispatcher->addSubscriber($listener);
83+
84+
$calls = 0;
85+
86+
$worker = new DefaultWorker(
87+
static function (callable $stop) use (&$calls, $signal): void {
88+
$calls++;
89+
90+
if ($calls === 1) {
91+
posix_kill((int)getmypid(), $signal);
92+
}
93+
94+
if ($calls < 5) {
95+
return;
96+
}
97+
98+
$stop();
99+
},
100+
$eventDispatcher,
101+
);
102+
103+
$worker->run(0);
104+
105+
return $calls;
106+
}
107+
}

0 commit comments

Comments
 (0)