diff --git a/.docker/php/7.1/Dockerfile b/.docker/php/7.1/Dockerfile index ebc0cfb..0d0dc12 100644 --- a/.docker/php/7.1/Dockerfile +++ b/.docker/php/7.1/Dockerfile @@ -1,10 +1,12 @@ FROM php:7.1-fpm-alpine +ENV XDEBUG_VERSION=2.7.2 ENV PHP_CONF_DIR=/usr/local/etc -RUN apk update && apk upgrade && apk add --no-cache $PHPIZE_DEPS \ - && pecl install xdebug-2.7.1 \ +RUN apk update && apk upgrade && apk add --no-cache ${PHPIZE_DEPS} \ + && pecl install xdebug-${XDEBUG_VERSION} \ && docker-php-ext-enable xdebug \ - && apk del $PHPIZE_DEPS -COPY network-socket.pool.conf $PHP_CONF_DIR/php-fpm.d/network-socket.pool.conf -COPY restricted-unix-domain-socket.pool.conf $PHP_CONF_DIR/php-fpm.d/restricted-unix-domain-socket.pool.conf -COPY unix-domain-socket.pool.conf $PHP_CONF_DIR/php-fpm.d/unix-domain-socket.pool.conf + && apk del ${PHPIZE_DEPS} \ + && rm -rf /var/cache/apk/* +COPY network-socket.pool.conf ${PHP_CONF_DIR}/php-fpm.d/network-socket.pool.conf +COPY restricted-unix-domain-socket.pool.conf ${PHP_CONF_DIR}/php-fpm.d/restricted-unix-domain-socket.pool.conf +COPY unix-domain-socket.pool.conf ${PHP_CONF_DIR}/php-fpm.d/unix-domain-socket.pool.conf WORKDIR /repo \ No newline at end of file diff --git a/.docker/php/7.2/Dockerfile b/.docker/php/7.2/Dockerfile index 623f0d8..44fc663 100644 --- a/.docker/php/7.2/Dockerfile +++ b/.docker/php/7.2/Dockerfile @@ -1,10 +1,12 @@ FROM php:7.2-fpm-alpine +ENV XDEBUG_VERSION=2.7.2 ENV PHP_CONF_DIR=/usr/local/etc -RUN apk update && apk upgrade && apk add --no-cache $PHPIZE_DEPS \ - && pecl install xdebug-2.7.1 \ +RUN apk update && apk upgrade && apk add --no-cache ${PHPIZE_DEPS} \ + && pecl install xdebug-${XDEBUG_VERSION} \ && docker-php-ext-enable xdebug \ - && apk del $PHPIZE_DEPS -COPY network-socket.pool.conf $PHP_CONF_DIR/php-fpm.d/network-socket.pool.conf -COPY restricted-unix-domain-socket.pool.conf $PHP_CONF_DIR/php-fpm.d/restricted-unix-domain-socket.pool.conf -COPY unix-domain-socket.pool.conf $PHP_CONF_DIR/php-fpm.d/unix-domain-socket.pool.conf + && apk del ${PHPIZE_DEPS} \ + && rm -rf /var/cache/apk/* +COPY network-socket.pool.conf ${PHP_CONF_DIR}/php-fpm.d/network-socket.pool.conf +COPY restricted-unix-domain-socket.pool.conf ${PHP_CONF_DIR}/php-fpm.d/restricted-unix-domain-socket.pool.conf +COPY unix-domain-socket.pool.conf ${PHP_CONF_DIR}/php-fpm.d/unix-domain-socket.pool.conf WORKDIR /repo \ No newline at end of file diff --git a/.docker/php/7.3/Dockerfile b/.docker/php/7.3/Dockerfile index fc1b98a..fd1923a 100644 --- a/.docker/php/7.3/Dockerfile +++ b/.docker/php/7.3/Dockerfile @@ -1,10 +1,12 @@ FROM php:7.3-fpm-alpine +ENV XDEBUG_VERSION=2.7.2 ENV PHP_CONF_DIR=/usr/local/etc -RUN apk update && apk upgrade && apk add --no-cache $PHPIZE_DEPS \ - && pecl install xdebug-2.7.1 \ +RUN apk update && apk upgrade && apk add --no-cache ${PHPIZE_DEPS} \ + && pecl install xdebug-${XDEBUG_VERSION} \ && docker-php-ext-enable xdebug \ - && apk del $PHPIZE_DEPS -COPY network-socket.pool.conf $PHP_CONF_DIR/php-fpm.d/network-socket.pool.conf -COPY restricted-unix-domain-socket.pool.conf $PHP_CONF_DIR/php-fpm.d/restricted-unix-domain-socket.pool.conf -COPY unix-domain-socket.pool.conf $PHP_CONF_DIR/php-fpm.d/unix-domain-socket.pool.conf + && apk del ${PHPIZE_DEPS} \ + && rm -rf /var/cache/apk/* +COPY network-socket.pool.conf ${PHP_CONF_DIR}/php-fpm.d/network-socket.pool.conf +COPY restricted-unix-domain-socket.pool.conf ${PHP_CONF_DIR}/php-fpm.d/restricted-unix-domain-socket.pool.conf +COPY unix-domain-socket.pool.conf ${PHP_CONF_DIR}/php-fpm.d/unix-domain-socket.pool.conf WORKDIR /repo \ No newline at end of file diff --git a/.gitignore b/.gitignore index b65a748..9c6ea52 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ /.idea/ /vendor/ /build/logs/ -/.phpunit.result.cache +/build/.phpunit.result.cache /composer.lock \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 47a746c..efee02d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/) and [Keep a CHANGELOG](http://keepachangelog.com). +## [2.7.2] - 2019-05-31 + +### Improved + +* Handling of `stream_select` returning `false` in case of a system call interrupt. - [#41] + +### Fixed + +* Remove/close sockets after fetching their responses triggered async requests in order to prevent halt on further + request processing, if the number of requests exceeds php-fpm's `pm.max_children` setting. - [#40] + ## [2.7.1] - 2019-04-29 ### Fixed @@ -193,6 +204,7 @@ Based on [Pierrick Charron](https://github.com/adoy)'s [PHP-FastCGI-Client](http * Getters/Setters for connect timeout, read/write timeout, keep alive, socket persistence from `Client` (now part of the socket connection) * Method `Client->getValues()` +[2.7.2]: https://github.com/hollodotme/fast-cgi-client/compare/v2.7.1...v2.7.2 [2.7.1]: https://github.com/hollodotme/fast-cgi-client/compare/v2.7.0...v2.7.1 [2.7.0]: https://github.com/hollodotme/fast-cgi-client/compare/v2.6.0...v2.7.0 [2.6.0]: https://github.com/hollodotme/fast-cgi-client/compare/v2.5.0...v2.6.0 @@ -221,3 +233,5 @@ Based on [Pierrick Charron](https://github.com/adoy)'s [PHP-FastCGI-Client](http [#27]: https://github.com/hollodotme/fast-cgi-client/issues/27 [#33]: https://github.com/hollodotme/fast-cgi-client/pull/33 [#37]: https://github.com/hollodotme/fast-cgi-client/issue/37 +[#40]: https://github.com/hollodotme/fast-cgi-client/issue/40 +[#41]: https://github.com/hollodotme/fast-cgi-client/issue/41 diff --git a/composer.json b/composer.json index f34e504..41716ee 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,8 @@ "keywords": [ "fastcgi", "php-fpm", - "socket" + "socket", + "async" ], "minimum-stability": "dev", "prefer-stable": true, diff --git a/src/Client.php b/src/Client.php index 132e47e..18f5ea5 100644 --- a/src/Client.php +++ b/src/Client.php @@ -172,10 +172,7 @@ public function waitForResponses( ?int $timeoutMs = null ) : void while ( $this->hasUnhandledResponses() ) { - foreach ( $this->getSocketsHavingResponse() as $socket ) - { - $this->fetchResponseAndNotifyCallback( $socket, $timeoutMs ); - } + $this->handleReadyResponses( $timeoutMs ); } } @@ -193,10 +190,12 @@ private function fetchResponseAndNotifyCallback( Socket $socket, ?int $timeoutMs } catch ( Throwable $e ) { - $this->sockets->remove( $socket->getId() ); - $socket->notifyFailureCallbacks( $e ); } + finally + { + $this->sockets->remove( $socket->getId() ); + } } /** @@ -207,18 +206,6 @@ public function hasUnhandledResponses() : bool return $this->sockets->hasBusySockets(); } - /** - * @return Generator|Socket[] - * @throws ReadFailedException - */ - private function getSocketsHavingResponse() : Generator - { - foreach ( $this->getRequestIdsHavingResponse() as $requestId ) - { - yield $this->sockets->getById( $requestId ); - } - } - /** * @param int $requestId * @@ -244,7 +231,12 @@ public function getRequestIdsHavingResponse() : array $reads = $this->sockets->collectResources(); $writes = $excepts = null; - stream_select( $reads, $writes, $excepts, 0, Socket::STREAM_SELECT_USEC ); + $result = @stream_select( $reads, $writes, $excepts, 0, Socket::STREAM_SELECT_USEC ); + + if ( false === $result || 0 === count( $reads ) ) + { + return []; + } return $this->sockets->getSocketIdsByResources( $reads ); } @@ -264,6 +256,10 @@ public function readResponses( ?int $timeoutMs = null, int ...$requestIds ) : Ge yield $this->sockets->getById( $requestId )->fetchResponse( $timeoutMs ); } catch ( Throwable $e ) + { + # Skip unknown request ids + } + finally { $this->sockets->remove( $requestId ); } diff --git a/src/Sockets/Socket.php b/src/Sockets/Socket.php index 3081c64..6e0318b 100644 --- a/src/Sockets/Socket.php +++ b/src/Sockets/Socket.php @@ -52,8 +52,10 @@ use function stream_select; use function stream_set_timeout; use function stream_socket_client; +use function stream_socket_shutdown; use function strlen; use function substr; +use const STREAM_SHUT_RDWR; final class Socket { @@ -541,6 +543,7 @@ private function disconnect() : void { if ( is_resource( $this->resource ) ) { + @stream_socket_shutdown( $this->resource, STREAM_SHUT_RDWR ); fclose( $this->resource ); } } diff --git a/tests/Integration/AsyncRequestsTest.php b/tests/Integration/AsyncRequestsTest.php new file mode 100644 index 0000000..053340d --- /dev/null +++ b/tests/Integration/AsyncRequestsTest.php @@ -0,0 +1,239 @@ +getMaxChildrenSettingFromNetworkSocket(); + $limit = $maxChildren + 5; + + $this->assertTrue( $limit > 5 ); + + $client = $this->getClientWithNetworkSocket(); + $results = []; + $expectedResults = range( 0, $limit - 1 ); + + $request = new PostRequest( __DIR__ . '/Workers/worker.php', '' ); + $request->addResponseCallbacks( + static function ( ProvidesResponseData $response ) use ( &$results ) + { + $results[] = (int)$response->getBody(); + } + ); + + for ( $i = 0; $i < $limit; $i++ ) + { + $request->setContent( http_build_query( ['test-key' => $i] ) ); + + $client->sendAsyncRequest( $request ); + } + + $client->waitForResponses(); + + sort( $results ); + + $this->assertSame( $expectedResults, $results ); + } + + private function getMaxChildrenSettingFromNetworkSocket() : int + { + $iniSettings = parse_ini_file( + __DIR__ . '/../../.docker/php/network-socket.pool.conf', + true + ); + + return (int)$iniSettings['network']['pm.max_children']; + } + + private function getClientWithNetworkSocket() : Client + { + $networkSocket = new NetworkSocket( + $this->getNetworkSocketHost(), + $this->getNetworkSocketPort() + ); + + return new Client( $networkSocket ); + } + + /** + * @throws ConnectException + * @throws ExpectationFailedException + * @throws InvalidArgumentException + * @throws ReadFailedException + * @throws Throwable + * @throws TimedoutException + * @throws WriteFailedException + */ + public function testAsyncRequestsWillRespondToCallbackIfRequestsExceedPhpFpmMaxChildrenSettingOnUnixDomainSocket( + ) : void + { + $maxChildren = $this->getMaxChildrenSettingFromUnixDomainSocket(); + $limit = $maxChildren + 5; + + $this->assertTrue( $limit > 5 ); + + $client = $this->getClientWithUnixDomainSocket(); + $results = []; + $expectedResults = range( 0, $limit - 1 ); + + $request = new PostRequest( __DIR__ . '/Workers/worker.php', '' ); + $request->addResponseCallbacks( + static function ( ProvidesResponseData $response ) use ( &$results ) + { + $results[] = (int)$response->getBody(); + } + ); + + for ( $i = 0; $i < $limit; $i++ ) + { + $request->setContent( http_build_query( ['test-key' => $i] ) ); + + $client->sendAsyncRequest( $request ); + } + + $client->waitForResponses(); + + sort( $results ); + + $this->assertSame( $expectedResults, $results ); + } + + private function getMaxChildrenSettingFromUnixDomainSocket() : int + { + $iniSettings = parse_ini_file( + __DIR__ . '/../../.docker/php/unix-domain-socket.pool.conf', + true + ); + + return (int)$iniSettings['uds']['pm.max_children']; + } + + private function getClientWithUnixDomainSocket() : Client + { + $unixDomainSocket = new UnixDomainSocket( $this->getUnixDomainSocket() ); + + return new Client( $unixDomainSocket ); + } + + /** + * @throws ConnectException + * @throws ExpectationFailedException + * @throws InvalidArgumentException + * @throws ReadFailedException + * @throws TimedoutException + * @throws WriteFailedException + */ + public function testCanReadResponsesOfAsyncRequestsIfRequestsExceedPhpFpmMaxChildrenSettingOnNetworkSocket() : void + { + $maxChildren = $this->getMaxChildrenSettingFromNetworkSocket(); + $limit = $maxChildren + 5; + + $this->assertTrue( $limit > 5 ); + + $client = $this->getClientWithNetworkSocket(); + $results = []; + $expectedResults = range( 0, $limit - 1 ); + + $request = new PostRequest( __DIR__ . '/Workers/worker.php', '' ); + + for ( $i = 0; $i < $limit; $i++ ) + { + $request->setContent( http_build_query( ['test-key' => $i] ) ); + + $client->sendAsyncRequest( $request ); + } + + while ( $client->hasUnhandledResponses() ) + { + foreach ( $client->readReadyResponses() as $response ) + { + $results[] = (int)$response->getBody(); + } + } + + sort( $results ); + + $this->assertSame( $expectedResults, $results ); + } + + /** + * @throws ConnectException + * @throws ExpectationFailedException + * @throws InvalidArgumentException + * @throws ReadFailedException + * @throws TimedoutException + * @throws WriteFailedException + */ + public function testCanReadResponsesOfAsyncRequestsIfRequestsExceedPhpFpmMaxChildrenSettingOnUnixDomainSocket( + ) : void + { + $maxChildren = $this->getMaxChildrenSettingFromUnixDomainSocket(); + $limit = $maxChildren + 5; + + $this->assertTrue( $limit > 5 ); + + $client = $this->getClientWithUnixDomainSocket(); + $results = []; + $expectedResults = range( 0, $limit - 1 ); + + $request = new PostRequest( __DIR__ . '/Workers/worker.php', '' ); + $request->addResponseCallbacks( + static function ( ProvidesResponseData $response ) use ( &$results ) + { + $results[] = (int)$response->getBody(); + } + ); + + for ( $i = 0; $i < $limit; $i++ ) + { + $request->setContent( http_build_query( ['test-key' => $i] ) ); + + $client->sendAsyncRequest( $request ); + } + + while ( $client->hasUnhandledResponses() ) + { + foreach ( $client->readReadyResponses() as $response ) + { + $results[] = (int)$response->getBody(); + } + } + + sort( $results ); + + $this->assertSame( $expectedResults, $results ); + } +} diff --git a/tests/Integration/SignaledWorkersTest.php b/tests/Integration/SignaledWorkersTest.php new file mode 100644 index 0000000..1439912 --- /dev/null +++ b/tests/Integration/SignaledWorkersTest.php @@ -0,0 +1,318 @@ +getClientWithNetworkSocket(); + $request = new PostRequest( __DIR__ . '/Workers/worker.php', '' ); + $success = []; + $failures = []; + + $request->addResponseCallbacks( + static function ( ProvidesResponseData $response ) use ( &$success ) + { + $success[] = (int)$response->getBody(); + } + ); + + $request->addFailureCallbacks( + static function ( Throwable $e ) use ( &$failures ) + { + $failures[] = $e; + } + ); + + for ( $i = 0; $i < 3; $i++ ) + { + $request->setContent( http_build_query( ['test-key' => $i] ) ); + + $client->sendAsyncRequest( $request ); + } + + $pids = $this->getPoolWorkerPIDs( 'pool network' ); + + $this->killPoolWorker( (int)$pids[0], $signal ); + + $client->waitForResponses(); + + $this->assertCount( 2, $success ); + $this->assertCount( 1, $failures ); + $this->assertContainsOnlyInstancesOf( ReadFailedException::class, $failures ); + + sleep( 1 ); + } + + public function signalProvider() : array + { + return [ + [ + # SIGHUP + 'signal' => 1, + ], + [ + # SIGINT + 'signal' => 2, + ], + [ + # SIGKILL + 'signal' => 9, + ], + [ + # SIGTERM + 'signal' => 15, + ], + ]; + } + + private function getClientWithNetworkSocket() : Client + { + $networkSocket = new NetworkSocket( + $this->getNetworkSocketHost(), + $this->getNetworkSocketPort() + ); + + return new Client( $networkSocket ); + } + + private function getPoolWorkerPIDs( string $poolName ) : array + { + $command = sprintf( + 'ps -o pid,args | grep %s | grep -v "grep"', + escapeshellarg( $poolName ) + ); + $list = shell_exec( $command ); + + return array_map( + static function ( string $item ) + { + preg_match( '#^(\d+)\s.+$#', trim( $item ), $matches ); + + return (int)$matches[1]; + }, + explode( "\n", trim( $list ) ) + ); + } + + private function killPoolWorker( int $PID, int $signal ) : void + { + $command = sprintf( 'kill -%d %d', $signal, $PID ); + exec( $command ); + } + + /** + * @param int $signal + * + * @throws ConnectException + * @throws ExpectationFailedException + * @throws InvalidArgumentException + * @throws ReadFailedException + * @throws Throwable + * @throws TimedoutException + * @throws WriteFailedException + * @dataProvider signalProvider + */ + public function testFailureCallbackGetsCalledIfOneProcessGetsInterruptedOnUnixDomainSocket( int $signal ) : void + { + $client = $this->getClientWithUnixDomainSocket(); + $request = new PostRequest( __DIR__ . '/Workers/worker.php', '' ); + $success = []; + $failures = []; + + $request->addResponseCallbacks( + static function ( ProvidesResponseData $response ) use ( &$success ) + { + $success[] = (int)$response->getBody(); + } + ); + + $request->addFailureCallbacks( + static function ( Throwable $e ) use ( &$failures ) + { + $failures[] = $e; + } + ); + + for ( $i = 0; $i < 3; $i++ ) + { + $request->setContent( http_build_query( ['test-key' => $i] ) ); + + $client->sendAsyncRequest( $request ); + } + + $pids = $this->getPoolWorkerPIDs( 'pool uds' ); + + $this->killPoolWorker( (int)$pids[0], $signal ); + + $client->waitForResponses(); + + $this->assertCount( 2, $success ); + $this->assertCount( 1, $failures ); + $this->assertContainsOnlyInstancesOf( ReadFailedException::class, $failures ); + + sleep( 1 ); + } + + private function getClientWithUnixDomainSocket() : Client + { + $unixDomainSocket = new UnixDomainSocket( $this->getUnixDomainSocket() ); + + return new Client( $unixDomainSocket ); + } + + /** + * @param int $signal + * + * @throws ConnectException + * @throws ExpectationFailedException + * @throws \InvalidArgumentException + * @throws ReadFailedException + * @throws Throwable + * @throws TimedoutException + * @throws WriteFailedException + * + * @dataProvider signalProvider + */ + public function testFailureCallbackGetsCalledIfAllProcessesGetInterruptedOnNetworkSocket( int $signal ) : void + { + $client = $this->getClientWithNetworkSocket(); + $request = new PostRequest( __DIR__ . '/Workers/sleepWorker.php', '' ); + $success = []; + $failures = []; + + $request->addResponseCallbacks( + static function ( ProvidesResponseData $response ) use ( &$success ) + { + $success[] = (int)$response->getBody(); + } + ); + + $request->addFailureCallbacks( + static function ( Throwable $e ) use ( &$failures ) + { + $failures[] = $e; + } + ); + + for ( $i = 0; $i < 3; $i++ ) + { + $request->setContent( http_build_query( ['test-key' => $i, 'sleep' => 1] ) ); + + $client->sendAsyncRequest( $request ); + } + + $this->killPhpFpmChildProcesses( 'pool network', $signal ); + + $client->waitForResponses(); + + $this->assertCount( 0, $success ); + $this->assertCount( 3, $failures ); + $this->assertContainsOnlyInstancesOf( ReadFailedException::class, $failures ); + + sleep( 1 ); + } + + private function killPhpFpmChildProcesses( string $poolName, int $signal ) : void + { + $PIDs = $this->getPoolWorkerPIDs( $poolName ); + $this->killPoolWorkers( $PIDs, $signal ); + } + + private function killPoolWorkers( array $PIDs, int $signal ) : void + { + $command = sprintf( 'kill -%d %s', $signal, implode( ' ', $PIDs ) ); + exec( $command ); + } + + /** + * @param int $signal + * + * @throws ConnectException + * @throws ExpectationFailedException + * @throws \InvalidArgumentException + * @throws ReadFailedException + * @throws Throwable + * @throws TimedoutException + * @throws WriteFailedException + * + * @dataProvider signalProvider + */ + public function testFailureCallbackGetsCalledIfAllProcessesGetInterruptedOnUnixDomainSocket( int $signal ) : void + { + $client = $this->getClientWithUnixDomainSocket(); + $request = new PostRequest( __DIR__ . '/Workers/sleepWorker.php', '' ); + $success = []; + $failures = []; + + $request->addResponseCallbacks( + static function ( ProvidesResponseData $response ) use ( &$success ) + { + $success[] = (int)$response->getBody(); + } + ); + + $request->addFailureCallbacks( + static function ( Throwable $e ) use ( &$failures ) + { + $failures[] = $e; + } + ); + + for ( $i = 0; $i < 3; $i++ ) + { + $request->setContent( http_build_query( ['test-key' => $i, 'sleep' => 1] ) ); + + $client->sendAsyncRequest( $request ); + } + + $this->killPhpFpmChildProcesses( 'pool uds', $signal ); + + $client->waitForResponses(); + + $this->assertCount( 0, $success ); + $this->assertCount( 3, $failures ); + $this->assertContainsOnlyInstancesOf( ReadFailedException::class, $failures ); + + sleep( 1 ); + } +} diff --git a/tests/runTestsOnAllLocalPhpVersions.sh b/tests/runTestsOnAllLocalPhpVersions.sh index decf861..722d07d 100755 --- a/tests/runTestsOnAllLocalPhpVersions.sh +++ b/tests/runTestsOnAllLocalPhpVersions.sh @@ -3,12 +3,15 @@ DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )")" cd "$(dirname "${DIR}" )" >/dev/null 2>&1 -docker-compose up -d +docker-compose up -d php71 php72 php73 echo -e "\n\033[43mRun PHPUnit\033[0m\n" +echo -e "\n\033[33mOn PHP 7.1\033[0m\n" docker-compose exec php71 vendor/bin/phpunit7.phar -c build +echo -e "\n\033[33mOn PHP 7.2\033[0m\n" docker-compose exec php72 vendor/bin/phpunit8.phar -c build +echo -e "\n\033[33mOn PHP 7.3\033[0m\n" docker-compose exec php73 vendor/bin/phpunit8.phar -c build echo -e "\n\033[43mRun phpstan\033[0m\n" -docker-compose run phpstan \ No newline at end of file +docker-compose run --rm phpstan \ No newline at end of file