diff --git a/src/Symfony/Component/HttpClient/CurlHttpClient.php b/src/Symfony/Component/HttpClient/CurlHttpClient.php index f59069349e6f..e28ade68dc7e 100644 --- a/src/Symfony/Component/HttpClient/CurlHttpClient.php +++ b/src/Symfony/Component/HttpClient/CurlHttpClient.php @@ -15,6 +15,8 @@ use Psr\Log\LoggerAwareTrait; use Psr\Log\LoggerInterface; use Symfony\Component\HttpClient\Exception\TransportException; +use Symfony\Component\HttpClient\Internal\CurlClientState; +use Symfony\Component\HttpClient\Internal\PushedResponse; use Symfony\Component\HttpClient\Response\CurlResponse; use Symfony\Component\HttpClient\Response\ResponseStream; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -37,6 +39,12 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface use LoggerAwareTrait; private $defaultOptions = self::OPTIONS_DEFAULTS; + + /** + * An internal object to share state between the client and its responses. + * + * @var CurlClientState + */ private $multi; /** @@ -56,22 +64,13 @@ public function __construct(array $defaultOptions = [], int $maxHostConnections [, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, self::OPTIONS_DEFAULTS); } - $mh = curl_multi_init(); + $this->multi = $multi = new CurlClientState(); // 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->multi->handle, CURLMOPT_PIPELINING, CURLPIPE_MULTIPLEX); } - curl_multi_setopt($mh, CURLMOPT_MAX_HOST_CONNECTIONS, 0 < $maxHostConnections ? $maxHostConnections : PHP_INT_MAX); - - // Use an internal stdClass object to share state between the client and its responses - $this->multi = $multi = (object) [ - 'openHandles' => [], - 'handlesActivity' => [], - 'handle' => $mh, - 'pushedResponses' => [], - 'dnsCache' => [[], [], []], - ]; + curl_multi_setopt($this->multi->handle, CURLMOPT_MAX_HOST_CONNECTIONS, 0 < $maxHostConnections ? $maxHostConnections : PHP_INT_MAX); // Skip configuring HTTP/2 push when it's unsupported or buggy, see https://bugs.php.net/bug.php?id=77535 if (0 >= $maxPendingPushes || \PHP_VERSION_ID < 70217 || (\PHP_VERSION_ID >= 70300 && \PHP_VERSION_ID < 70304)) { @@ -85,7 +84,7 @@ public function __construct(array $defaultOptions = [], int $maxHostConnections $logger = &$this->logger; - curl_multi_setopt($mh, CURLMOPT_PUSHFUNCTION, static function ($parent, $pushed, array $requestHeaders) use ($multi, $maxPendingPushes, &$logger) { + curl_multi_setopt($this->multi->handle, CURLMOPT_PUSHFUNCTION, static function ($parent, $pushed, array $requestHeaders) use ($multi, $maxPendingPushes, &$logger) { return self::handlePush($parent, $pushed, $requestHeaders, $multi, $maxPendingPushes, $logger); }); } @@ -103,7 +102,7 @@ public function request(string $method, string $url, array $options = []): Respo $host = parse_url($authority, PHP_URL_HOST); $url = implode('', $url); - if ([$pushedResponse, $pushedHeaders] = $this->multi->pushedResponses[$url] ?? null) { + if ($pushedResponse = $this->multi->pushedResponses[$url] ?? null) { unset($this->multi->pushedResponses[$url]); // Accept pushed responses only if their headers related to authentication match the request $expectedHeaders = [ @@ -113,13 +112,13 @@ public function request(string $method, string $url, array $options = []): Respo $options['headers']['range'] ?? null, ]; - if ('GET' === $method && $expectedHeaders === $pushedHeaders && !$options['body']) { + if ('GET' === $method && $expectedHeaders === $pushedResponse->headers && !$options['body']) { $this->logger && $this->logger->debug(sprintf('Connecting request to pushed response: "%s %s"', $method, $url)); // Reinitialize the pushed response with request's options - $pushedResponse->__construct($this->multi, $url, $options, $this->logger); + $pushedResponse->response->__construct($this->multi, $url, $options, $this->logger); - return $pushedResponse; + return $pushedResponse->response; } $this->logger && $this->logger->debug(sprintf('Rejecting pushed response for "%s": authorization headers don\'t match the request', $url)); @@ -159,14 +158,14 @@ public function request(string $method, string $url, array $options = []): Respo } // curl's resolve feature varies by host:port but ours varies by host only, let's handle this with our own DNS map - if (isset($this->multi->dnsCache[0][$host])) { - $options['resolve'] += [$host => $this->multi->dnsCache[0][$host]]; + if (isset($this->multi->dnsCache->hostnames[$host])) { + $options['resolve'] += [$host => $this->multi->dnsCache->hostnames[$host]]; } - if ($options['resolve'] || $this->multi->dnsCache[2]) { + if ($options['resolve'] || $this->multi->dnsCache->evictions) { // First reset any old DNS cache entries then add the new ones - $resolve = $this->multi->dnsCache[2]; - $this->multi->dnsCache[2] = []; + $resolve = $this->multi->dnsCache->evictions; + $this->multi->dnsCache->evictions = []; $port = parse_url($authority, PHP_URL_PORT) ?: ('http:' === $scheme ? 80 : 443); if ($resolve && 0x072a00 > curl_version()['version_number']) { @@ -178,8 +177,8 @@ public function request(string $method, string $url, array $options = []): Respo foreach ($options['resolve'] as $host => $ip) { $resolve[] = null === $ip ? "-$host:$port" : "$host:$port:$ip"; - $this->multi->dnsCache[0][$host] = $ip; - $this->multi->dnsCache[1]["-$host:$port"] = "-$host:$port"; + $this->multi->dnsCache->hostnames[$host] = $ip; + $this->multi->dnsCache->removals["-$host:$port"] = "-$host:$port"; } $curlopts[CURLOPT_RESOLVE] = $resolve; @@ -299,7 +298,7 @@ public function __destruct() } } - private static function handlePush($parent, $pushed, array $requestHeaders, \stdClass $multi, int $maxPendingPushes, ?LoggerInterface $logger): int + private static function handlePush($parent, $pushed, array $requestHeaders, CurlClientState $multi, int $maxPendingPushes, ?LoggerInterface $logger): int { $headers = []; $origin = curl_getinfo($parent, CURLINFO_EFFECTIVE_URL); @@ -336,15 +335,15 @@ private static function handlePush($parent, $pushed, array $requestHeaders, \std $url .= $headers[':path']; $logger && $logger->debug(sprintf('Queueing pushed response: "%s"', $url)); - $multi->pushedResponses[$url] = [ + $multi->pushedResponses[$url] = new PushedResponse( new CurlResponse($multi, $pushed), [ $headers['authorization'] ?? null, $headers['cookie'] ?? null, $headers['x-requested-with'] ?? null, null, - ], - ]; + ] + ); return CURL_PUSH_OK; } diff --git a/src/Symfony/Component/HttpClient/Internal/ClientState.php b/src/Symfony/Component/HttpClient/Internal/ClientState.php new file mode 100644 index 000000000000..c316e7b07892 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Internal/ClientState.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient\Internal; + +/** + * Internal representation of the client state. + * + * @author Alexander M. Turek + * + * @internal + */ +class ClientState +{ + public $handlesActivity = []; + public $openHandles = []; +} diff --git a/src/Symfony/Component/HttpClient/Internal/CurlClientState.php b/src/Symfony/Component/HttpClient/Internal/CurlClientState.php new file mode 100644 index 000000000000..1c2e6c8eed48 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Internal/CurlClientState.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient\Internal; + +/** + * Internal representation of the cURL client's state. + * + * @author Alexander M. Turek + * + * @internal + */ +final class CurlClientState extends ClientState +{ + /** @var resource */ + public $handle; + /** @var PushedResponse[] */ + public $pushedResponses = []; + /** @var DnsCache */ + public $dnsCache; + + public function __construct() + { + $this->handle = curl_multi_init(); + $this->dnsCache = new DnsCache(); + } +} diff --git a/src/Symfony/Component/HttpClient/Internal/DnsCache.php b/src/Symfony/Component/HttpClient/Internal/DnsCache.php new file mode 100644 index 000000000000..bd23f77f8a05 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Internal/DnsCache.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient\Internal; + +/** + * Cache for resolved DNS queries. + * + * @author Alexander M. Turek + * + * @internal + */ +final class DnsCache +{ + /** + * Resolved hostnames (hostname => IP address). + * + * @var string[] + */ + public $hostnames = []; + + /** + * @var string[] + */ + public $removals = []; + + /** + * @var string[] + */ + public $evictions = []; +} diff --git a/src/Symfony/Component/HttpClient/Internal/NativeClientState.php b/src/Symfony/Component/HttpClient/Internal/NativeClientState.php new file mode 100644 index 000000000000..e82ce4c853d2 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Internal/NativeClientState.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient\Internal; + +use Symfony\Component\HttpClient\Response\NativeResponse; + +/** + * Internal representation of the native client's state. + * + * @author Alexander M. Turek + * + * @internal + */ +final class NativeClientState extends ClientState +{ + /** @var int */ + public $id; + /** @var NativeResponse[] */ + public $pendingResponses = []; + /** @var int */ + public $maxHostConnections = PHP_INT_MAX; + /** @var int */ + public $responseCount = 0; + /** @var string[] */ + public $dnsCache = []; + /** @var resource[] */ + public $handles = []; + /** @var bool */ + public $sleep = false; + + public function __construct() + { + $this->id = random_int(PHP_INT_MIN, PHP_INT_MAX); + } +} diff --git a/src/Symfony/Component/HttpClient/Internal/PushedResponse.php b/src/Symfony/Component/HttpClient/Internal/PushedResponse.php new file mode 100644 index 000000000000..632f0c41d055 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Internal/PushedResponse.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient\Internal; + +use Symfony\Component\HttpClient\Response\CurlResponse; + +/** + * A pushed response with headers. + * + * @author Alexander M. Turek + * + * @internal + */ +final class PushedResponse +{ + /** @var CurlResponse */ + public $response; + + /** @var string[] */ + public $headers; + + public function __construct(CurlResponse $response, array $headers) + { + $this->response = $response; + $this->headers = $headers; + } +} diff --git a/src/Symfony/Component/HttpClient/NativeHttpClient.php b/src/Symfony/Component/HttpClient/NativeHttpClient.php index 6ca9f6cce171..3442b50ef395 100644 --- a/src/Symfony/Component/HttpClient/NativeHttpClient.php +++ b/src/Symfony/Component/HttpClient/NativeHttpClient.php @@ -14,6 +14,7 @@ use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerAwareTrait; use Symfony\Component\HttpClient\Exception\TransportException; +use Symfony\Component\HttpClient\Internal\NativeClientState; use Symfony\Component\HttpClient\Response\NativeResponse; use Symfony\Component\HttpClient\Response\ResponseStream; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -36,6 +37,8 @@ final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterfac use LoggerAwareTrait; private $defaultOptions = self::OPTIONS_DEFAULTS; + + /** @var NativeClientState */ private $multi; /** @@ -50,18 +53,8 @@ public function __construct(array $defaultOptions = [], int $maxHostConnections [, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, self::OPTIONS_DEFAULTS); } - // Use an internal stdClass object to share state between the client and its responses - $this->multi = (object) [ - 'openHandles' => [], - 'handlesActivity' => [], - 'pendingResponses' => [], - 'maxHostConnections' => 0 < $maxHostConnections ? $maxHostConnections : PHP_INT_MAX, - 'responseCount' => 0, - 'dnsCache' => [], - 'handles' => [], - 'sleep' => false, - 'id' => random_int(PHP_INT_MIN, PHP_INT_MAX), - ]; + $this->multi = new NativeClientState(); + $this->multi->maxHostConnections = 0 < $maxHostConnections ? $maxHostConnections : PHP_INT_MAX; } /** @@ -291,7 +284,7 @@ private static function getProxy(?string $proxy, array $url): ?array /** * Resolves the IP of the host using the local DNS cache if possible. */ - private static function dnsResolve(array $url, \stdClass $multi, array &$info, ?\Closure $onProgress): array + private static function dnsResolve(array $url, NativeClientState $multi, array &$info, ?\Closure $onProgress): array { if ($port = parse_url($url['authority'], PHP_URL_PORT) ?: '') { $info['primary_port'] = $port; @@ -342,7 +335,7 @@ private static function createRedirectResolver(array $options, string $host, ?ar } } - return static function (\stdClass $multi, ?string $location, $context) use ($redirectHeaders, $proxy, $noProxy, &$info, $maxRedirects, $onProgress): ?string { + return static function (NativeClientState $multi, ?string $location, $context) use ($redirectHeaders, $proxy, $noProxy, &$info, $maxRedirects, $onProgress): ?string { if (null === $location || $info['http_code'] < 300 || 400 <= $info['http_code']) { $info['redirect_url'] = null; diff --git a/src/Symfony/Component/HttpClient/Response/CurlResponse.php b/src/Symfony/Component/HttpClient/Response/CurlResponse.php index b98a2cb8ff15..2a4cd5546ae8 100644 --- a/src/Symfony/Component/HttpClient/Response/CurlResponse.php +++ b/src/Symfony/Component/HttpClient/Response/CurlResponse.php @@ -14,6 +14,7 @@ use Psr\Log\LoggerInterface; use Symfony\Component\HttpClient\Chunk\FirstChunk; use Symfony\Component\HttpClient\Exception\TransportException; +use Symfony\Component\HttpClient\Internal\CurlClientState; use Symfony\Contracts\HttpClient\ResponseInterface; /** @@ -26,11 +27,12 @@ final class CurlResponse implements ResponseInterface use ResponseTrait; private static $performing = false; + private $multi; /** * @internal */ - public function __construct(\stdClass $multi, $ch, array $options = null, LoggerInterface $logger = null, string $method = 'GET', callable $resolveRedirect = null) + public function __construct(CurlClientState $multi, $ch, array $options = null, LoggerInterface $logger = null, string $method = 'GET', callable $resolveRedirect = null) { $this->multi = $multi; @@ -186,8 +188,8 @@ public function __destruct() $this->multi->pushedResponses = []; // Schedule DNS cache eviction for the next request - $this->multi->dnsCache[2] = $this->multi->dnsCache[2] ?: $this->multi->dnsCache[1]; - $this->multi->dnsCache[1] = $this->multi->dnsCache[0] = []; + $this->multi->dnsCache->evictions = $this->multi->dnsCache->evictions ?: $this->multi->dnsCache->removals; + $this->multi->dnsCache->removals = $this->multi->dnsCache->hostnames = []; } } } @@ -195,7 +197,7 @@ public function __destruct() /** * {@inheritdoc} */ - protected function close(): void + private function close(): void { unset($this->multi->openHandles[$this->id], $this->multi->handlesActivity[$this->id]); curl_multi_remove_handle($this->multi->handle, $this->handle); @@ -213,7 +215,7 @@ protected function close(): void /** * {@inheritdoc} */ - protected static function schedule(self $response, array &$runningResponses): void + private static function schedule(self $response, array &$runningResponses): void { if (isset($runningResponses[$i = (int) $response->multi->handle])) { $runningResponses[$i][1][$response->id] = $response; @@ -231,7 +233,7 @@ protected static function schedule(self $response, array &$runningResponses): vo /** * {@inheritdoc} */ - protected static function perform(\stdClass $multi, array &$responses = null): void + private static function perform(CurlClientState $multi, array &$responses = null): void { if (self::$performing) { return; @@ -253,7 +255,7 @@ protected static function perform(\stdClass $multi, array &$responses = null): v /** * {@inheritdoc} */ - protected static function select(\stdClass $multi, float $timeout): int + private static function select(CurlClientState $multi, float $timeout): int { return curl_multi_select($multi->handle, $timeout); } @@ -261,7 +263,7 @@ protected static function select(\stdClass $multi, float $timeout): int /** * Parses header lines as curl yields them to us. */ - private static function parseHeaderLine($ch, string $data, array &$info, array &$headers, ?array $options, \stdClass $multi, int $id, ?string &$location, ?callable $resolveRedirect, ?LoggerInterface $logger): int + private static function parseHeaderLine($ch, string $data, array &$info, array &$headers, ?array $options, CurlClientState $multi, int $id, ?string &$location, ?callable $resolveRedirect, ?LoggerInterface $logger): int { if (!\in_array($waitFor = @curl_getinfo($ch, CURLINFO_PRIVATE), ['headers', 'destruct'], true)) { return \strlen($data); // Ignore HTTP trailers @@ -295,11 +297,11 @@ private static function parseHeaderLine($ch, string $data, array &$info, array & $info['redirect_url'] = $resolveRedirect($ch, $location); $url = parse_url($location ?? ':'); - if (isset($url['host']) && null !== $ip = $multi->dnsCache[0][$url['host'] = strtolower($url['host'])] ?? null) { + if (isset($url['host']) && null !== $ip = $multi->dnsCache->hostnames[$url['host'] = strtolower($url['host'])] ?? null) { // Populate DNS cache for redirects if needed $port = $url['port'] ?? ('http' === ($url['scheme'] ?? parse_url(curl_getinfo($ch, CURLINFO_EFFECTIVE_URL), PHP_URL_SCHEME)) ? 80 : 443); curl_setopt($ch, CURLOPT_RESOLVE, ["{$url['host']}:$port:$ip"]); - $multi->dnsCache[1]["-{$url['host']}:$port"] = "-{$url['host']}:$port"; + $multi->dnsCache->removals["-{$url['host']}:$port"] = "-{$url['host']}:$port"; } } diff --git a/src/Symfony/Component/HttpClient/Response/MockResponse.php b/src/Symfony/Component/HttpClient/Response/MockResponse.php index 2eed8b9b8827..df38c7e5bb2c 100644 --- a/src/Symfony/Component/HttpClient/Response/MockResponse.php +++ b/src/Symfony/Component/HttpClient/Response/MockResponse.php @@ -15,6 +15,7 @@ use Symfony\Component\HttpClient\Chunk\FirstChunk; use Symfony\Component\HttpClient\Exception\InvalidArgumentException; use Symfony\Component\HttpClient\Exception\TransportException; +use Symfony\Component\HttpClient\Internal\ClientState; use Symfony\Contracts\HttpClient\ResponseInterface; /** @@ -130,10 +131,7 @@ protected static function schedule(self $response, array &$runningResponses): vo throw new InvalidArgumentException('MockResponse instances must be issued by MockHttpClient before processing.'); } - $multi = self::$mainMulti ?? self::$mainMulti = (object) [ - 'handlesActivity' => [], - 'openHandles' => [], - ]; + $multi = self::$mainMulti ?? self::$mainMulti = new ClientState(); if (!isset($runningResponses[0])) { $runningResponses[0] = [$multi, []]; @@ -145,7 +143,7 @@ protected static function schedule(self $response, array &$runningResponses): vo /** * {@inheritdoc} */ - protected static function perform(\stdClass $multi, array &$responses): void + protected static function perform(ClientState $multi, array &$responses): void { foreach ($responses as $response) { $id = $response->id; @@ -185,7 +183,7 @@ protected static function perform(\stdClass $multi, array &$responses): void /** * {@inheritdoc} */ - protected static function select(\stdClass $multi, float $timeout): int + protected static function select(ClientState $multi, float $timeout): int { return 42; } diff --git a/src/Symfony/Component/HttpClient/Response/NativeResponse.php b/src/Symfony/Component/HttpClient/Response/NativeResponse.php index b6652f75a05f..037dd5da889b 100644 --- a/src/Symfony/Component/HttpClient/Response/NativeResponse.php +++ b/src/Symfony/Component/HttpClient/Response/NativeResponse.php @@ -14,6 +14,7 @@ use Psr\Log\LoggerInterface; use Symfony\Component\HttpClient\Chunk\FirstChunk; use Symfony\Component\HttpClient\Exception\TransportException; +use Symfony\Component\HttpClient\Internal\NativeClientState; use Symfony\Contracts\HttpClient\ResponseInterface; /** @@ -32,11 +33,12 @@ final class NativeResponse implements ResponseInterface private $remaining; private $buffer; private $inflate; + private $multi; /** * @internal */ - public function __construct(\stdClass $multi, $context, string $url, $options, bool $gzipEnabled, array &$info, callable $resolveRedirect, ?callable $onProgress, ?LoggerInterface $logger) + public function __construct(NativeClientState $multi, $context, string $url, $options, bool $gzipEnabled, array &$info, callable $resolveRedirect, ?callable $onProgress, ?LoggerInterface $logger) { $this->multi = $multi; $this->id = (int) $context; @@ -193,7 +195,7 @@ private static function schedule(self $response, array &$runningResponses): void /** * {@inheritdoc} */ - private static function perform(\stdClass $multi, array &$responses = null): void + private static function perform(NativeClientState $multi, array &$responses = null): void { // List of native handles for stream_select() if (null !== $responses) { @@ -283,6 +285,7 @@ private static function perform(\stdClass $multi, array &$responses = null): voi if ($multi->pendingResponses && \count($multi->handles) < $multi->maxHostConnections) { // Open the next pending request - this is a blocking operation so we do only one of them + /** @var self $response */ $response = array_shift($multi->pendingResponses); $response->open(); $responses[$response->id] = $response; @@ -305,7 +308,7 @@ private static function perform(\stdClass $multi, array &$responses = null): voi /** * {@inheritdoc} */ - private static function select(\stdClass $multi, float $timeout): int + private static function select(NativeClientState $multi, float $timeout): int { $_ = []; diff --git a/src/Symfony/Component/HttpClient/Response/ResponseTrait.php b/src/Symfony/Component/HttpClient/Response/ResponseTrait.php index fc557ea90fcf..98e96ea0a64e 100644 --- a/src/Symfony/Component/HttpClient/Response/ResponseTrait.php +++ b/src/Symfony/Component/HttpClient/Response/ResponseTrait.php @@ -20,6 +20,7 @@ use Symfony\Component\HttpClient\Exception\RedirectionException; use Symfony\Component\HttpClient\Exception\ServerException; use Symfony\Component\HttpClient\Exception\TransportException; +use Symfony\Component\HttpClient\Internal\ClientState; /** * Implements the common logic for response classes. @@ -49,7 +50,7 @@ trait ResponseTrait 'error' => null, ]; - private $multi; + /** @var resource */ private $handle; private $id; private $timeout; @@ -181,12 +182,12 @@ abstract protected static function schedule(self $response, array &$runningRespo /** * Performs all pending non-blocking operations. */ - abstract protected static function perform(\stdClass $multi, array &$responses): void; + abstract protected static function perform(ClientState $multi, array &$responses): void; /** * Waits for network activity. */ - abstract protected static function select(\stdClass $multi, float $timeout): int; + abstract protected static function select(ClientState $multi, float $timeout): int; private static function addResponseHeaders(array $responseHeaders, array &$info, array &$headers): void { @@ -254,6 +255,7 @@ public static function stream(iterable $responses, float $timeout = null): \Gene $timeoutMax = 0; $timeoutMin = $timeout ?? INF; + /** @var ClientState $multi */ foreach ($runningResponses as $i => [$multi]) { $responses = &$runningResponses[$i][1]; self::perform($multi, $responses);