Skip to content

Commit b539903

Browse files
nunomaduroStyleCIBottaylorotwell
authored
[9.x] Introducing Signal Traps 🚦 (#43933)
* Adds signal traps * Apply fixes from StyleCI * Improves code logic * Improves return type * Don't run traps outside of console or in unit tests * Updates description * Adds tests * Removes non needed annotation * Improves test * Avoids missing constant on Windows * Improves exit "as default" behaviour * Adjusts docs * Only use signals if `posix` and `pcntl` are available * Fixes missing parentheses * Apply fixes from StyleCI * No longers kill the process * Unsets null values * Adjusts PHP annotations * move to service provider * move method * Apply fixes from StyleCI * Update InteractsWithSignals.php * Update Signals.php * Update Signals.php Co-authored-by: StyleCI Bot <[email protected]> Co-authored-by: Taylor Otwell <[email protected]>
1 parent 7f46d58 commit b539903

File tree

7 files changed

+432
-3
lines changed

7 files changed

+432
-3
lines changed

Diff for: ‎src/Illuminate/Console/Command.php

+8-3
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class Command extends SymfonyCommand
1313
use Concerns\CallsCommands,
1414
Concerns\HasParameters,
1515
Concerns\InteractsWithIO,
16+
Concerns\InteractsWithSignals,
1617
Macroable;
1718

1819
/**
@@ -120,9 +121,13 @@ public function run(InputInterface $input, OutputInterface $output): int
120121

121122
$this->components = $this->laravel->make(Factory::class, ['output' => $this->output]);
122123

123-
return parent::run(
124-
$this->input = $input, $this->output
125-
);
124+
try {
125+
return parent::run(
126+
$this->input = $input, $this->output
127+
);
128+
} finally {
129+
$this->untrap();
130+
}
126131
}
127132

128133
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
namespace Illuminate\Console\Concerns;
4+
5+
use Illuminate\Console\Signals;
6+
use Illuminate\Support\Arr;
7+
8+
trait InteractsWithSignals
9+
{
10+
/**
11+
* The signal registrar instance.
12+
*
13+
* @var \Illuminate\Console\Signals|null
14+
*/
15+
protected $signals;
16+
17+
/**
18+
* Define a callback to be run when the given signal(s) occurs.
19+
*
20+
* @param iterable<array-key, int>|int $signals
21+
* @param callable(int $signal): void $callback
22+
* @return void
23+
*/
24+
public function trap($signals, $callback)
25+
{
26+
Signals::whenAvailable(function () use ($signals, $callback) {
27+
$this->signals ??= new Signals(
28+
$this->getApplication()->getSignalRegistry(),
29+
);
30+
31+
collect(Arr::wrap($signals))
32+
->each(fn ($signal) => $this->signals->register($signal, $callback));
33+
});
34+
}
35+
36+
/**
37+
* Untrap signal handlers set within the command's handler.
38+
*
39+
* @return void
40+
*
41+
* @internal
42+
*/
43+
public function untrap()
44+
{
45+
if (! is_null($this->signals)) {
46+
$this->signals->unregister();
47+
48+
$this->signals = null;
49+
}
50+
}
51+
}

Diff for: ‎src/Illuminate/Console/Signals.php

