17

I have a PHP command line app with a custom shutdown handler:

<?php
declare(ticks=1);

$shutdownHandler = function () {
    echo 'Exiting';
    exit();
};

pcntl_signal(SIGINT, $shutdownHandler); 

while (true) {
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, 'http://blackhole.webpagetest.org');
    curl_exec($ch);
    curl_close($ch);
}

If I kill the script with Ctrl+C while a CURL request is in progress, it has no effect. The command just hangs. If I remove my custom shutdown handler, Ctrl+C kills the CURL request immediately.

Why is CURL unkillable when I define a SIGINT handler?

Derek Pollard
  • 6,953
  • 6
  • 39
  • 59
Jonathan
  • 13,947
  • 17
  • 94
  • 123
  • Why do you think CURL is responding to the signal, rather than it just being completely ignored? – IMSoP Apr 10 '18 at 17:17
  • @IMSoP Sorry, my question was maybe a bit misleading (I've updated). The crux of it is why is CURL unkillable in this scenario. – Jonathan Apr 10 '18 at 17:25
  • Curl is an external library so killing it in the middle of an operation isn't going to be easy/possible from userland. You're going to have to figure out how to hook back into the default signal handler to get it killed properly. – Sammitch Apr 10 '18 at 18:24
  • sounds like a bug in the curl resource's cleanup routine, which happens implicitly at script exit - what happens if you change it to ```$shutdownHandler = function () use(&$ch){ echo 'Exiting'; curl_close($ch); echo "curl closed.\n"; exit(); }; ``` ? – hanshenrik Apr 10 '18 at 22:21
  • Just so it's been asked: do you see the same issue with the ticks declaration removed? – Anthony Apr 10 '18 at 22:24
  • I think your underlying assumption might be wrong. I replaced the curl requests with a `sleep(30)` like : `while ($testing) { echo "Sleeeeeping..." . PHP_EOL; sleep(10); echo "Awake!" . PHP_EOL; }` doing a ctrl+C causes the `sleep` to halt and "Awake!" to get printed earlier than expected, but it returns right back to the loop. Even if I have `$testing = false;` in my signal handler. – Anthony Apr 10 '18 at 23:02
  • nvm, apparently those ticks do matter. shows what I know. – Anthony Apr 10 '18 at 23:20
  • This might be relevant : https://github.com/laravel/framework/issues/22301 – Anthony Apr 10 '18 at 23:30
  • sounds like a bug in PHP (something like `unable to call the SIGINT handler during curl_exec()`), someone should take it to https://bugs.php.net – hanshenrik Apr 14 '18 at 23:34
  • Are you running Ubuntu >=16.04 ? – user10089632 Apr 16 '18 at 09:00
  • @user10089632 this is on Ubuntu 16.04.3 LTS, running PHP 7.0.27. – Jonathan Apr 16 '18 at 10:20
  • 2
    I think the answer lies in https://stackoverflow.com/questions/26934216/pcntl-signal-function-not-being-hit-and-ctrlc-doesnt-work-when-using-sockets, the issue is your curl call is stuck with a blocking io and php can't reach to your signal handler at all and setting handler disables the original handler all together – Tarun Lalwani Apr 16 '18 at 14:33
  • 1
    @Jonathan, it is not possible to do this without restructing the code in `7.0.X`. See if this gist is a acceptable solution to you? https://gist.github.com/tarunlalwani/6b4f2b81f20c781234899e62f22b0436, if it is then only I will post an answer – Tarun Lalwani Apr 20 '18 at 11:46

4 Answers4

12

What does work?

What really seems to work is giving the whole thing some space to work its signal handling magic. Such space seems to be provided by enabling cURL's progress handling, while also setting a "userland" progress callback:

Solution 1

while (true) {
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_NOPROGRESS, false); // "true" by default
    curl_setopt($ch, CURLOPT_PROGRESSFUNCTION, function() {
        usleep(100);
    });
    curl_setopt($ch, CURLOPT_URL, 'http://blackhole.webpagetest.org');
    curl_exec($ch);
    curl_close($ch);
}

