From d4266464fe38b50f83d9ec9a3f9cfde87ac6e45f Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Thu, 13 Jan 2022 16:11:29 +0100 Subject: [PATCH] [HttpClient] fix resetting DNS/etc when calling CurlHttpClient::reset() --- .../Component/HttpClient/CurlHttpClient.php | 6 +- .../HttpClient/Internal/CurlClientState.php | 49 ++++++-------- .../HttpClient/Response/CurlResponse.php | 66 ++++++++----------- .../HttpClient/Tests/CurlHttpClientTest.php | 4 +- 4 files changed, 52 insertions(+), 73 deletions(-) diff --git a/src/Symfony/Component/HttpClient/CurlHttpClient.php b/src/Symfony/Component/HttpClient/CurlHttpClient.php index f30c3435205c0..86e4d682fb54f 100644 --- a/src/Symfony/Component/HttpClient/CurlHttpClient.php +++ b/src/Symfony/Component/HttpClient/CurlHttpClient.php @@ -168,7 +168,6 @@ public function request(string $method, string $url, array $options = []): Respo if ($resolve && 0x072A00 > CurlClientState::$curlVersion['version_number']) { // DNS cache removals require curl 7.42 or higher - // On lower versions, we have to create a new multi handle $this->multi->reset(); } @@ -280,6 +279,7 @@ public function request(string $method, string $url, array $options = []): Respo if (!$pushedResponse) { $ch = curl_init(); $this->logger && $this->logger->info(sprintf('Request: "%s %s"', $method, $url)); + $curlopts += [\CURLOPT_SHARE => $this->multi->share]; } foreach ($curlopts as $opt => $value) { @@ -306,9 +306,9 @@ public function stream($responses, float $timeout = null): ResponseStreamInterfa throw new \TypeError(sprintf('"%s()" expects parameter 1 to be an iterable of CurlResponse objects, "%s" given.', __METHOD__, \is_object($responses) ? \get_class($responses) : \gettype($responses))); } - if (\is_resource($mh = $this->multi->handles[0] ?? null) || $mh instanceof \CurlMultiHandle) { + if (\is_resource($this->multi->handle) || $this->multi->handle instanceof \CurlMultiHandle) { $active = 0; - while (\CURLM_CALL_MULTI_PERFORM === curl_multi_exec($mh, $active)) { + while (\CURLM_CALL_MULTI_PERFORM === curl_multi_exec($this->multi->handle, $active)) { } } diff --git a/src/Symfony/Component/HttpClient/Internal/CurlClientState.php b/src/Symfony/Component/HttpClient/Internal/CurlClientState.php index c0782331ad52f..b7211b1b8f900 100644 --- a/src/Symfony/Component/HttpClient/Internal/CurlClientState.php +++ b/src/Symfony/Component/HttpClient/Internal/CurlClientState.php @@ -23,8 +23,10 @@ */ final class CurlClientState extends ClientState { - /** @var array<\CurlMultiHandle|resource> */ - public $handles = []; + /** @var \CurlMultiHandle|resource */ + public $handle; + /** @var \CurlShareHandle|resource */ + public $share; /** @var PushedResponse[] */ public $pushedResponses = []; /** @var DnsCache */ @@ -34,27 +36,23 @@ final class CurlClientState extends ClientState public static $curlVersion; - private $maxHostConnections; - private $maxPendingPushes; - public function __construct(int $maxHostConnections, int $maxPendingPushes) { self::$curlVersion = self::$curlVersion ?? curl_version(); - array_unshift($this->handles, $mh = curl_multi_init()); + $this->handle = curl_multi_init(); $this->dnsCache = new DnsCache(); - $this->maxHostConnections = $maxHostConnections; - $this->maxPendingPushes = $maxPendingPushes; + $this->reset(); // Don't enable HTTP/1.1 pipelining: it forces responses to be sent in order if (\defined('CURLPIPE_MULTIPLEX')) { - curl_multi_setopt($mh, \CURLMOPT_PIPELINING, \CURLPIPE_MULTIPLEX); + curl_multi_setopt($this->handle, \CURLMOPT_PIPELINING, \CURLPIPE_MULTIPLEX); } if (\defined('CURLMOPT_MAX_HOST_CONNECTIONS')) { - $maxHostConnections = curl_multi_setopt($mh, \CURLMOPT_MAX_HOST_CONNECTIONS, 0 < $maxHostConnections ? $maxHostConnections : \PHP_INT_MAX) ? 0 : $maxHostConnections; + $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($mh, \CURLMOPT_MAXCONNECTS, $maxHostConnections); + curl_multi_setopt($this->handle, \CURLMOPT_MAXCONNECTS, $maxHostConnections); } // Skip configuring HTTP/2 push when it's unsupported or buggy, see https://bugs.php.net/77535 @@ -67,17 +65,8 @@ public function __construct(int $maxHostConnections, int $maxPendingPushes) return; } - // Clone to prevent a circular reference - $multi = clone $this; - $multi->handles = [$mh]; - $multi->pushedResponses = &$this->pushedResponses; - $multi->logger = &$this->logger; - $multi->handlesActivity = &$this->handlesActivity; - $multi->openHandles = &$this->openHandles; - $multi->lastTimeout = &$this->lastTimeout; - - curl_multi_setopt($mh, \CURLMOPT_PUSHFUNCTION, static function ($parent, $pushed, array $requestHeaders) use ($multi, $maxPendingPushes) { - return $multi->handlePush($parent, $pushed, $requestHeaders, $maxPendingPushes); + curl_multi_setopt($this->handle, \CURLMOPT_PUSHFUNCTION, function ($parent, $pushed, array $requestHeaders) use ($maxPendingPushes) { + return $this->handlePush($parent, $pushed, $requestHeaders, $maxPendingPushes); }); } @@ -85,10 +74,7 @@ public function reset() { foreach ($this->pushedResponses as $url => $response) { $this->logger && $this->logger->debug(sprintf('Unused pushed response: "%s"', $url)); - - foreach ($this->handles as $mh) { - curl_multi_remove_handle($mh, $response->handle); - } + curl_multi_remove_handle($this->handle, $response->handle); curl_close($response->handle); } @@ -96,11 +82,14 @@ public function reset() $this->dnsCache->evictions = $this->dnsCache->evictions ?: $this->dnsCache->removals; $this->dnsCache->removals = $this->dnsCache->hostnames = []; - if (\defined('CURLMOPT_PUSHFUNCTION')) { - curl_multi_setopt($this->handles[0], \CURLMOPT_PUSHFUNCTION, null); - } + $this->share = curl_share_init(); + + curl_share_setopt($this->share, \CURLSHOPT_SHARE, \CURL_LOCK_DATA_DNS); + curl_share_setopt($this->share, \CURLSHOPT_SHARE, \CURL_LOCK_DATA_SSL_SESSION); - $this->__construct($this->maxHostConnections, $this->maxPendingPushes); + if (\defined('CURL_LOCK_DATA_CONNECT')) { + curl_share_setopt($this->share, \CURLSHOPT_SHARE, \CURL_LOCK_DATA_CONNECT); + } } private function handlePush($parent, $pushed, array $requestHeaders, int $maxPendingPushes): int diff --git a/src/Symfony/Component/HttpClient/Response/CurlResponse.php b/src/Symfony/Component/HttpClient/Response/CurlResponse.php index 2d0d76e83d67d..cbd70e9e07ce4 100644 --- a/src/Symfony/Component/HttpClient/Response/CurlResponse.php +++ b/src/Symfony/Component/HttpClient/Response/CurlResponse.php @@ -150,7 +150,7 @@ public function __construct(CurlClientState $multi, $ch, array $options = null, // Schedule the request in a non-blocking way $multi->lastTimeout = null; $multi->openHandles[$id] = [$ch, $options]; - curl_multi_add_handle($multi->handles[0], $ch); + curl_multi_add_handle($multi->handle, $ch); $this->canary = new Canary(static function () use ($ch, $multi, $id) { unset($multi->openHandles[$id], $multi->handlesActivity[$id]); @@ -160,9 +160,7 @@ public function __construct(CurlClientState $multi, $ch, array $options = null, return; } - foreach ($multi->handles as $mh) { - curl_multi_remove_handle($mh, $ch); - } + curl_multi_remove_handle($multi->handle, $ch); curl_setopt_array($ch, [ \CURLOPT_NOPROGRESS => true, \CURLOPT_PROGRESSFUNCTION => null, @@ -244,7 +242,7 @@ public function __destruct() */ private static function schedule(self $response, array &$runningResponses): void { - if (isset($runningResponses[$i = (int) $response->multi->handles[0]])) { + if (isset($runningResponses[$i = (int) $response->multi->handle])) { $runningResponses[$i][1][$response->id] = $response; } else { $runningResponses[$i] = [$response->multi, [$response->id => $response]]; @@ -276,47 +274,39 @@ private static function perform(ClientState $multi, array &$responses = null): v try { self::$performing = true; + $active = 0; + while (\CURLM_CALL_MULTI_PERFORM === ($err = curl_multi_exec($multi->handle, $active))) { + } - foreach ($multi->handles as $i => $mh) { - $active = 0; - while (\CURLM_CALL_MULTI_PERFORM === ($err = curl_multi_exec($mh, $active))) { - } + if (\CURLM_OK !== $err) { + throw new TransportException(curl_multi_strerror($err)); + } - if (\CURLM_OK !== $err) { - throw new TransportException(curl_multi_strerror($err)); + while ($info = curl_multi_info_read($multi->handle)) { + if (\CURLMSG_DONE !== $info['msg']) { + continue; } + $result = $info['result']; + $id = (int) $ch = $info['handle']; + $waitFor = @curl_getinfo($ch, \CURLINFO_PRIVATE) ?: '_0'; - while ($info = curl_multi_info_read($mh)) { - if (\CURLMSG_DONE !== $info['msg']) { - continue; - } - $result = $info['result']; - $id = (int) $ch = $info['handle']; - $waitFor = @curl_getinfo($ch, \CURLINFO_PRIVATE) ?: '_0'; - - if (\in_array($result, [\CURLE_SEND_ERROR, \CURLE_RECV_ERROR, /*CURLE_HTTP2*/ 16, /*CURLE_HTTP2_STREAM*/ 92], true) && $waitFor[1] && 'C' !== $waitFor[0]) { - curl_multi_remove_handle($mh, $ch); - $waitFor[1] = (string) ((int) $waitFor[1] - 1); // decrement the retry counter - curl_setopt($ch, \CURLOPT_PRIVATE, $waitFor); - curl_setopt($ch, \CURLOPT_FORBID_REUSE, true); - - if (0 === curl_multi_add_handle($mh, $ch)) { - continue; - } - } + if (\in_array($result, [\CURLE_SEND_ERROR, \CURLE_RECV_ERROR, /*CURLE_HTTP2*/ 16, /*CURLE_HTTP2_STREAM*/ 92], true) && $waitFor[1] && 'C' !== $waitFor[0]) { + curl_multi_remove_handle($multi->handle, $ch); + $waitFor[1] = (string) ((int) $waitFor[1] - 1); // decrement the retry counter + curl_setopt($ch, \CURLOPT_PRIVATE, $waitFor); + curl_setopt($ch, \CURLOPT_FORBID_REUSE, true); - if (\CURLE_RECV_ERROR === $result && 'H' === $waitFor[0] && 400 <= ($responses[(int) $ch]->info['http_code'] ?? 0)) { - $multi->handlesActivity[$id][] = new FirstChunk(); + if (0 === curl_multi_add_handle($multi->handle, $ch)) { + continue; } - - $multi->handlesActivity[$id][] = null; - $multi->handlesActivity[$id][] = \in_array($result, [\CURLE_OK, \CURLE_TOO_MANY_REDIRECTS], true) || '_0' === $waitFor || curl_getinfo($ch, \CURLINFO_SIZE_DOWNLOAD) === curl_getinfo($ch, \CURLINFO_CONTENT_LENGTH_DOWNLOAD) ? null : new TransportException(sprintf('%s for "%s".', curl_strerror($result), curl_getinfo($ch, \CURLINFO_EFFECTIVE_URL))); } - if (!$active && 0 < $i) { - curl_multi_close($mh); - unset($multi->handles[$i]); + if (\CURLE_RECV_ERROR === $result && 'H' === $waitFor[0] && 400 <= ($responses[(int) $ch]->info['http_code'] ?? 0)) { + $multi->handlesActivity[$id][] = new FirstChunk(); } + + $multi->handlesActivity[$id][] = null; + $multi->handlesActivity[$id][] = \in_array($result, [\CURLE_OK, \CURLE_TOO_MANY_REDIRECTS], true) || '_0' === $waitFor || curl_getinfo($ch, \CURLINFO_SIZE_DOWNLOAD) === curl_getinfo($ch, \CURLINFO_CONTENT_LENGTH_DOWNLOAD) ? null : new TransportException(sprintf('%s for "%s".', curl_strerror($result), curl_getinfo($ch, \CURLINFO_EFFECTIVE_URL))); } } finally { self::$performing = false; @@ -335,7 +325,7 @@ private static function select(ClientState $multi, float $timeout): int $timeout = min($timeout, 0.01); } - return curl_multi_select($multi->handles[array_key_last($multi->handles)], $timeout); + return curl_multi_select($multi->handle, $timeout); } /** diff --git a/src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php index c8bb52cd139d2..e932470d2a98c 100644 --- a/src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php @@ -143,9 +143,9 @@ public function testHandleIsReinitOnReset() $r = new \ReflectionProperty($httpClient, 'multi'); $r->setAccessible(true); $clientState = $r->getValue($httpClient); - $initialHandleId = (int) $clientState->handles[0]; + $initialShareId = $clientState->share; $httpClient->reset(); - self::assertNotSame($initialHandleId, (int) $clientState->handles[0]); + self::assertNotSame($initialShareId, $clientState->share); } public function testProcessAfterReset()