+152
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
<?php
2+
3+
namespace Illuminate\Console;
4+
5+
/**
6+
* @internal
7+
*/
8+
class Signals
9+
{
10+
/**
11+
* The signal registry instance.
12+
*
13+
* @var \Symfony\Component\Console\SignalRegistry\SignalRegistry
14+
*/
15+
protected $registry;
16+
17+
/**
18+
* The signal registry's previous list of handlers.
19+
*
20+
* @param array<int, array<int, callable>>|null
21+
*/
22+
protected $previousHandlers;
23+
24+
/**
25+
* The current availability resolver, if any.
26+
*
27+
* @param (callable(): bool)|null
28+
*/
29+
protected static $availabilityResolver;
30+
31+
/**
32+
* Create a new signal registrar instance.
33+
*
34+
* @param \Symfony\Component\Console\SignalRegistry\SignalRegistry $registry
35+
* @return void
36+
*/
37+
public function __construct($registry)
38+
{
39+
$this->registry = $registry;
40+
41+
$this->previousHandlers = $this->getHandlers();
42+
}
43+
44+
/**
45+
* Register a new signal handler.
46+
*
47+
* @param int $signal
48+
* @param callable(int $signal): void $callback
49+
* @return void
50+
*/
51+
public function register($signal, $callback)
52+
{
53+
$this->previousHandlers[$signal] ??= $this->initializeSignal($signal);
54+
55+
with($this->getHandlers(), function ($handlers) use ($signal) {
56+
$handlers[$signal] ??= $this->initializeSignal($signal);
57+
58+
$this->setHandlers($handlers);
59+
});
60+
61+
$this->registry->register($signal, $callback);
62+
63+
with($this->getHandlers(), function ($handlers) use ($signal) {
64+
$lastHandlerInserted = array_pop($handlers[$signal]);
65+
66+
array_unshift($handlers[$signal], $lastHandlerInserted);
67+
68+
$this->setHandlers($handlers);
69+
});
70+
}
71+
72+
/**
73+
* Gets the signal's existing handler in array format.
74+
*
75+
* @return array<int, callable(int $signal): void>
76+
*/
77+
protected function initializeSignal($signal)
78+
{
79+
return is_callable($existingHandler = pcntl_signal_get_handler($signal))
80+
? [$existingHandler]
81+
: null;
82+
}
83+
84+
/**
85+
* Unregister the current signal handlers.
86+
*
87+
* @return array<int, array<int, callable(int $signal): void>>
88+
*/
89+
public function unregister()
90+
{
91+
$previousHandlers = $this->previousHandlers;
92+
93+
foreach ($previousHandlers as $signal => $handler) {
94+
if (is_null($handler)) {
95+
pcntl_signal($signal, SIG_DFL);
96+
97+
unset($previousHandlers[$signal]);
98+
}
99+
}
100+
101+
$this->setHandlers($previousHandlers);
102+
}
103+
104+
/**
105+
* Execute the given callback if "signals" should be used and are available.
106+
*
107+
* @param callable $callback
108+
* @return void
109+
*/
110+
public static function whenAvailable($callback)
111+
{
112+
$resolver = static::$availabilityResolver;
113+
114+
if ($resolver()) {
115+
$callback();
116+
}
117+
}
118+
119+
/**
120+
* Get the registry's handlers.
121+
*
122+
* @return array<int, array<int, callable>>
123+
*/
124+
protected function getHandlers()
125+
{
126+
return (fn () => $this->signalHandlers)
127+
->call($this->registry);
128+
}
129+
130+
/**
131+
* Set the registry's handlers.
132+
*
133+
* @param array<int, array<int, callable(int $signal):void>> $handlers
134+
* @return void
135+
*/
136+
protected function setHandlers($handlers)
137+
{
138+
(fn () => $this->signalHandlers = $handlers)
139+
->call($this->registry);
140+
}
141+
142+
/**
143+
* Set the availability resolver.
144+
*
145+
* @param callable(): bool
146+
* @return void
147+
*/
148+
public static function resolveAvailabilityUsing($resolver)
149+
{
150+
static::$availabilityResolver = $resolver;
151+
}
152+
}

Diff for: ‎src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php

+7
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use Illuminate\Console\Scheduling\ScheduleRunCommand;
1313
use Illuminate\Console\Scheduling\ScheduleTestCommand;
1414
use Illuminate\Console\Scheduling\ScheduleWorkCommand;
15+
use Illuminate\Console\Signals;
1516
use Illuminate\Contracts\Support\DeferrableProvider;
1617
use Illuminate\Database\Console\DbCommand;
1718
use Illuminate\Database\Console\DumpCommand;
@@ -202,6 +203,12 @@ public function register()
202203
$this->commands,
203204
$this->devCommands
204205
));
206+
207+
Signals::resolveAvailabilityUsing(function () {
208+
return $this->app->runningInConsole()
209+
&& ! $this->app->runningUnitTests()
210+
&& extension_loaded('pcntl');
211+
});
205212
}
206213

