Recently I read the book Signaling PHP by Cal Evans. It's a short book, yet very affordable and it learned me a couple of things. First of all it explains about how you can "capture" a Ctrl+C
on your long-running command and do some necessary cleanup work before actually terminating the application. In the appendix it also mentioned the interesting concept of a PID file. Such a file is used to store a single process identifier. The file is created by the process itself, to allow other scripts or applications to terminate it. This is especially useful in the context of daemon applications. They can be started by some wrapper script, run in the background, then be monitored and eventually interrupted by an administrator using the SIGINT
signal.
In Appendix A of "Signaling PHP", Cal writes about a way to extend a Symfony command to automatically create such a PID file before executing its task, and to delete this file afterwards. In the example code in the appendix the command gets to choose the filename of the PID file. However, since a PID file is only useful for external applications that handle starting and terminating the command, you may want to let the location and name of the PID file be determined by the external application itself. In other words, you'd want to be able to run a command and use an option to determine the location of the PID file:
app/console my:command --pidfile=/home/matthias/some-name.pid
I'd like every command in my application to have this extra option. Unfortunately there is no standard way to do this with Symfony. So the first thing we need to do is find a way to globally add an option to the Symfony console application. Let me give away the clue: I did some research and found a way to do this. It is a bit of a hack, but not too bad a hack, since I don't think it will break anywhere in the near feature.
Add a global command option
Since Symfony 2.3 there are some basic yet useful events related to executing a console command: console.command
will be dispatched when a command is about to be executed, console.exception
will be dispatched when executing a command results in an exception being thrown, and console.terminate
will be dispatched when the execution is finished and the application will soon terminate.
When you'd like to do something before any command is being executed, you should listen to the console.command
event. Since we'd like to add a new option to all commands, this may indeed be the right thing to do!
So create a bundle, create a class and register the class as an event listener:
namespace Matthias\PidFileBundle\EventListener;
use Symfony\Component\Console\Event\ConsoleCommandEvent;
class PidFileEventListener
{
public function onConsoleCommand(ConsoleCommandEvent $event)
{
...
}
}
<?xml version="1.0" ?>
<container ...>
<services>
<service id="matthias_pid_file.console_event_listener"
class="Matthias\PidFileBundle\EventListener\PidFileEventListener">
<tag name="kernel.event_listener" event="console.command" method="onConsoleCommand" />
</service>
</services>
</container>
For every console command being executed, the onConsoleCommand
method will be called.
Inside your own console commands you'd normally add a new option like this:
use Symfony\Component\Console\Input\InputOption;
class MyCommand
{
protected function configure()
{
...
$this->addOption('pidfile', null, InputOption::VALUE_OPTIONAL, '...');
}
}
This adds an extra option to the so-called input definition of the command. Later this input definition will be used to parse the actual input arguments provided by the user and validate them. The console application itself also has an input definition, which contains options like --help
, --no-interaction
, etc. Since we want to add an option for all commands instead of just one, we should alter the application's input definition and add the pidfile
option there. This is the way you can do it inside the event listener:
use Symfony\Component\Console\Input\InputOption;
class PidFileEventListener
{
public function onConsoleCommand(ConsoleCommandEvent $event)
{
$inputDefinition = $event->getCommand()->getApplication()->getDefinition();
// add the option to the application's input definition
$inputDefinition->addOption(
new InputOption('pidfile', null, InputOption::VALUE_OPTIONAL, 'The location of the PID file that should be created for this process', null)
);
}
}
Now try it, by running:
app/console --help
You will see that the option was indeed added:
Now, the documentation of the Console Component says that inside your event listener you can use the input object of the console command (by calling $event->getInput()
). However, when the event is being dispatched, the input has not yet been bound to the command's input definition, which also has not been merged with the application's input definition. So inside the event listener the input object is quite useless to us.
Luckily there is an easy workaround here: we can generate our own input object, based on the arguments the user provided, and parse them ourselves using the application's input definition. This way we can extract the value of the pidfile
option, which is the only thing we need inside the event listener:
use Symfony\Component\Console\Input\ArgvInput;
class PidFileEventListener
{
public function onConsoleCommand(ConsoleCommandEvent $event)
{
...
// the input object will read the actual arguments from $_SERVER['argv']
$input = new ArgvInput();
// bind the application's input definition to it
$input->bind($inputDefinition);
$pidFile = $input->getOption('pidfile');
}
}
But there is still one problem: we have used just the application's input definition here, so any option (or argument) defined by the command will be lost and when the real input arguments are being parsed, we will get a nasty error, saying for instance that the --no-warmup
option does not exist:
So instead of using just the application's input definition, we should combine it with the input definition of the command itself. The Command
class already has a convenient method for this.
namespace Symfony\Component\Console\Command;
...
class Command
{
/**
* Merges the application definition with the command definition.
*
* This method is not part of public API and should not be used directly.
*
* ...
*/
public function mergeApplicationDefinition($mergeArgs = true)
{
...
}
}
But according to the description of this method, we should not use this method directly... So this might be the part of this article where in the end things may fall apart, but of course, there is nothing which prevents us from copying the code in mergeApplicationDefinition()
to our own class.
After we have merged the application's input definition with the input definition of the command, the onConsoleCommand
method will look like this, we can bind the input object to the command's input definition:
class PidFileEventListener
{
public function onConsoleCommand(ConsoleCommandEvent $event)
{
$inputDefinition = $event->getCommand()->getApplication()->getDefinition();
$inputDefinition->addOption(
new InputOption('pidfile', null, InputOption::VALUE_OPTIONAL, 'The location of the PID file that should be created for this process', null)
);
// merge the application's input definition
$event->getCommand()->mergeApplicationDefinition();
$input = new ArgvInput();
// we use the input definition of the command
$input->bind($event->getCommand()->getDefinition());
$pidFile = $input->getOption('pidfile');
}
}
And now $pidFile
contains the value provided by the user as the command line option pidfile
!
Creating the PID file
We can simply use the getmypid()
PHP function to retrieve the process identifier and write it to a file at the requested location:
class PidFileEventListener
{
public function onConsoleCommand(ConsoleCommandEvent $event)
{
...
if ($pidFile !== null) {
file_put_contents($pidFile, getmypid());
}
}
}
Cleaning up
Very nice, now we have a global option pidfile
and a real PID file is being generated. Of course, the file should be removed too, when the console command has been terminated. Therefore we need to listen to another console event: console.terminate
:
use Symfony\Component\Console\Event\ConsoleTerminateEvent;
class PidFileEventListener
{
public function onConsoleTerminate(ConsoleTerminateEvent $event)
{
...
}
}
Don't forget to register this new method:
<?xml version="1.0" ?>
<container ...>
<services>
<service id="matthias_pid_file.console_event_listener"
class="...">
...
<tag name="kernel.event_listener" event="console.terminate" method="onConsoleTerminate" />
</service>
</services>
</container>
Since an event listener should only rely upon the event object that has been provided (which is an instance of ConsoleTerminateEvent
), we need to fetch the pidfile
again. However, since we did all the setup work in the onConsoleCommand
method, we can assume that the pidfile
option already exists and that the user's input argument have been parsed correctly. So to retrieve the value of the pidfile
option, these couple of lines would suffice to fetch the name of the PID file and, if it was provided at all, remove it:
class PidFileEventListener
{
...
public function onConsoleTerminate(ConsoleTerminateEvent $event)
{
$pidFile = $event->getInput()->getOption('pidfile');
if ($pidFile !== null) {
unlink($pidFile);
}
}
}
Now every time a console command terminates, the generated PID file will be deleted.
Please note that adding new options in the console.COMMAND event is now officially supported as of Symfony 2.8/3.0:
$definition = $event->getCommand()->getDefinition();
$input = $event->getInput();
$definition->addOption(new InputOption('extra', null, InputOption::VALUE_REQUIRED));
$input->bind($definition);
$extraValue = $input->getOption('extra');
Also, it is no longer required to merge the application definition and bind the input. Using $event->getInput(), you can get access to the options of the application/command immediately.
Related PR: https://github.com/symfony/...
My final code was like this:
public function onConsoleCommand(ConsoleCommandEvent $event)
{
$definition = $event->getCommand()->getDefinition();
$input = $event->getInput();
$option = new InputOption('country', 'c', InputOption::VALUE_OPTIONAL, 'The two letter country code that is used to configure the API', null);
$definition->addOption($option);
$input->bind($definition);
$definition = $event->getCommand()->getApplication()->getDefinition();
$definition->addOption($option);
return $event;
}
Good news, this makes global console commands a lot easier. But am I correct that these options won't show up in the overview with console --help?
Yes, as --help doesn't run the console.COMMAND event for the command that is displayed.
to show the option in the command help do:
$option = new InputOption(...)
$inputDefinition = $event->getCommand()->getApplication()->getDefinition();
$inputDefinition->addOption($option);
Hi! It seems you don't need to call
mergeApplicationDefinition. You can just add your option and argument:
$inputDefinition = $event->getCommand()->getDefinition();
$inputDefinition->addOption(
new InputOption(
'domain', null, InputOption::VALUE_OPTIONAL, 'Customer domain', null
)
);
$inputDefinition->addArgument(new InputArgument('domain'));
$event->getCommand()->setDefinition($inputDefinition);
$input = new ArgvInput();
$input->bind($inputDefinition);
$domain = $input->getOption('domain');
Thank for your article!
Awesome, I've been looking all over for this information. I am applying this to a silex console application but the concept should be similar. Thanks this saved me the effort of going through all the code!
Not bad to have something like this in standart bundle. Locking console command is needed when long running cron commands used ins application
First of all, thanks for all these nice posts!
I think this can be done through the raw value:
[php]
public function onConsoleCommand( ConsoleCommandEvent $event )
{
$input = $event->getInput();
$inputOption = new InputOption( 'pidfile', null, InputOption::VALUE_OPTIONAL, 'The location of the PID file that should be created for this process' );
$event
->getCommand()
->getApplication()
->getDefinition()
->addOption( $inputOption );
$option = array( '--pidfile' );
if( true === $input->hasParameterOption( $option ) ) {
$value = $input->getParameterOption( $option );
/*
$pidfile = validate the raw value ( file exists, directory exists, is writable ... )
file_put_contents( $pidfile, getmypid() );
...
register_shutdown_function( function () use ( $pidfile ) {
@unlink( $pidfile );
} );
*/
}
}
[/php]
I don't know if this is a good way to do it, but i (currently) see no harm in it. :)
Thanks, Gino. Actually, this seems to me a very good way to do it! I didn't know about the
hasParameterOption()
.This makes adding the input option to the application's input definition just a formal step to make it known to someone who asks for help using
--help
.Yes, but it's still needed, otherwise the command will throw an undefined option error.
Sry for the messed up code block in my previous post.
Right. No problem, I'll fix it.
Matthias, thanks, it's a very useful post.
I'm working in a multitenant application that requires intercept all Doctrine's commands in order to specify the target tenant. The possibility of retrieving the value of dynamically inserted options is very useful and I brought up this question when this feature was being planed.
I opened a issue to bring up some solutions. Can you leave your opinion?
https://github.com/symfony/...
I created a bundle some weeks ago that add defaults to console commands, you just gave me the idea to add a "*" rule!
https://github.com/matteosi...
Nice, seems like a useful bundle!
Not Bad but:Your listener is a service, It should not be state full => You can not store the pid as a class attribute.Then, I used to use "register_shutdown_function" to force the removal of the pid file.And I used this hack to implement a lock.[php] $lockFile = '/var/lock/project/command_name.lock';if (file_exists($lockFile)) { $lockingPID = file_get_contents($lockFile); if (in_array($lockingPID, explode("\n", trim(
ps -e | awk '{print $1}'
)))) { if ($verbose) { $output->writeln('This command is already running in another process.'); } return; } }file_put_contents($lockFile, getmypid());register_shutdown_function(function () use ($lockFile) { unlink($lockFile); });// php does not call register_shutdown_function when we signal the process pcntl_signal(SIGTERM, function () use ($lockFile) { unlink($lockFile); }); [/php]I modified the article to make no reference to
$this->pidFile
anymore ;) Also, I noticed that the first solution had a bug: it did not consider existing command options.Hi Greg, thanks for your suggestion! I agree that a service should not have observable state. Though the mere fact that this event listener is registered as a service does not make it a service per se. Would this principle apply in this situation too, what do you think?
But using a property is indeed somewhat "dangerous" because this is an event listener and in theory any other part of the application could have triggered a console event which would mess things up. The best way to fix my code here would be to repeat the steps and parse the input again.
[php]
public function onConsoleTerminate(ConsoleTerminateEvent $event)
{
$inputDefinition = $event->getCommand()->getApplication()->getDefinition();
$input = new ArgvInput();
$input->bind($inputDefinition);
$pidFile = $input->getOption('pidfile');
if ($pidFile !== null) {
unlink($pidFile);
}
}
[/php]
Using a shutdown function to remove the file also seems like a good idea, especially because the PID file is otherwise not deleted at the last moment possible (though it is also not created at the first moment, something I did not mention in the article).
Well, thanks again - maybe sometime we could open source some console tools like these?