diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c9589ee..58b0792e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,3 +3,4 @@ ## 1.0.0 under development - Initial release. +- Bug #347: `SignalLoop` installs signal handlers only while messages are consumed and restores the previous ones afterwards, instead of replacing them when it is created (@samdark) diff --git a/docs/guide/en/loops.md b/docs/guide/en/loops.md index c7700b23..0e925c89 100644 --- a/docs/guide/en/loops.md +++ b/docs/guide/en/loops.md @@ -27,6 +27,19 @@ It supports: - Pause/resume via `SIGTSTP` and `SIGCONT`. - Optional soft memory limit (see below). +To stop a worker gracefully, send `SIGTERM` to its process, for example: + +```shell +kill -TERM +``` + +The worker finishes the current message and leaves the remaining messages in the queue for the next worker. +Use `SIGTSTP` to pause processing and `SIGCONT` to resume it. + +You can create or resolve `SignalLoop` in another console command without changing that command's signal handling. +Existing application handlers are restored when queue processing ends, including when processing fails. +While a queue worker is running, its graceful-shutdown behavior takes precedence over application handlers. + ### `SimpleLoop` `\Yiisoft\Queue\Cli\SimpleLoop` is used by default when `ext-pcntl` is **not** available. @@ -91,6 +104,31 @@ use Yiisoft\Queue\Cli\SignalLoop; $loop = new SignalLoop(memorySoftLimit: 256 * 1024 * 1024); ``` +When creating `RunCommand` or `ListenAllCommand` manually, pass the same loop instance to the command and its consumers. +This keeps graceful shutdown active between queues. For example, with your configured consumer provider: + +```php +use Yiisoft\Queue\Command\RunCommand; + +$command = new RunCommand($consumerProvider, $loop); +``` + +For your own execution loop, use `start()` and `finish()` to restore application signal handlers when work ends: + +```php +$loop->start(); +try { + while ($loop->canContinue()) { + // Process one unit of work. + } +} finally { + $loop->finish(); +} +``` + +If you call `canContinue()` without `start()`, the loop keeps its signal handlers for the rest of the process. +Use the paired calls above when other application code needs to handle signals after your work ends. + ## Writing a custom loop Implement `LoopInterface` and encapsulate your own stopping conditions: @@ -102,3 +140,8 @@ Implement `LoopInterface` and encapsulate your own stopping conditions: The only requirement is that `canContinue()` returns `false` when the worker should stop. +Implement `ScopedLoopInterface` when your custom loop needs setup and cleanup around message processing. +Put setup in `start()` and cleanup in `finish()`; consumers and commands call these methods for you, +even if processing throws. Support nested calls when consumers share a loop with a command: keep the loop active +until the matching outermost `finish()`. + diff --git a/src/Cli/ScopedLoopInterface.php b/src/Cli/ScopedLoopInterface.php new file mode 100644 index 00000000..53985eb4 --- /dev/null +++ b/src/Cli/ScopedLoopInterface.php @@ -0,0 +1,25 @@ +|null Handlers that were active before ours were installed, by signal. `null` if our + * handlers are not installed. + */ + private ?array $previousHandlers = null; + private int $depth = 0; + private bool $installedByStart = false; + /** * @param int $memorySoftLimit Soft RAM limit in bytes. The loop won't let you continue to execute the program if * soft limit is reached. Zero means no limit. */ - public function __construct(protected int $memorySoftLimit = 0) + public function __construct(protected int $memorySoftLimit = 0) {} + + public function start(): void { - foreach (self::SIGNALS_EXIT as $signal) { - pcntl_signal($signal, fn() => $this->exit = true); + $this->depth++; + + if ($this->depth === 1 && $this->previousHandlers === null) { + $this->installHandlers(); + $this->installedByStart = true; } - foreach (self::SIGNALS_SUSPEND as $signal) { - pcntl_signal($signal, fn() => $this->pause = true); + } + + public function finish(): void + { + if ($this->depth === 0) { + return; } - foreach (self::SIGNALS_RESUME as $signal) { - pcntl_signal($signal, fn() => $this->pause = false); + + $this->depth--; + + if ($this->depth === 0 && $this->installedByStart) { + $this->restoreHandlers(); } } @@ -53,6 +81,10 @@ public function canContinue(): bool protected function dispatchSignals(): bool { + if ($this->previousHandlers === null) { + $this->installHandlers(); + } + pcntl_signal_dispatch(); // Wait for resume signal until the loop is suspended @@ -68,4 +100,44 @@ protected function getMemoryLimit(): int { return $this->memorySoftLimit; } + + private function installHandlers(): void + { + $handlers = []; + foreach (self::SIGNALS_EXIT as $signal) { + $handlers[$signal] = function (): void { + $this->exit = true; + }; + } + foreach (self::SIGNALS_SUSPEND as $signal) { + $handlers[$signal] = function (): void { + $this->pause = true; + }; + } + foreach (self::SIGNALS_RESUME as $signal) { + $handlers[$signal] = function (): void { + $this->pause = false; + }; + } + + $this->previousHandlers = []; + foreach ($handlers as $signal => $handler) { + $this->previousHandlers[$signal] = pcntl_signal_get_handler($signal); + pcntl_signal($signal, $handler); + } + } + + private function restoreHandlers(): void + { + // Deliver signals received so far to our handlers, so that they are not lost. + pcntl_signal_dispatch(); + + foreach ($this->previousHandlers ?? [] as $signal => $handler) { + /** @psalm-var callable():void|int $handler */ + pcntl_signal($signal, $handler); + } + + $this->previousHandlers = null; + $this->installedByStart = false; + } } diff --git a/src/Command/ListenAllCommand.php b/src/Command/ListenAllCommand.php index 6735ae09..f3bdde4d 100644 --- a/src/Command/ListenAllCommand.php +++ b/src/Command/ListenAllCommand.php @@ -11,6 +11,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Yiisoft\Queue\Cli\LoopInterface; +use Yiisoft\Queue\Cli\ScopedLoopInterface; use Yiisoft\Queue\Provider\QueueConsumerProviderInterface; #[AsCommand( @@ -86,15 +87,25 @@ protected function execute(InputInterface $input, OutputInterface $output): int $limit = (int) $input->getOption('limit'); - while ($this->loop->canContinue()) { - $hasMessages = false; - foreach ($consumers as $consumer) { - $hasMessages = $consumer->run($limit) > 0 || $hasMessages; - } + if ($this->loop instanceof ScopedLoopInterface) { + $this->loop->start(); + } - if (!$hasMessages && $this->loop->canContinue()) { - /** @psalm-var 0|positive-int $pauseSeconds */ - sleep($pauseSeconds); + try { + while ($this->loop->canContinue()) { + $hasMessages = false; + foreach ($consumers as $consumer) { + $hasMessages = $consumer->run($limit) > 0 || $hasMessages; + } + + if (!$hasMessages && $this->loop->canContinue()) { + /** @psalm-var 0|positive-int $pauseSeconds */ + sleep($pauseSeconds); + } + } + } finally { + if ($this->loop instanceof ScopedLoopInterface) { + $this->loop->finish(); } } diff --git a/src/Command/RunCommand.php b/src/Command/RunCommand.php index 648eefed..b57d676d 100644 --- a/src/Command/RunCommand.php +++ b/src/Command/RunCommand.php @@ -10,6 +10,8 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Yiisoft\Queue\Cli\LoopInterface; +use Yiisoft\Queue\Cli\ScopedLoopInterface; use Yiisoft\Queue\Provider\QueueConsumerProviderInterface; #[AsCommand( @@ -20,6 +22,7 @@ final class RunCommand extends Command { public function __construct( private readonly QueueConsumerProviderInterface $queueProvider, + private readonly ?LoopInterface $loop = null, ) { parent::__construct(); } @@ -50,14 +53,27 @@ protected function execute(InputInterface $input, OutputInterface $output): int $queueNames = $this->queueProvider->getConsumerQueueNames(); } - /** @var string $queueName */ - foreach ($queueNames as $queueName) { - $queueConsumer = $this->queueProvider->getConsumer($queueName); + $limit = (int) $input->getOption('limit'); - $output->write("Processing queue $queueName... "); - $count = $queueConsumer->run((int) $input->getOption('limit')); + // One scope for all queues, so that a signal received between two of them doesn't reach the default handler. + if ($this->loop instanceof ScopedLoopInterface) { + $this->loop->start(); + } + + try { + /** @var string $queueName */ + foreach ($queueNames as $queueName) { + $queueConsumer = $this->queueProvider->getConsumer($queueName); + + $output->write("Processing queue $queueName... "); + $count = $queueConsumer->run($limit); - $output->writeln("Messages processed: $count."); + $output->writeln("Messages processed: $count."); + } + } finally { + if ($this->loop instanceof ScopedLoopInterface) { + $this->loop->finish(); + } } return Command::SUCCESS; diff --git a/src/QueueConsumer.php b/src/QueueConsumer.php index c16d1d97..36bc944d 100644 --- a/src/QueueConsumer.php +++ b/src/QueueConsumer.php @@ -8,6 +8,7 @@ use Psr\Log\LoggerInterface; use Yiisoft\Queue\Adapter\AdapterInterface; use Yiisoft\Queue\Cli\LoopInterface; +use Yiisoft\Queue\Cli\ScopedLoopInterface; use Yiisoft\Queue\Message\MessageInterface; use Yiisoft\Queue\Worker\WorkerInterface; @@ -34,13 +35,18 @@ public function run(int $max = 0): int } $this->logger->debug('Start processing queue messages.'); $count = 0; - $this->adapter->runExisting(function (MessageInterface $message) use (&$count, $max): bool { - if (($max > 0 && $count >= $max) || !$this->handle($message)) { - return false; - } - $count++; - return true; - }); + $this->startLoop(); + try { + $this->adapter->runExisting(function (MessageInterface $message) use (&$count, $max): bool { + if (($max > 0 && $count >= $max) || !$this->handle($message)) { + return false; + } + $count++; + return true; + }); + } finally { + $this->finishLoop(); + } $this->logger->info('Processed {count} queue messages.', ['count' => $count]); return $count; } @@ -52,10 +58,29 @@ public function listen(): void return; } $this->logger->info('Start listening to the queue.'); - $this->adapter->subscribe(fn(MessageInterface $message): bool => $this->handle($message)); + $this->startLoop(); + try { + $this->adapter->subscribe(fn(MessageInterface $message): bool => $this->handle($message)); + } finally { + $this->finishLoop(); + } $this->logger->info('Finish listening to the queue.'); } + private function startLoop(): void + { + if ($this->loop instanceof ScopedLoopInterface) { + $this->loop->start(); + } + } + + private function finishLoop(): void + { + if ($this->loop instanceof ScopedLoopInterface) { + $this->loop->finish(); + } + } + private function handle(MessageInterface $message): bool { if (!$this->loop->canContinue()) { diff --git a/tests/Unit/Cli/SignalLoopTest.php b/tests/Unit/Cli/SignalLoopTest.php index ebf06971..052e16d1 100644 --- a/tests/Unit/Cli/SignalLoopTest.php +++ b/tests/Unit/Cli/SignalLoopTest.php @@ -9,12 +9,15 @@ use PHPUnit\Framework\Attributes\RequiresPhpExtension; use Yiisoft\Queue\Cli\SignalLoop; +use function array_map; + use const SIGALRM; use const SIGCONT; use const SIGHUP; use const SIGINT; use const SIGTERM; use const SIGTSTP; +use const SIG_DFL; /** * @psalm-suppress UndefinedConstant @@ -23,12 +26,132 @@ #[RequiresPhpExtension('pcntl')] final class SignalLoopTest extends TestCase { + private const SIGNALS = [SIGHUP, SIGINT, SIGTERM, SIGTSTP, SIGCONT]; + + /** + * @var SignalLoop[] + */ + private array $startedLoops = []; + public function setUp(): void { parent::setUp(); pcntl_async_signals(true); } + protected function tearDown(): void + { + foreach ($this->startedLoops as $loop) { + $loop->finish(); + } + $this->startedLoops = []; + foreach (self::SIGNALS as $signal) { + pcntl_signal($signal, SIG_DFL); + } + + parent::tearDown(); + } + + public function testConstructionKeepsExistingHandlers(): void + { + $handler = static function (): void {}; + foreach (self::SIGNALS as $signal) { + pcntl_signal($signal, $handler); + } + + new SignalLoop(); + + foreach (self::SIGNALS as $signal) { + self::assertSame($handler, pcntl_signal_get_handler($signal), "Handler of signal {$signal} was replaced."); + } + } + + public function testFinishRestoresHandlersReplacedByStart(): void + { + $handler = static function (): void {}; + foreach (self::SIGNALS as $signal) { + pcntl_signal($signal, $handler); + } + $loop = new SignalLoop(); + + $loop->start(); + $replaced = array_map(pcntl_signal_get_handler(...), self::SIGNALS); + $loop->finish(); + + foreach ($replaced as $signalHandler) { + self::assertNotSame($handler, $signalHandler); + } + foreach (self::SIGNALS as $signal) { + self::assertSame($handler, pcntl_signal_get_handler($signal), "Handler of signal {$signal} was not restored."); + } + } + + public function testNestedStartRestoresHandlersOnOutermostFinish(): void + { + $handler = static function (): void {}; + pcntl_signal(SIGTERM, $handler); + $loop = new SignalLoop(); + + $loop->start(); + $loop->start(); + $loop->finish(); + $afterInnerFinish = pcntl_signal_get_handler(SIGTERM); + $loop->finish(); + + self::assertNotSame($handler, $afterInnerFinish); + self::assertSame($handler, pcntl_signal_get_handler(SIGTERM)); + } + + public function testExtraFinishDoesNothing(): void + { + $handler = static function (): void {}; + pcntl_signal(SIGTERM, $handler); + $loop = new SignalLoop(); + + $loop->finish(); + + self::assertSame($handler, pcntl_signal_get_handler(SIGTERM)); + } + + public function testExitSignalReceivedWhileStartedIsKeptAfterFinish(): void + { + $loop = new SignalLoop(); + $loop->start(); + posix_kill(getmypid(), SIGTERM); + $loop->finish(); + + $loop->start(); + $result = $loop->canContinue(); + $loop->finish(); + + self::assertFalse($result); + } + + public function testCanContinueWithoutStartInstallsHandlers(): void + { + $loop = new SignalLoop(); + + self::assertTrue($loop->canContinue()); + posix_kill(getmypid(), SIGTERM); + + self::assertFalse($loop->canContinue()); + } + + public function testFinishKeepsHandlersInstalledByCanContinueBeforeStart(): void + { + $handler = static function (): void {}; + pcntl_signal(SIGTERM, $handler); + $loop = new SignalLoop(); + $loop->canContinue(); + $installed = pcntl_signal_get_handler(SIGTERM); + + $loop->start(); + $loop->finish(); + + self::assertNotSame($handler, $installed); + self::assertSame($installed, pcntl_signal_get_handler(SIGTERM)); + } + public function testMemoryLimitReached(): void { $loop = new SignalLoop(1); @@ -37,7 +160,7 @@ public function testMemoryLimitReached(): void public function testSuspendAndResume(): void { - $loop = new SignalLoop(0); + $loop = $this->createStartedLoop(); pcntl_signal(SIGALRM, static function (): void { posix_kill(getmypid(), SIGCONT); }); @@ -56,7 +179,7 @@ public function testSuspendAndResume(): void #[DataProvider('exitSignalProvider')] public function testExitSignals(int $signal): void { - $loop = new SignalLoop(0); + $loop = $this->createStartedLoop(); self::assertTrue($loop->canContinue(), 'Loop should continue'); posix_kill(getmypid(), $signal); @@ -73,7 +196,7 @@ public static function exitSignalProvider(): iterable public function testResumeSignal(): void { - $loop = new SignalLoop(0); + $loop = $this->createStartedLoop(); // First suspend the loop posix_kill(getmypid(), SIGTSTP); @@ -91,7 +214,7 @@ public function testResumeSignal(): void public function testMultipleExitSignals(): void { - $loop = new SignalLoop(0); + $loop = $this->createStartedLoop(); // Send multiple exit signals posix_kill(getmypid(), SIGINT); @@ -104,7 +227,7 @@ public function testMultipleExitSignals(): void public function testSuspendOverridesResume(): void { - $loop = new SignalLoop(0); + $loop = $this->createStartedLoop(); // Resume first posix_kill(getmypid(), SIGCONT); @@ -124,4 +247,13 @@ public function testSuspendOverridesResume(): void self::assertTrue($result); self::assertGreaterThan(0.5, $elapsed, 'Loop should wait for resume after suspend'); } + + private function createStartedLoop(): SignalLoop + { + $loop = new SignalLoop(0); + $loop->start(); + $this->startedLoops[] = $loop; + + return $loop; + } } diff --git a/tests/Unit/Command/ListenAllCommandTest.php b/tests/Unit/Command/ListenAllCommandTest.php index 7053c02d..a2d6b214 100644 --- a/tests/Unit/Command/ListenAllCommandTest.php +++ b/tests/Unit/Command/ListenAllCommandTest.php @@ -73,13 +73,14 @@ public function testPausesWhenThereAreNoMessages(): void */ private function createLoop(int $continueTimes): LoopInterface { - return new class ($continueTimes) implements LoopInterface { - public function __construct(private int $continueTimes) {} + $loop = $this->createMock(LoopInterface::class); + $loop->method('canContinue')->willReturnCallback(static function () use (&$continueTimes): bool { + $canContinue = $continueTimes > 0; + $continueTimes--; - public function canContinue(): bool - { - return $this->continueTimes-- > 0; - } - }; + return $canContinue; + }); + + return $loop; } } diff --git a/tests/Unit/QueueTest.php b/tests/Unit/QueueTest.php index bfe8ba71..6f74722b 100644 --- a/tests/Unit/QueueTest.php +++ b/tests/Unit/QueueTest.php @@ -4,7 +4,6 @@ namespace Yiisoft\Queue\Tests\Unit; -use Closure; use Psr\Log\NullLogger; use Yiisoft\Queue\QueueConsumer; use Yiisoft\Queue\QueueConsumerInterface; @@ -19,11 +18,6 @@ use function count; -enum TestQueue: string -{ - case HIGH_PRIORITY = 'high-priority'; -} - final class QueueTest extends TestCase { public function testProducerContract(): void @@ -151,16 +145,9 @@ public function testConsumerListenStopsBeforeProcessingAnotherMessage(): void private function createStopAfterFirstExecutionLoop(): LoopInterface { - return new class (fn(): int => $this->executionTimes) implements LoopInterface { - /** - * @param Closure(): int $executionTimes - */ - public function __construct(private readonly Closure $executionTimes) {} - - public function canContinue(): bool - { - return ($this->executionTimes)() === 0; - } - }; + $loop = $this->createMock(LoopInterface::class); + $loop->method('canContinue')->willReturnCallback(fn(): bool => $this->executionTimes === 0); + + return $loop; } } diff --git a/tests/Unit/SignalHandlingTest.php b/tests/Unit/SignalHandlingTest.php new file mode 100644 index 00000000..32572709 --- /dev/null +++ b/tests/Unit/SignalHandlingTest.php @@ -0,0 +1,208 @@ +previousHandlers[$signal] = pcntl_signal_get_handler($signal); + } + $this->previousAsyncSignals = pcntl_async_signals(true); + } + + protected function tearDown(): void + { + foreach ($this->previousHandlers as $signal => $handler) { + pcntl_signal($signal, $handler); + } + pcntl_async_signals($this->previousAsyncSignals); + + parent::tearDown(); + } + + #[DataProvider('consumerMethodProvider')] + public function testShutdownFinishesCurrentMessageAndLeavesNextMessageQueued(string $method): void + { + $applicationSignals = []; + pcntl_signal(SIGTERM, static function (int $signal) use (&$applicationSignals): void { + $applicationSignals[] = $signal; + }); + $adapter = new InMemoryAdapter(); + $producer = $this->createQueue($adapter); + $first = IdEnvelope::fromMessage($producer->push(new GenericMessage('stop', null))); + $second = IdEnvelope::fromMessage($producer->push(new GenericMessage('stop', null))); + $finishedMessages = 0; + $worker = $this->createMock(WorkerInterface::class); + $worker->expects($this->once())->method('process')->willReturnCallback( + static function () use (&$finishedMessages): void { + posix_kill(getmypid(), SIGTERM); + $finishedMessages++; + }, + ); + $consumer = new QueueConsumer($worker, new SignalLoop(), new NullLogger(), $adapter); + + $consumer->$method(); + posix_kill(getmypid(), SIGTERM); + + self::assertSame(1, $finishedMessages); + self::assertSame(MessageStatus::DONE, $adapter->status($first->getId())); + self::assertSame(MessageStatus::WAITING, $adapter->status($second->getId())); + self::assertSame([SIGTERM], $applicationSignals, 'Application handles signals again after consuming ends.'); + } + + #[DataProvider('consumerMethodProvider')] + public function testProcessingFailureRestoresApplicationSignalHandling(string $method): void + { + $applicationSignals = []; + pcntl_signal(SIGTERM, static function (int $signal) use (&$applicationSignals): void { + $applicationSignals[] = $signal; + }); + $adapter = new InMemoryAdapter(); + $producer = $this->createQueue($adapter); + $message = IdEnvelope::fromMessage($producer->push(new GenericMessage('failing', null))); + $failure = new RuntimeException('Worker failed.'); + $worker = $this->createMock(WorkerInterface::class); + $worker->method('process')->willThrowException($failure); + $consumer = new QueueConsumer($worker, new SignalLoop(), new NullLogger(), $adapter); + + try { + $consumer->$method(); + self::fail('The processing failure must reach the caller.'); + } catch (RuntimeException $exception) { + self::assertSame($failure, $exception); + } + posix_kill(getmypid(), SIGTERM); + + self::assertSame([SIGTERM], $applicationSignals); + self::assertSame(MessageStatus::WAITING, $adapter->status($message->getId())); + } + + public static function consumerMethodProvider(): iterable + { + yield 'run' => ['run']; + yield 'listen' => ['listen']; + } + + /** + * @param class-string|class-string $commandClass + */ + #[DataProvider('commandProvider')] + public function testSignalBetweenQueuesStopsBeforeTheNextMessage(string $commandClass): void + { + $applicationSignals = []; + pcntl_signal(SIGTERM, static function (int $signal) use (&$applicationSignals): void { + $applicationSignals[] = $signal; + }); + $loop = new SignalLoop(); + $firstAdapter = new InMemoryAdapter(); + $firstMessage = IdEnvelope::fromMessage( + $this->createQueue($firstAdapter)->push(new GenericMessage('stop', null)), + ); + $secondAdapter = new InMemoryAdapter(); + $secondMessage = IdEnvelope::fromMessage( + $this->createQueue($secondAdapter)->push(new GenericMessage('later', null)), + ); + $firstWorker = $this->createMock(WorkerInterface::class); + $firstWorker->expects($this->once())->method('process'); + $firstConsumer = new QueueConsumer($firstWorker, $loop, new NullLogger(), $firstAdapter); + $consumerAtQueueBoundary = $this->createMock(QueueConsumerInterface::class); + $consumerAtQueueBoundary->method('run')->willReturnCallback(static function () use ($firstConsumer): int { + $count = $firstConsumer->run(); + posix_kill(getmypid(), SIGTERM); + + return $count; + }); + $secondWorker = $this->createMock(WorkerInterface::class); + $secondWorker->expects($this->never())->method('process'); + $provider = new PredefinedQueueProvider([ + 'first' => ['consumer' => $consumerAtQueueBoundary], + 'second' => ['consumer' => new QueueConsumer($secondWorker, $loop, new NullLogger(), $secondAdapter)], + ]); + $command = new $commandClass($provider, $loop); + $input = new ArrayInput([], $command->getNativeDefinition()); + + $result = $command->run($input, new BufferedOutput()); + posix_kill(getmypid(), SIGTERM); + + self::assertSame(0, $result); + self::assertSame(MessageStatus::DONE, $firstAdapter->status($firstMessage->getId())); + self::assertSame(MessageStatus::WAITING, $secondAdapter->status($secondMessage->getId())); + self::assertSame([SIGTERM], $applicationSignals); + } + + /** + * @param class-string|class-string $commandClass + */ + #[DataProvider('commandProvider')] + public function testCommandFailureRestoresApplicationSignalHandling(string $commandClass): void + { + $applicationSignals = []; + pcntl_signal(SIGTERM, static function (int $signal) use (&$applicationSignals): void { + $applicationSignals[] = $signal; + }); + $adapter = new InMemoryAdapter(); + $this->createQueue($adapter)->push(new GenericMessage('failing', null)); + $failure = new RuntimeException('Worker failed.'); + $worker = $this->createMock(WorkerInterface::class); + $worker->method('process')->willThrowException($failure); + $loop = new SignalLoop(); + $consumer = new QueueConsumer($worker, $loop, new NullLogger(), $adapter); + $command = new $commandClass(new PredefinedQueueProvider(['queue' => ['consumer' => $consumer]]), $loop); + $input = new ArrayInput([], $command->getNativeDefinition()); + + try { + $command->run($input, new BufferedOutput()); + self::fail('The processing failure must reach the caller.'); + } catch (RuntimeException $exception) { + self::assertSame($failure, $exception); + } + posix_kill(getmypid(), SIGTERM); + + self::assertSame([SIGTERM], $applicationSignals); + } + + /** + * @return iterable|class-string}> + */ + public static function commandProvider(): iterable + { + yield 'queue:run' => [RunCommand::class]; + yield 'queue:listen-all' => [ListenAllCommand::class]; + } +} diff --git a/tests/Unit/TestQueue.php b/tests/Unit/TestQueue.php new file mode 100644 index 00000000..24748985 --- /dev/null +++ b/tests/Unit/TestQueue.php @@ -0,0 +1,10 @@ +