php / php-src

The PHP Interpreter
https://www.php.net
Other
38.04k stars 7.73k forks source link

stream_socket_accept() timeout sometimes doesn't work? #13220

Open l-aloha opened 8 months ago

l-aloha commented 8 months ago

Description

The following simple client-server socket code:

<?php // server.php

//  set_time_limit(0); // infinity for server, but as CLI the default is 0.

    define('DEFAULT_SERVER_THREAD_COUNT', 2);

    if (!function_exists('pcntl_fork')) die('PCNTL functions not available on this PHP installation');

    function logParent(string $message) : void
    {
        printf('[%s] %s%s', date('Ymd His'), $message, PHP_EOL);
    }

    function logChild(string $message) : void
    {
        printf('[%s][Pid: %u] %s%s', date('Ymd His'), getmypid(), $message, PHP_EOL);
    }

    abstract class Server
    {
        public function __construct(private string $address = DEFAULT_CONNECTION_ADDRESS) { }

        public function run(int $threadCount = DEFAULT_SERVER_THREAD_COUNT, int $acceptTimeOut = -1) : void
        {
            logParent('Server started');
            if (!($socket = stream_socket_server($this->address, $errno, $errstr)))
            {
                throw new Exception("$errstr ($errno)");
            }
            $pid = -1;
            try
            {
                for ($ii = $threadCount; $ii-- > 0; )
                {
                    $pid = pcntl_fork();
                    if (-1 == $pid)
                    {
                        die('Could not fork!');
                    }
                    else if ($pid) // parent
                    {
                        logParent("Created child: $pid");
                        sleep(1); // delay between children creation
                    }
                    else // child
                    {
                        logChild('child started');
                        break;
                    }
                }

                if ($pid) // parent
                {
                    for ($ii = $threadCount; $ii-- > 0; ) // parent wait (or manage) the children
                    {   
                        $_pid = pcntl_wait($status); // wait the children...
//                      logParent("Child ended: $_pid");
                    }
                    sleep(1); // wait yet for the actual closure of the children
                }
                else // wait the client in child
                {
//                  stream_set_timeout($socket, $acceptTimeOut); // unfortunately didn't help
                    $cycle = true;
                    while ($cycle)
                    {
                        logChild('waiting for client connection');
                        if ($conn = @stream_socket_accept($socket, $acceptTimeOut))
                        {
                            logChild('client connected, processing...');
                            $request = fread($conn, 4096);
                            $response = $this->processRequest($request);
                            fwrite($conn, $response);
                            fclose($conn);
                        }
                        else
                        {
                            logChild('call idle function');
                            $cycle = (-1 != $acceptTimeOut) && $this->idleFunction();
                        }
                    }
                }
            }
            finally
            {
                if ($pid) // parent
                {
//TODO maybe kill the (orphan) children if need?
                    fclose($socket);
                    logParent('Bye...');
                }
                else // child
                {
                    logChild('child ended');
                }
            }
        }

        protected function idleFunction() : bool { return true; }
        abstract protected function processRequest(string $request) : string;
    }

    class ServerTest extends Server
    {
        protected function processRequest(string $request) : string { return 'OK'; }
    }

//  defined('DEFAULT_CONNECTION_ADDRESS') || define('DEFAULT_CONNECTION_ADDRESS', 'tcp://127.0.0.1:6666');
    define('SOCKET_FILE', '/tmp/test.sock');
    @unlink(SOCKET_FILE);
    defined('DEFAULT_CONNECTION_ADDRESS') || define('DEFAULT_CONNECTION_ADDRESS', 'unix://' . SOCKET_FILE);
    (new ServerTest(DEFAULT_CONNECTION_ADDRESS))->run(acceptTimeOut: 10);
?>