Seems like there needs to be "something" in the progress callback function. Empty body does not seem to work, as it probably just doesn't give PHP much time for signal handling (hardcore speculation).


Solution 2

Putting pcntl_signal_dispatch() in the callback seems to work even without declare(ticks=1); on PHP 7.1.

...
curl_setopt($ch, CURLOPT_PROGRESSFUNCTION, function() {
    pcntl_signal_dispatch();
});
...

Solution 3 ❤ (PHP 7.1+)

Using pcntl_async_signals(true) instead of declare(ticks=1); works even with empty progress callback function body.

This is probably what I, personally, would use, so I'll put the complete code here:

<?php

pcntl_async_signals(true);

$shutdownHandler = function() {
    die("Exiting\n");
};

pcntl_signal(SIGINT, $shutdownHandler);

while (true) {
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_NOPROGRESS, false);
    curl_setopt($ch, CURLOPT_PROGRESSFUNCTION, function() {});
    curl_setopt($ch, CURLOPT_URL, 'http://blackhole.webpagetest.org');
    curl_exec($ch);
    curl_close($ch);
}

All these three solutions cause the PHP 7.1 to quit almost instantly after hitting CTRL+C.

Smuuf
  • 6,339
  • 3
  • 23
  • 35
  • 3
    It's not `sleep(100)`. It's **`usleep(100)`**. `100` microseconds = **`.1` miliseconds**. – Smuuf Apr 20 '18 at 11:12
  • Ok, I appologize for the misunderstanding, but I would suggest to use third solution if you don't see any disadvantages in that solution. – user10089632 Apr 20 '18 at 11:19
  • The third solution will only work from `7.1.X` or higher. Seems like `v7.0.28-0ubuntu0.16.04.1` doesn't have it – Tarun Lalwani Apr 20 '18 at 11:50
  • That's because `pcntl_async_signals()` is [available only in PHP 7.1+](http://php.net/manual/en/function.pcntl-async-signals.php). – Smuuf Apr 20 '18 at 22:25
8

What is happening?

When you send the Ctrl + C command, PHP tries to finish the current action before exiting.

Why does my (OP's) code not exit?

SEE FINAL THOUGHTS AT THE END FOR A MORE DETAILED EXPLANATION

Your code does not exit because cURL doesn't finish, so PHP cannot exit until it finishes the current action.

The website you've chosen for this exercise never loads.

How to fix

To fix, replace the URL with something that does load, like https://google.com, for instance

Proof

I wrote my own code sample to show me exactly when/where PHP decides to exit:

<?php
declare(ticks=1);

$t = 1;
$shutdownHandler = function () {
    exit("EXITING NOW\n");
};

pcntl_signal(SIGINT, $shutdownHandler);

while (true) {
    print "$t\n";
    $t++;
}

When running this in the terminal, you get a much clearer idea of how PHP is operating: enter image description here

In the image above, you can see that when I issue the SIGINT command via Ctrl + C (shown by the arrow), it finishes up the action it is doing, then exits.

This means that if my fix is correct, all it should take to kill curl in OP's code, is a simple URL change:

<?php

declare(ticks=1);
$t = 1;
$shutdownHandler = function () {
    exit("\nEXITING\n");
};

pcntl_signal(SIGINT, $shutdownHandler);


while (true) {
    echo "CURL$t\n";
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, 'https://google.com');
    curl_exec($ch);
    curl_close($ch);
}

And then running: enter image description here

Viola! As expected, the script terminated after the current process was finished, like its supposed to.

Final thoughts