207214
/**

Diff for: ‎tests/Console/CommandTrapTest.php

+127
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
<?php
2+
3+
namespace Illuminate\Tests\Console;
4+
5+
use Illuminate\Console\Command;
6+
use Illuminate\Console\Signals;
7+
use Illuminate\Tests\Console\Fixtures\FakeSignalsRegistry;
8+
use Mockery as m;
9+
use PHPUnit\Framework\TestCase;
10+
11+
class CommandTrapTest extends TestCase
12+
{
13+
protected $registry;
14+
15+
protected $signals;
16+
17+
protected $state;
18+
19+
protected function setUp(): void
20+
{
21+
Signals::resolveAvailabilityUsing(fn () => true);
22+
23+
$this->registry = new FakeSignalsRegistry();
24+
$this->state = null;
25+
}
26+
27+
protected function tearDown(): void
28+
{
29+
m::close();
30+
}
31+
32+
public function testTrapWhenAvailable()
33+
{
34+
$command = $this->createCommand();
35+
36+
$command->trap('my-signal', function () {
37+
$this->state = 'taylorotwell';
38+
});
39+
40+
$this->registry->handle('my-signal');
41+
42+
$this->assertSame('taylorotwell', $this->state);
43+
}
44+
45+
public function testTrapWhenNotAvailable()
46+
{
47+
Signals::resolveAvailabilityUsing(fn () => false);
48+
49+
$command = $this->createCommand();
50+
51+
$command->trap('my-signal', function () {
52+
$this->state = 'taylorotwell';
53+
});
54+
55+
$this->registry->handle('my-signal');
56+
57+
$this->assertNull($this->state);
58+
}
59+
60+
public function testUntrap()
61+
{
62+
$command = $this->createCommand();
63+
64+
$command->trap('my-signal', function () {
65+
$this->state = 'taylorotwell';
66+
});
67+
68+
$command->untrap();
69+
70+
$this->registry->handle('my-signal');
71+
72+
$this->assertNull($this->state);
73+
}
74+
75+
public function testNestedTraps()
76+
{
77+
$a = $this->createCommand();
78+
$a->trap('my-signal', fn () => $this->state .= '1');
79+
80+
$b = $this->createCommand();
81+
$b->trap('my-signal', fn () => $this->state .= '2');
82+
83+
$c = $this->createCommand();
84+
$c->trap('my-signal', fn () => $this->state .= '3');
85+
86+
$this->state = '';
87+
$this->registry->handle('my-signal');
88+
$this->assertSame('321', $this->state);
89+
90+
$c->untrap();
91+
$this->state = '';
92+
$this->registry->handle('my-signal');
93+
$this->assertSame('21', $this->state);
94+
95+
$d = $this->createCommand();
96+
$d->trap('my-signal', fn () => $this->state .= '3');
97+
98+
$this->state = '';
99+
$this->registry->handle('my-signal');
100+
$this->assertSame('321', $this->state);
101+
102+
$d->untrap();
103+
$this->state = '';
104+
$this->registry->handle('my-signal');
105+
$this->assertSame('21', $this->state);
106+
107+
$b->untrap();
108+
$this->state = '';
109+
$this->registry->handle('my-signal');
110+
$this->assertSame('1', $this->state);
111+
112+
$a->untrap();
113+
$this->state = '';
114+
$this->registry->handle('my-signal');
115+
$this->assertSame('', $this->state);
116+
}
117+
118+
protected function createCommand()
119+
{
120+
$command = new Command;
121+
$registry = $this->registry;
122+
123+
(fn () => $this->signals = new Signals($registry))->call($command);
124+
125+
return $command;
126+
}
127+
}

0 commit comments

Comments
 (0)