PHP & Symfony About PHP and Symfony2 development

Symfony2: Add a global option to console commands and generate a PID file

Posted on by Matthias Noback

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:

PID-file option

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:

Error: 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.

Categories: PHP Symfony2

Tags: console events PID file

Comments: Comments