The site you're attempting to curl is effectively sending your code on a trip that has no end. The only actions capable of stopping the process are CTRL + X, the max execution time setting, or the CURLOPT_TIMEOUT cURL option. The reason CTRL+C works when you take OUT pcntl_signal(SIGINT, $shutdownHandler); is because PHP no longer has the burden of graceful shutdown by an internal function. Since PHP isn't concurrent, when you do have the handler in, it has to wait its turn before it is executed - which it will never get because the cURL task will never finish, thus leaving you with the never-ending results.

I hope this helps!

Derek Pollard
  • 6,953
  • 6
  • 39
  • 59
  • 4
    Thanks for your detailed response, however the choice of a slow loading URL is key to the question. If a signal handler is _not_ defined a request will always be killed, even if it's in progress. This behaviour changes when the signal handler is defined and is what I'm interested in. – Jonathan Apr 20 '18 at 10:32
  • @Jonathan I went into detail about why it doesn't work with your URL. please read the whole answer. – Derek Pollard Apr 20 '18 at 10:35
  • *Your code does not exit because cURL doesn't finish, so PHP cannot exit until it finishes the current action.* is literally the answer, the only fix is to change the URL. – Derek Pollard Apr 20 '18 at 10:38
  • **The reason CTRL+C works when you take OUT pcntl_signal(SIGINT, $shutdownHandler); is because PHP no longer has the burden of graceful shutdown by an internal function. Since PHP isn't concurrent, when you do have the handler in, it has to wait its term before it is executed - which it will never get because the cURL task will never finish, thus leaving you with the never-ending results.** – Derek Pollard Apr 20 '18 at 10:39
4

If it is possible to upgrade to PHP 7.1.X or higher, I would use the Solution 3 that @Smuuf posted. That is clean.

But if you can't upgrade then you need to use a workaround. The issue happens as explained in the below SO thread

pcntl_signal function not being hit and CTRL+C doesn't work when using sockets

PHP is busy in a blocking IO and it cannot process the pending signal for which you have customized the handler. Anytime a signal is raised and you have handler associated with it, PHP queues the same. Once PHP has completed its current operation then it executes your handler.

But unfortunately in this situation it never gets that chance, you don't specify a timeout for your CURL and it is just a blackhole which PHP is not able to escape.

So if you can have one php process have the responsibility of handling the SIGTERM and one child process having to handle the work then it would work, as the parent process will be able to catch the signal and process the callback even when child is occupied with the same

Below is a code which demonstrates the same

<?php
    $pid = pcntl_fork();
    if ($pid == -1) {
        die('could not fork');
    } else if ($pid) {
        // we are the parent
        declare (ticks = 1);
        //pcntl_async_signals(true);
        $shutdownHandler = function()
        {
            echo 'Exiting' . PHP_EOL;
        };
        pcntl_signal(SIGINT, $shutdownHandler);
        pcntl_wait($status); //Protect against Zombie children
    } else {
        // we are the child
        echo "hanging now" . PHP_EOL;
        while (true) {
            $ch = curl_init();
            curl_setopt($ch, CURLOPT_URL, 'http://blackhole.webpagetest.org');
            curl_exec($ch);
            curl_close($ch);
        }
    }

And below is it in action

PHP Kill

Tarun Lalwani
  • 142,312
  • 9
  • 204
  • 265
-2

I've scratched my head over this for a while and didn't find any optimal solutions using pcntl but depending on the intended usage of your command you may consider :

  • Using a timeout :
    curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15);
    As you may already know there are many options to control the behavior of curl, including setting a progress callback function.
  • Using file_get_contents() instead of curl.

A solution using the Ev extension :

For the signal handling code, it could be as simple as this :

$w = new EvSignal(SIGINT, function (){
        exit("SIGINT received\n");
});
Ev::run();

A quick summary of the installation of the Ev extension :
sudo pecl install Ev
You will need to enable the extension by adding to the php.ini file of the php cli, may be /etc/php/7.0/cli/php.ini
extension="ev.so"
If pecl complain about missing phpize, install php7.0-dev.

user10089632
  • 5,216
  • 1
  • 26
  • 34