Skip to content

[Console] Add support for SignalableCommandInterface with invokable commands #60389

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions src/Symfony/Component/Console/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
use Symfony\Component\Console\Command\HelpCommand;
use Symfony\Component\Console\Command\LazyCommand;
use Symfony\Component\Console\Command\ListCommand;
use Symfony\Component\Console\Command\SignalableCommandInterface;
use Symfony\Component\Console\CommandLoader\CommandLoaderInterface;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
Expand Down Expand Up @@ -1005,8 +1004,7 @@ protected function doRunCommand(Command $command, InputInterface $input, OutputI
}
}

$commandSignals = $command instanceof SignalableCommandInterface ? $command->getSubscribedSignals() : [];
if ($commandSignals || $this->dispatcher && $this->signalsToDispatchEvent) {
if (($commandSignals = $command->getSubscribedSignals()) || $this->dispatcher && $this->signalsToDispatchEvent) {
$signalRegistry = $this->getSignalRegistry();

if (Terminal::hasSttyAvailable()) {
Expand Down
1 change: 1 addition & 0 deletions src/Symfony/Component/Console/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ CHANGELOG
* Add support for `LockableTrait` in invokable commands
* Deprecate returning a non-integer value from a `\Closure` function set via `Command::setCode()`
* Mark `#[AsCommand]` attribute as `@final`
* Add support for `SignalableCommandInterface` with invokable commands

7.2
---
Expand Down
12 changes: 11 additions & 1 deletion src/Symfony/Component/Console/Command/Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class Command
class Command implements SignalableCommandInterface
{
// see https://tldp.org/LDP/abs/html/exitcodes.html
public const SUCCESS = 0;
Expand Down Expand Up @@ -674,6 +674,16 @@ public function getHelper(string $name): HelperInterface
return $this->helperSet->get($name);
}

public function getSubscribedSignals(): array
{
return $this->code?->getSubscribedSignals() ?? [];
}

public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
{
return $this->code?->handleSignal($signal, $previousExitCode) ?? false;
}

/**
* Validates a command name.
*
Expand Down
14 changes: 13 additions & 1 deletion src/Symfony/Component/Console/Command/InvokableCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@
*
* @internal
*/
class InvokableCommand
class InvokableCommand implements SignalableCommandInterface
{
private readonly \Closure $code;
private readonly ?SignalableCommandInterface $signalableCommand;
private readonly \ReflectionFunction $reflection;
private bool $triggerDeprecations = false;

Expand All @@ -39,6 +40,7 @@ public function __construct(
callable $code,
) {
$this->code = $this->getClosure($code);
$this->signalableCommand = $code instanceof SignalableCommandInterface ? $code : null;
$this->reflection = new \ReflectionFunction($this->code);
}

Expand Down Expand Up @@ -142,4 +144,14 @@ private function getParameters(InputInterface $input, OutputInterface $output):

return $parameters ?: [$input, $output];
}

public function getSubscribedSignals(): array
{
return $this->signalableCommand?->getSubscribedSignals() ?? [];
}

public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
{
return $this->signalableCommand?->handleSignal($signal, $previousExitCode) ?? false;
}
}
8 changes: 2 additions & 6 deletions src/Symfony/Component/Console/Command/TraceableCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
*
* @author Jules Pietri <jules@heahprod.com>
*/
final class TraceableCommand extends Command implements SignalableCommandInterface
final class TraceableCommand extends Command
{
public readonly Command $command;
public int $exitCode;
Expand Down Expand Up @@ -89,15 +89,11 @@ public function __call(string $name, array $arguments): mixed

public function getSubscribedSignals(): array
{
return $this->command instanceof SignalableCommandInterface ? $this->command->getSubscribedSignals() : [];
return $this->command->getSubscribedSignals();
}

public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
{
if (!$this->command instanceof SignalableCommandInterface) {
return false;
}

$event = $this->stopwatch->start($this->getName().'.handle_signal');

$exit = $this->command->handleSignal($signal, $previousExitCode);
Expand Down
74 changes: 70 additions & 4 deletions src/Symfony/Component/Console/Tests/ApplicationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2255,6 +2255,41 @@ public function testSignalableRestoresStty()
$this->assertSame($previousSttyMode, $sttyMode);
}

/**
* @requires extension pcntl
*/
public function testSignalableInvokableCommand()
{
$command = new Command();
$command->setName('signal-invokable');
$command->setCode($invokable = new class implements SignalableCommandInterface {
use SignalableInvokableCommandTrait;
});

$application = $this->createSignalableApplication($command, null);
$application->setSignalsToDispatchEvent(\SIGUSR1);

$this->assertSame(1, $application->run(new ArrayInput(['signal-invokable'])));
$this->assertTrue($invokable->signaled);
}

/**
* @requires extension pcntl
*/
public function testSignalableInvokableCommandThatExtendsBaseCommand()
{
$command = new class extends Command implements SignalableCommandInterface {
use SignalableInvokableCommandTrait;
};
$command->setName('signal-invokable');

$application = $this->createSignalableApplication($command, null);
$application->setSignalsToDispatchEvent(\SIGUSR1);

$this->assertSame(1, $application->run(new ArrayInput(['signal-invokable'])));
$this->assertTrue($command->signaled);
}

/**
* @requires extension pcntl
*/
Expand Down Expand Up @@ -2514,7 +2549,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}

#[AsCommand(name: 'signal')]
class SignableCommand extends BaseSignableCommand implements SignalableCommandInterface
class SignableCommand extends BaseSignableCommand
{
public function getSubscribedSignals(): array
{
Expand All @@ -2531,7 +2566,7 @@ public function handleSignal(int $signal, int|false $previousExitCode = 0): int|
}

#[AsCommand(name: 'signal')]
class TerminatableCommand extends BaseSignableCommand implements SignalableCommandInterface
class TerminatableCommand extends BaseSignableCommand
{
public function getSubscribedSignals(): array
{
Expand All @@ -2548,7 +2583,7 @@ public function handleSignal(int $signal, int|false $previousExitCode = 0): int|
}

#[AsCommand(name: 'signal')]
class TerminatableWithEventCommand extends Command implements SignalableCommandInterface, EventSubscriberInterface
class TerminatableWithEventCommand extends Command implements EventSubscriberInterface
{
private bool $shouldContinue = true;
private OutputInterface $output;
Expand Down Expand Up @@ -2615,8 +2650,39 @@ public static function getSubscribedEvents(): array
}
}

trait SignalableInvokableCommandTrait
{
public bool $signaled = false;

public function __invoke(): int
{
posix_kill(posix_getpid(), \SIGUSR1);

for ($i = 0; $i < 1000; ++$i) {
usleep(100);
if ($this->signaled) {
return 1;
}
}

return 0;
}

public function getSubscribedSignals(): array
{
return SignalRegistry::isSupported() ? [\SIGUSR1] : [];
}

public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
{
$this->signaled = true;

return false;
}
}

#[AsCommand(name: 'alarm')]
class AlarmableCommand extends BaseSignableCommand implements SignalableCommandInterface
class AlarmableCommand extends BaseSignableCommand
{
public function __construct(private int $alarmInterval)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Command\LazyCommand;
use Symfony\Component\Console\Command\SignalableCommandInterface;
use Symfony\Component\Console\CommandLoader\ContainerCommandLoader;
use Symfony\Component\Console\DependencyInjection\AddConsoleCommandPass;
use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument;
Expand Down Expand Up @@ -325,6 +326,27 @@ public function testProcessInvokableCommand()
self::assertSame('The command description', $command->getDescription());
self::assertSame('The %command.name% command help content.', $command->getHelp());
}

public function testProcessInvokableSignalableCommand()
{
$container = new ContainerBuilder();
$container->addCompilerPass(new AddConsoleCommandPass(), PassConfig::TYPE_BEFORE_REMOVING);

$definition = new Definition(InvokableSignalableCommand::class);
$definition->addTag('console.command', [
'command' => 'invokable-signalable',
'description' => 'The command description',
'help' => 'The %command.name% command help content.',
]);
$container->setDefinition('invokable_signalable_command', $definition);

$container->compile();
$command = $container->get('console.command_loader')->get('invokable-signalable');

self::assertTrue($container->has('invokable_signalable_command.command'));
self::assertSame('The command description', $command->getDescription());
self::assertSame('The %command.name% command help content.', $command->getHelp());
}
}

class MyCommand extends Command
Expand Down Expand Up @@ -361,3 +383,21 @@ public function __invoke(): void
{
}
}

#[AsCommand(name: 'invokable-signalable', description: 'Just testing', help: 'The %command.name% help content.')]
class InvokableSignalableCommand implements SignalableCommandInterface
{
public function __invoke(): void
{
}

public function getSubscribedSignals(): array
{
return [];
}

public function handleSignal(int $signal, false|int $previousExitCode = 0): int|false
{
return false;
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
<?php

use Symfony\Component\Console\Command\SignalableCommandInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion;
Expand All @@ -12,7 +11,7 @@
}
require $vendor.'/vendor/autoload.php';

(new class extends SingleCommandApplication implements SignalableCommandInterface {
(new class extends SingleCommandApplication {
public function getSubscribedSignals(): array
{
return [SIGINT];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ Test command that exits

use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Command\SignalableCommandInterface;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
Expand All @@ -19,7 +18,7 @@ while (!file_exists($vendor.'/vendor')) {
}
require $vendor.'/vendor/autoload.php';

class MyCommand extends Command implements SignalableCommandInterface
class MyCommand extends Command
{
protected function initialize(InputInterface $input, OutputInterface $output): void
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ Test command that exits

use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Command\SignalableCommandInterface;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
Expand All @@ -19,7 +18,7 @@ while (!file_exists($vendor.'/vendor')) {
}
require $vendor.'/vendor/autoload.php';

class MyCommand extends Command implements SignalableCommandInterface
class MyCommand extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
Expand Down
Loading