<?php // client.php
//  defined('DEFAULT_CONNECTION_ADDRESS') || define('DEFAULT_CONNECTION_ADDRESS', 'tcp://127.0.0.1:6666');
    define('SOCKET_FILE', '/tmp/test.sock');
    defined('DEFAULT_CONNECTION_ADDRESS') || define('DEFAULT_CONNECTION_ADDRESS', 'unix://' . SOCKET_FILE);

    $fp = stream_socket_client(DEFAULT_CONNECTION_ADDRESS, $errno, $errstr, 30);
    if (!$fp)
    {
        echo "$errstr ($errno)" . PHP_EOL;
    }
    else
    {
        fwrite($fp, 'KO');
        while (!feof($fp))
        {
            echo fgets($fp, 1024);
        }
        fclose($fp);
    }
?>

Resulted in this output:

$ php -d extension=pcntl server.php
[20240122 104636] Server started
[20240122 104636] Created child: 459146
[20240122 104636][Pid: 459146] child started
[20240122 104636][Pid: 459146] waiting for client connection
[20240122 104637] Created child: 459147
[20240122 104637][Pid: 459147] child started
[20240122 104637][Pid: 459147] waiting for client connection
[20240122 104643][Pid: 459147] client connected, processing...
[20240122 104643][Pid: 459147] waiting for client connection
[20240122 104646][Pid: 459146] call idle function
[20240122 104646][Pid: 459146] waiting for client connection
[20240122 104653][Pid: 459147] call idle function
[20240122 104653][Pid: 459147] waiting for client connection
[20240122 104656][Pid: 459146] call idle function
[20240122 104656][Pid: 459146] waiting for client connection
[20240122 104703][Pid: 459147] call idle function
[20240122 104703][Pid: 459147] waiting for client connection
[20240122 104706][Pid: 459146] call idle function
[20240122 104706][Pid: 459146] waiting for client connection
[20240122 104707][Pid: 459146] client connected, processing...
[20240122 104707][Pid: 459146] waiting for client connection
[20240122 104708][Pid: 459147] client connected, processing...
[20240122 104708][Pid: 459147] waiting for client connection
[20240122 104708][Pid: 459146] client connected, processing...
[20240122 104708][Pid: 459146] waiting for client connection
[20240122 104709][Pid: 459147] client connected, processing...
[20240122 104709][Pid: 459147] waiting for client connection
[20240122 104710][Pid: 459146] client connected, processing...
[20240122 104710][Pid: 459146] waiting for client connection
[20240122 104720][Pid: 459146] call idle function
[20240122 104720][Pid: 459146] waiting for client connection
[20240122 104730][Pid: 459146] call idle function
[20240122 104730][Pid: 459146] waiting for client connection
[20240122 104740][Pid: 459146] call idle function
[20240122 104740][Pid: 459146] waiting for client connection
[20240122 104741][Pid: 459147] client connected, processing...
[20240122 104741][Pid: 459147] waiting for client connection
[20240122 104751][Pid: 459147] call idle function
[20240122 104751][Pid: 459147] waiting for client connection
[20240122 104801][Pid: 459147] call idle function
[20240122 104801][Pid: 459147] waiting for client connection
[20240122 104811][Pid: 459147] call idle function
[20240122 104811][Pid: 459147] waiting for client connection
    ...
[20240122 105211][Pid: 459147] call idle function
[20240122 105211][Pid: 459147] waiting for client connection

But I expected this output instead:

$ php -d extension=pcntl server.php
[20240122 104636] Server started
[20240122 104636] Created child: 459146
[20240122 104636][Pid: 459146] child started
[20240122 104636][Pid: 459146] waiting for client connection
[20240122 104637] Created child: 459147
[20240122 104637][Pid: 459147] child started
[20240122 104637][Pid: 459147] waiting for client connection
[20240122 104643][Pid: 459147] client connected, processing...
[20240122 104643][Pid: 459147] waiting for client connection
[20240122 104646][Pid: 459146] call idle function
[20240122 104646][Pid: 459146] waiting for client connection
[20240122 104653][Pid: 459147] call idle function
[20240122 104653][Pid: 459147] waiting for client connection
[20240122 104656][Pid: 459146] call idle function
[20240122 104656][Pid: 459146] waiting for client connection
[20240122 104703][Pid: 459147] call idle function
[20240122 104703][Pid: 459147] waiting for client connection
[20240122 104706][Pid: 459146] call idle function
[20240122 104706][Pid: 459146] waiting for client connection
[20240122 104707][Pid: 459146] client connected, processing...
[20240122 104707][Pid: 459146] waiting for client connection
[20240122 104708][Pid: 459147] client connected, processing...
[20240122 104708][Pid: 459147] waiting for client connection
[20240122 104708][Pid: 459146] client connected, processing...
[20240122 104708][Pid: 459146] waiting for client connection
[20240122 104709][Pid: 459147] client connected, processing...
[20240122 104709][Pid: 459147] waiting for client connection
[20240122 104710][Pid: 459146] client connected, processing...
[20240122 104710][Pid: 459146] waiting for client connection
[20240122 104719][Pid: 459147] call idle function
[20240122 104719][Pid: 459147] waiting for client connection
[20240122 104720][Pid: 459146] call idle function
[20240122 104720][Pid: 459146] waiting for client connection
[20240122 104729][Pid: 459147] call idle function
[20240122 104729][Pid: 459147] waiting for client connection
[20240122 104730][Pid: 459146] call idle function
[20240122 104730][Pid: 459146] waiting for client connection
    ...

The problem:

Server forked 2 child processes (459146, 459147), that wait for client connections (stream_socket_accept) with 10 sec timeout.
If a client connected to one of the processes, then that will process the request, and wait a client connection again with 10 sec timeout.
If not client connected within 10 sec (timeout), then run idle function and wait to a client connection again with 10 sec timeout.
Unfortunately, after a few client request connection, if again wait to a client connection, then only one process (459146) wait with timeout, the another process (459147) wait infinity. If connect to this other process (459147), this thing will be swapped...

Is the problem in PHP? Or with OS? Or with VPS?

PHP Version

PHP 8.3+

Operating System

Rocky Linux 9.3 (6.6.10-1.el9)

bukka commented 8 months ago

I have been playing with this and did a bit of debugging.

This is expected behavior. The reason is that you are essential using pre-forking (forking between listen and accept). That means that you have two processes to accept connection in parallel. The part that you might not know that the timeout is done using poll (checking if listened socket is readable - meaning there is connection to accept available) before doing the actual accept. The issue that you see is that both process polling at the same time on the same socket. If there is a connection available, it will unblock both of them, but only one of them will accept the connection and the other one will be blocked. But this time it will be blocked on the actual accept that does not have any timeout. It will get unblocked when there is a new connection but the other process might get released by poll and then get blocked on accept again (that's why you see it swapped). This might not be always the case and I saw that sometimes it just release accept but does not release poll because before it gets that info, the connection is already accepted by other process. Hope it makes sense.

If you really wanted to make sure that the timeout is always applied, then use non blocking socket or use some sort of IPC (e.g. using pipes). In any case this is not a PHP issue but more issue with your code.

l-aloha commented 8 months ago

Thank you for your reply and help! But the main problem is that the acceptTimeOut is not working, if there is no request, I would like to call the idleFunction() every 10 seconds. However, unfortunately, this does not happen, because after a while it only works for one process at a time.

bukka commented 8 months ago

Yes that's what I described above. It's because there's some sort of race condition between polling and accepting so it get stuck on accept where it is not possible to set timeout. This cannot be handled without external synchronization which you need to do in your application or use non blocking socket as I mentioned above.

bukka commented 6 months ago

I will actually re-open this as it might be possible to get around this by making accept non blocking (changing socket to non blocking and then back to blocking if it was blocking). We do similar thing in OpenSSL so this could be applied here as well.