Skip to content

Commit ffa562d

Browse files
[13.x] schedule:work catch signals (#60616)
* Catch signals * formatting * Update ScheduleWorkCommand.php --------- Co-authored-by: Taylor Otwell <taylor@laravel.com>
1 parent 1c0c8fb commit ffa562d

2 files changed

Lines changed: 254 additions & 8 deletions

File tree

src/Illuminate/Console/Scheduling/ScheduleWorkCommand.php

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,24 @@ class ScheduleWorkCommand extends Command
2929
*/
3030
protected $description = 'Start the schedule worker';
3131

32+
/**
33+
* The "schedule:run" executions that are currently running.
34+
*
35+
* @var \Symfony\Component\Process\Process[]
36+
*/
37+
protected $executions = [];
38+
39+
/**
40+
* Indicates if the schedule worker should exit.
41+
*
42+
* @var bool
43+
*/
44+
protected $shouldQuit = false;
45+
3246
/**
3347
* Execute the console command.
3448
*
35-
* @return never
49+
* @return int
3650
*/
3751
public function handle()
3852
{
@@ -41,8 +55,6 @@ public function handle()
4155
$this->getLaravel()->environment('local') ? OutputInterface::VERBOSITY_NORMAL : OutputInterface::VERBOSITY_VERBOSE
4256
);
4357

44-
[$lastExecutionStartedAt, $executions] = [Carbon::now()->subMinutes(10), []];
45-
4658
$command = Application::formatCommandString('schedule:run');
4759

4860
if ($this->option('whisper')) {
@@ -53,28 +65,73 @@ public function handle()
5365
$command .= ' >> '.ProcessUtils::escapeArgument($this->option('run-output-file')).' 2>&1';
5466
}
5567

68+
$this->listenForSignals();
69+
70+
return $this->work($command);
71+
}
72+
73+
/**
74+
* Run the schedule worker loop until it is signalled to stop.
75+
*
76+
* @param string $command
77+
* @return int
78+
*/
79+
protected function work($command)
80+
{
81+
$lastExecutionStartedAt = Carbon::now()->subMinutes(10);
82+
5683
while (true) {
57-
usleep(100 * 1000);
84+
$this->sleep();
5885

59-
if (Carbon::now()->second === 0 &&
86+
// Once a stop signal has been received we stop scheduling new runs so
87+
// that the worker can stop any in-flight executions before exiting
88+
// which lets the current tasks execute instead of being stopped.
89+
if (! $this->shouldQuit &&
90+
Carbon::now()->second === 0 &&
6091
! Carbon::now()->startOfMinute()->equalTo($lastExecutionStartedAt)) {
61-
$executions[] = $execution = Process::fromShellCommandline($command, base_path());
92+
$this->executions[] = $execution = Process::fromShellCommandline($command, base_path());
6293

6394
$execution->start();
6495

6596
$lastExecutionStartedAt = Carbon::now()->startOfMinute();
6697
}
6798

68-
foreach ($executions as $key => $execution) {
99+
foreach ($this->executions as $key => $execution) {
69100
$output = $execution->getIncrementalOutput().
70101
$execution->getIncrementalErrorOutput();
71102

72103
$this->output->write(ltrim($output, "\n"));
73104

74105
if (! $execution->isRunning()) {
75-
unset($executions[$key]);
106+
unset($this->executions[$key]);
76107
}
77108
}
109+
110+
if ($this->shouldQuit && empty($this->executions)) {
111+
return static::SUCCESS;
112+
}
78113
}
79114
}
115+
116+
/**
117+
* Listen for the signals that should terminate the schedule worker.
118+
*
119+
* @return void
120+
*/
121+
protected function listenForSignals()
122+
{
123+
$this->trap(fn () => [SIGINT, SIGTERM, SIGQUIT], function () {
124+
$this->shouldQuit = true;
125+
});
126+
}
127+
128+
/**
129+
* Sleep for a short period before the next worker tick.
130+
*
131+
* @return void
132+
*/
133+
protected function sleep()
134+
{
135+
usleep(100 * 1000);
136+
}
80137
}
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
<?php
2+
3+
namespace Illuminate\Tests\Console\Scheduling;
4+
5+
use Illuminate\Console\OutputStyle;
6+
use Illuminate\Console\Scheduling\ScheduleWorkCommand;
7+
use Illuminate\Console\Signals;
8+
use Illuminate\Support\Carbon;
9+
use Illuminate\Tests\Console\Fixtures\FakeSignalsRegistry;
10+
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
11+
use Mockery as m;
12+
use PHPUnit\Framework\TestCase;
13+
use ReflectionProperty;
14+
use Symfony\Component\Console\Input\ArrayInput;
15+
use Symfony\Component\Console\Output\BufferedOutput;
16+
use Symfony\Component\Process\Process;
17+
18+
class ScheduleWorkCommandTest extends TestCase
19+
{
20+
use MockeryPHPUnitIntegration;
21+
22+
/**
23+
* The signal availability resolver in place before the test ran.
24+
*
25+
* @var (callable(): bool)|null
26+
*/
27+
protected $originalAvailabilityResolver;
28+
29+
protected function setUp(): void
30+
{
31+
parent::setUp();
32+
33+
$this->originalAvailabilityResolver = (new ReflectionProperty(Signals::class, 'availabilityResolver'))
34+
->getValue();
35+
36+
Carbon::setTestNow(Carbon::create(2026, 6, 27, 12, 0, 30));
37+
}
38+
39+
protected function tearDown(): void
40+
{
41+
Carbon::setTestNow();
42+
43+
Signals::resolveAvailabilityUsing($this->originalAvailabilityResolver);
44+
45+
parent::tearDown();
46+
}
47+
48+
public function test_stop_signal_marks_the_worker_to_quit()
49+
{
50+
if (! extension_loaded('pcntl')) {
51+
$this->markTestSkipped('The pcntl extension is required to trap signals.');
52+
}
53+
54+
// When a handler is registered, Signals chains any handler already bound
55+
// to the signal (see Signals::initializeSignal). Other tests may leave
56+
// real handlers in place, so reset these signals to their default
57+
// disposition to keep this test isolated from the global signal state.
58+
$previousHandlers = [];
59+
60+
foreach ([SIGINT, SIGTERM, SIGQUIT] as $signal) {
61+
$previousHandlers[$signal] = pcntl_signal_get_handler($signal);
62+
63+
pcntl_signal($signal, SIG_DFL);
64+
}
65+
66+
try {
67+
Signals::resolveAvailabilityUsing(fn () => true);
68+
69+
$registry = new FakeSignalsRegistry;
70+
71+
$command = new ScheduleWorkCommandTestStub;
72+
73+
// Wire the command up to a fake signal registry so we can simulate a
74+
// signal being delivered without sending a real one to the process.
75+
(fn () => $this->signals = new Signals($registry))->call($command);
76+
77+
$command->callListenForSignals();
78+
79+
$this->assertFalse($command->shouldQuit(), 'The worker should not quit before a signal is received.');
80+
81+
$registry->handle(SIGTERM);
82+
83+
$this->assertTrue($command->shouldQuit(), 'The worker should be marked to quit after a SIGTERM.');
84+
} finally {
85+
foreach ($previousHandlers as $signal => $handler) {
86+
pcntl_signal($signal, $handler ?: SIG_DFL);
87+
}
88+
}
89+
}
90+
91+
public function test_in_flight_executions_finish_before_the_worker_quits()
92+
{
93+
$execution = m::mock(Process::class);
94+
$execution->shouldReceive('getIncrementalOutput')->andReturn('scheduled task ran', '');
95+
$execution->shouldReceive('getIncrementalErrorOutput')->andReturn('');
96+
97+
// The worker should poll the running execution after the signal arrives,
98+
// wait for it to report finished, and flush its output before quitting.
99+
$execution->shouldReceive('isRunning')->twice()->andReturn(true, false);
100+
101+
$command = new ScheduleWorkCommandTestStub;
102+
$command->setOutput(new OutputStyle(new ArrayInput([]), $buffer = new BufferedOutput));
103+
104+
// Simulate the stop signal arriving while a "schedule:run" execution is
105+
// still in progress (after the worker's very first loop tick).
106+
$command->onTick = function (ScheduleWorkCommandTestStub $command) {
107+
if ($command->ticks === 1) {
108+
$command->markShouldQuit();
109+
}
110+
};
111+
112+
$status = $command->work('schedule:run', [$execution]);
113+
114+
$this->assertSame(ScheduleWorkCommand::SUCCESS, $status);
115+
$this->assertSame([], $command->remainingExecutions(), 'The execution should have been drained before quitting.');
116+
$this->assertStringContainsString('scheduled task ran', $buffer->fetch());
117+
}
118+
119+
public function test_no_new_executions_are_started_after_a_stop_signal()
120+
{
121+
// A new run would normally be started on the zero second of the minute.
122+
Carbon::setTestNow(Carbon::create(2026, 6, 27, 12, 0, 0));
123+
124+
$command = new ScheduleWorkCommandTestStub;
125+
$command->markShouldQuit();
126+
127+
// With no executions in flight and a stop signal already received, the
128+
// worker must exit immediately without starting a new "schedule:run".
129+
// If the guard regressed, it would try to spawn a real process here.
130+
$status = $command->work('schedule:run');
131+
132+
$this->assertSame(ScheduleWorkCommand::SUCCESS, $status);
133+
$this->assertSame([], $command->remainingExecutions());
134+
$this->assertSame(1, $command->ticks, 'The worker should exit after a single tick when idle and quitting.');
135+
}
136+
}
137+
138+
class ScheduleWorkCommandTestStub extends ScheduleWorkCommand
139+
{
140+
/**
141+
* The number of times the worker has ticked (slept).
142+
*
143+
* @var int
144+
*/
145+
public $ticks = 0;
146+
147+
/**
148+
* A callback invoked on every worker tick, used to drive the loop in tests.
149+
*
150+
* @var (callable(self): void)|null
151+
*/
152+
public $onTick;
153+
154+
public function callListenForSignals(): void
155+
{
156+
$this->listenForSignals();
157+
}
158+
159+
public function shouldQuit(): bool
160+
{
161+
return $this->shouldQuit;
162+
}
163+
164+
public function markShouldQuit(): void
165+
{
166+
$this->shouldQuit = true;
167+
}
168+
169+
public function work($command, array $executions = [])
170+
{
171+
$this->executions = $executions;
172+
173+
return parent::work($command);
174+
}
175+
176+
public function remainingExecutions(): array
177+
{
178+
return $this->executions;
179+
}
180+
181+
protected function sleep()
182+
{
183+
$this->ticks++;
184+
185+
if ($this->onTick) {
186+
($this->onTick)($this);
187+
}
188+
}
189+
}

0 commit comments

Comments
 (0)