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

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:

pidfile-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.

Posted in Console, Events, Symfony2 | 11 Comments

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

  • # November 16, 2013 at 23:17

    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.

    $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);
    });
    
    Greg

    Reply

    • # November 17, 2013 at 10:12

      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.

      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);
          }
      }
      

      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?

      Matthias Noback

      Reply

    • # November 18, 2013 at 09:24

      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.

      Matthias Noback

      Reply

  • # November 17, 2013 at 14:40

    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/matteosister/CypressConsoleDefaultsBundle

    matteo

    Reply

    • # November 17, 2013 at 19:20

      Nice, seems like a useful bundle!

      Matthias Noback

      Reply

  • # November 19, 2013 at 20:04

    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/symfony/issues/9538

    Marcos Passos

    Reply

  • # November 27, 2013 at 12:18

    First of all, thanks for all these nice posts!

    I think this can be done through the raw value:

    public function onConsoleCommand( ConsoleCommandEvent $event )
    {
        $input = $event-&gt;getInput();
        $inputOption = new InputOption( 'pidfile', null, InputOption::VALUE_OPTIONAL, 'The location of the PID file that should be created for this process' );
        $event
            -&gt;getCommand()
            -&gt;getApplication()
            -&gt;getDefinition()
            -&gt;addOption( $inputOption );
        
        $option = array( '--pidfile' );
        if( true === $input-&gt;hasParameterOption( $option ) ) {
            $value = $input-&gt;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 );
            } );
            */
        }
    }
    

    I don’t know if this is a good way to do it, but i (currently) see no harm in it. :)

    Gino C.

    Reply

    • # November 27, 2013 at 15:39

      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.

      Matthias Noback

      Reply

      • # November 27, 2013 at 19:52

        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.

        Gino C.

        Reply

        • # November 27, 2013 at 21:12

          Right. No problem, I’ll fix it.

          Matthias Noback

          Reply

  • Pingback: Дайджест интересных новостей и материалов из мира PHP (10—24 ноября 2013) | Juds

Leave a Reply

Your email address will not be published. Required fields are marked *

* *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>