diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 4cacc482991ba..15901096db2bb 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -122,11 +122,17 @@ jobs: image: dunglas/frankenphp:1.1.0 ports: - 80:80 + - 8081:81 + - 8082:82 volumes: - ${{ github.workspace }}:/symfony env: - SERVER_NAME: 'http://localhost' + SERVER_NAME: 'http://localhost http://localhost:81 http://localhost:82' CADDY_SERVER_EXTRA_DIRECTIVES: | + route /http-client* { + root * /symfony/src/Symfony/Component/HttpClient/Tests/Fixtures/response-functional/ + php_server + } root * /symfony/src/Symfony/Component/HttpFoundation/Tests/Fixtures/response-functional/ steps: diff --git a/src/Symfony/Component/HttpClient/CHANGELOG.md b/src/Symfony/Component/HttpClient/CHANGELOG.md index 5c70b9b3d4f6e..387e3879e91b5 100644 --- a/src/Symfony/Component/HttpClient/CHANGELOG.md +++ b/src/Symfony/Component/HttpClient/CHANGELOG.md @@ -5,6 +5,7 @@ CHANGELOG --- * Add support for amphp/http-client v5 on PHP 8.4+ + * Allow setting max persistent connections in `CurlHttpClient` 7.1 --- diff --git a/src/Symfony/Component/HttpClient/CurlHttpClient.php b/src/Symfony/Component/HttpClient/CurlHttpClient.php index 3b0196f69d972..bae03baff6d6d 100644 --- a/src/Symfony/Component/HttpClient/CurlHttpClient.php +++ b/src/Symfony/Component/HttpClient/CurlHttpClient.php @@ -45,6 +45,7 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface, // password as the second one; or string like username:password - enabling NTLM auth 'extra' => [ 'curl' => [], // A list of extra curl options indexed by their corresponding CURLOPT_* + 'max_connections' => null, // The maximum amount of simultaneously open connections, corresponds to CURLMOPT_MAXCONNECTS ], ]; private static array $emptyDefaults = self::OPTIONS_DEFAULTS + ['auth_ntlm' => null]; @@ -450,7 +451,7 @@ private static function createRedirectResolver(array $options, string $host, int private function ensureState(): CurlClientState { if (!isset($this->multi)) { - $this->multi = new CurlClientState($this->maxHostConnections, $this->maxPendingPushes); + $this->multi = new CurlClientState($this->maxHostConnections, $this->maxPendingPushes, $this->defaultOptions['extra']['max_connections'] ?? null); $this->multi->logger = $this->logger; } diff --git a/src/Symfony/Component/HttpClient/Internal/CurlClientState.php b/src/Symfony/Component/HttpClient/Internal/CurlClientState.php index 208325658fad6..7b3a5b3774a7e 100644 --- a/src/Symfony/Component/HttpClient/Internal/CurlClientState.php +++ b/src/Symfony/Component/HttpClient/Internal/CurlClientState.php @@ -37,7 +37,7 @@ final class CurlClientState extends ClientState public static array $curlVersion; - public function __construct(int $maxHostConnections, int $maxPendingPushes) + public function __construct(int $maxHostConnections, int $maxPendingPushes, ?int $maxConnections = null) { self::$curlVersion ??= curl_version(); @@ -52,8 +52,8 @@ public function __construct(int $maxHostConnections, int $maxPendingPushes) if (\defined('CURLMOPT_MAX_HOST_CONNECTIONS')) { $maxHostConnections = curl_multi_setopt($this->handle, \CURLMOPT_MAX_HOST_CONNECTIONS, 0 < $maxHostConnections ? $maxHostConnections : \PHP_INT_MAX) ? 0 : $maxHostConnections; } - if (\defined('CURLMOPT_MAXCONNECTS') && 0 < $maxHostConnections) { - curl_multi_setopt($this->handle, \CURLMOPT_MAXCONNECTS, $maxHostConnections); + if (\defined('CURLMOPT_MAXCONNECTS') && null !== $maxConnections ??= 0 < $maxHostConnections ? $maxHostConnections : null) { + curl_multi_setopt($this->handle, \CURLMOPT_MAXCONNECTS, $maxConnections); } // Skip configuring HTTP/2 push when it's unsupported or buggy, see https://bugs.php.net/77535 diff --git a/src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php index 03a939b559e1f..cb95db569f6ee 100644 --- a/src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php @@ -20,17 +20,17 @@ */ class CurlHttpClientTest extends HttpClientTestCase { - protected function getHttpClient(string $testCase): HttpClientInterface + protected function getHttpClient(string $testCase, array $options = []): HttpClientInterface { if (!str_contains($testCase, 'Push')) { - return new CurlHttpClient(['verify_peer' => false, 'verify_host' => false]); + return new CurlHttpClient(['verify_peer' => false, 'verify_host' => false] + $options); } if (!\defined('CURLMOPT_PUSHFUNCTION') || 0x073D00 > ($v = curl_version())['version_number'] || !(\CURL_VERSION_HTTP2 & $v['features'])) { $this->markTestSkipped('curl <7.61 is used or it is not compiled with support for HTTP/2 PUSH'); } - return new CurlHttpClient(['verify_peer' => false, 'verify_host' => false], 6, 50); + return new CurlHttpClient(['verify_peer' => false, 'verify_host' => false] + $options, 6, 50); } public function testBindToPort() @@ -138,4 +138,58 @@ public function testKeepAuthorizationHeaderOnRedirectToSameHostWithConfiguredHos $this->assertSame(200, $response->getStatusCode()); $this->assertSame('/302', $response->toArray()['REQUEST_URI'] ?? null); } + + /** + * @group integration + * + * @dataProvider provideMaxConnectionCases + */ + public function testMaxConnections(?int $maxConnections, array $expectedResults) + { + foreach ($ports = [80, 8081, 8082] as $port) { + if (!($fp = @fsockopen('localhost', $port, $errorCode, $errorMessage, 2))) { + self::markTestSkipped('FrankenPHP is not running'); + } + fclose($fp); + } + + $httpClient = $this->getHttpClient(__FUNCTION__, ['extra' => ['max_connections' => $maxConnections]]); + + foreach ($expectedResults as $expectedResult) { + foreach ($ports as $i => $port) { + $response = $httpClient->request('GET', \sprintf('http://localhost:%s/http-client', $port)); + $response->getContent(); + + self::assertSame($expectedResult[$i], str_contains($response->getInfo('debug'), 'Re-using existing connection')); + } + } + } + + public static function provideMaxConnectionCases(): iterable + { + yield 'default' => [ + null, + [ + [false, false, false], + [true, true, true], + [true, true, true], + ], + ]; + yield 'one' => [ + 1, + [ + [false, false, false], + [false, false, false], + [false, false, false], + ], + ]; + yield 'exact' => [ + 3, + [ + [false, false, false], + [true, true, true], + [true, true, true], + ], + ]; + } } diff --git a/src/Symfony/Component/HttpClient/Tests/Fixtures/response-functional/index.php b/src/Symfony/Component/HttpClient/Tests/Fixtures/response-functional/index.php new file mode 100644 index 0000000000000..7a8076aaa8992 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Tests/Fixtures/response-functional/index.php @@ -0,0 +1,12 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +echo 'Success';