PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / trunk
Yoast SEO – Advanced SEO with real-time guidance and built-in AI vtrunk
28.5 28.4 28.3 28.2 28.1 28.0 27.9 27.8 27.7 27.6 27.5 trunk 18.0 18.1 18.2 18.3 18.4 18.4.1 18.5 18.5.1 18.6 18.7 18.8 18.9 19.0 All 129 releases
← All changes | vendor_prefixed/guzzlehttp/guzzle/src/Handler/CurlMultiHandler.php +934 -86 28.1 → trunk View file →
@@ -2,11 +2,14 @@
2 2
3 3 namespace YoastSEO_Vendor\GuzzleHttp\Handler;
4 4
5 5 use Closure;
6 +use YoastSEO_Vendor\GuzzleHttp\Exception\RequestException;
7 +use YoastSEO_Vendor\GuzzleHttp\Multiplexing;
6 8 use YoastSEO_Vendor\GuzzleHttp\Promise as P;
7 9 use YoastSEO_Vendor\GuzzleHttp\Promise\Promise;
8 10 use YoastSEO_Vendor\GuzzleHttp\Promise\PromiseInterface;
11 +use YoastSEO_Vendor\GuzzleHttp\Psr7;
9 12 use YoastSEO_Vendor\GuzzleHttp\TransportSharing;
10 13 use YoastSEO_Vendor\GuzzleHttp\Utils;
11 14 use YoastSEO_Vendor\Psr\Http\Message\RequestInterface;
12 15 /**
@@ -19,9 +22,17 @@
19 22 * @final
20 23 */
21 24 class CurlMultiHandler
22 25 {
26 + private const KNOWN_CONSTRUCTOR_OPTIONS = ['handle_factory' => \true, 'max_host_connections' => \true, 'max_total_connections' => \true, 'multiplex' => \true, 'options' => \true, 'select_timeout' => \true, 'transport_sharing' => \true];
27 + private const CONNECTION_CAP_OPTIONS = ['max_host_connections' => 'CURLMOPT_MAX_HOST_CONNECTIONS', 'max_total_connections' => 'CURLMOPT_MAX_TOTAL_CONNECTIONS'];
23 28 /**
29 + * cURL options that isolate a transfer from foreign proxy tunnel
30 + * connections. Failing to apply either one would fall open into
31 + * credential-bearing connection reuse.
32 + */
33 + private const PROXY_TUNNEL_ISOLATION_OPTIONS = ['CURLOPT_FRESH_CONNECT', 'CURLOPT_FORBID_REUSE'];
34 + /**
24 35 * @var CurlFactoryInterface
25 36 */
26 37 private $factory;
27 38 /**
@@ -51,19 +62,71 @@
51 62 /**
52 63 * @var array<mixed> An associative array of CURLMOPT_* options and corresponding values for curl_multi_setopt()
53 64 */
54 65 private $options = [];
66 + /**
67 + * @var array<int, true> Native options derived from first-class
68 + * constructor options; failing to apply one is an
69 + * error rather than a compatibility warning.
70 + */
71 + private $requiredOptions = [];
72 + /**
73 + * @var bool Whether any connection cap constructor option was applied
74 + */
75 + private $connectionCapsApplied = \false;
76 + /**
77 + * @var bool Whether the "multiplex" constructor option disabled
78 + * multiplexing on this handler's multi handle
79 + */
80 + private $multiplexDisabled = \false;
81 + /**
82 + * @var bool Whether a custom "handle_factory" constructor option supplies
83 + * the easy handles
84 + */
85 + private $customHandleFactory = \false;
55 86 /** @var resource|\CurlMultiHandle */
56 87 private $_mh;
57 88 /**
58 - * @var bool
89 + * @var int Depth of nested guarded native operations (execution and
90 + * handle removal, both of which can run user callbacks). A
91 + * callback can re-enter tick(), and the nested frame must not
92 + * clear the outer frame's guard; deferred work stays parked
93 + * until the outermost frame unwinds.
59 94 */
60 - private $executingMulti = \false;
95 + private $multiExecDepth = 0;
61 96 /**
62 - * @var array<int, EasyHandle>
97 + * @var bool Guards finishDeferredWork() against re-entry from the
98 + * guarded native removals it performs while flushing.
63 99 */
100 + private $finishingDeferredWork = \false;
101 + /**
102 + * @var array<int, array{easy: EasyHandle, attached: bool}>
103 + */
64 104 private $deferredCancels = [];
65 105 /**
106 + * @var array<int, object|null> Wait tokens of requests created from inside
107 + * a cURL callback, keyed by handle id; native
108 + * attachment is deferred until the outermost
109 + * native execution unwinds.
110 + */
111 + private $deferredAdds = [];
112 + /**
113 + * @var string|null Owner signature of the proxy tunnels the multi handle's
114 + * connection cache may hold
115 + */
116 + private $proxyTunnelOwner;
117 + /** @var array<string, int> Count of attached transfers per proxy tunnel signature. */
118 + private $activeProxyTunnelSignatures = [];
119 + /** @var array<int, string> Maps an attached handle id to its proxy tunnel signature. */
120 + private $activeProxyTunnelHandles = [];
121 + /**
122 + * @var int Depth of nested processMessages() calls. Guards against
123 + * multi-handle recreation re-entrancy from processMessages (a
124 + * retried transfer re-invokes the handler); a depth is tracked
125 + * because a completion callback can re-enter tick().
126 + */
127 + private $messageProcessingDepth = 0;
128 + /**
66 129 * This handler accepts the following options:
67 130 *
68 131 * - handle_factory: An optional factory used to create curl handles
69 132 * - transport_sharing: Optional transport sharing mode.
@@ -68,13 +131,38 @@
68 131 * - handle_factory: An optional factory used to create curl handles
69 132 * - transport_sharing: Optional transport sharing mode.
70 133 * - select_timeout: Optional timeout (in seconds) to block before timing
71 134 * out while selecting curl handles. Defaults to 1 second.
135 + * - max_host_connections: Optional maximum concurrent connections per host.
136 + * - max_total_connections: Optional maximum concurrent connections overall.
137 + * - multiplex: Optional Multiplexing::NONE to disallow multiplexing on
138 + * this handler's multi handle. The eager, wait, and required modes are
139 + * request options, not handler options; Multiplexing::NONE is also
140 + * conditionally accepted as a request option value.
72 141 * - options: An associative array of CURLMOPT_* options and
73 142 * corresponding values for curl_multi_setopt()
74 143 */
75 144 public function __construct(array $options = [])
76 145 {
146 + foreach ($options as $name => $_) {
147 + if (!isset(self::KNOWN_CONSTRUCTOR_OPTIONS[$name])) {
148 + \YoastSEO_Vendor\trigger_deprecation('guzzlehttp/guzzle', '7.14', \sprintf('The "%s" CurlMultiHandler constructor option is unknown; guzzlehttp/guzzle 8.0 will reject unknown constructor options.', (string) $name));
149 + }
150 + }
151 + $handlerMultiplex = $options['multiplex'] ?? null;
152 + if (null !== $handlerMultiplex && \YoastSEO_Vendor\GuzzleHttp\Multiplexing::NONE !== $handlerMultiplex) {
153 + if (\in_array($handlerMultiplex, [\YoastSEO_Vendor\GuzzleHttp\Multiplexing::EAGER, \YoastSEO_Vendor\GuzzleHttp\Multiplexing::WAIT, \YoastSEO_Vendor\GuzzleHttp\Multiplexing::REQUIRE_EAGER, \YoastSEO_Vendor\GuzzleHttp\Multiplexing::REQUIRE_WAIT], \true)) {
154 + throw new \InvalidArgumentException('The "multiplex" CurlMultiHandler option only accepts Multiplexing::NONE; the eager, wait, and required modes are request options.');
155 + }
156 + throw new \InvalidArgumentException(\sprintf('The "multiplex" CurlMultiHandler option must be null or Multiplexing::NONE; received %s.', \get_debug_type($handlerMultiplex)));
157 + }
158 + $this->multiplexDisabled = null !== $handlerMultiplex;
159 + if ($this->multiplexDisabled && !\defined('CURLMOPT_PIPELINING')) {
160 + // ext-curl only defines the constant when built against libcurl
161 + // 7.16 or newer headers, and such builds compile out the matching
162 + // curl_multi_setopt() case, so the guarantee cannot be applied.
163 + throw new \InvalidArgumentException('The "multiplex" CurlMultiHandler option requires CURLMOPT_PIPELINING, but it is not available in the installed PHP cURL extension.');
164 + }
77 165 \YoastSEO_Vendor\GuzzleHttp\Handler\CurlShareHandleState::assertNoRequiredSharingCustomFactoryConflict($options, 'CurlMultiHandler');
78 166 $transportSharing = $options['transport_sharing'] ?? null;
79 167 $sharingMode = \YoastSEO_Vendor\GuzzleHttp\Handler\CurlShareHandleState::normalizeMode($transportSharing, 'transport_sharing');
80 168 if (\array_key_exists('handle_factory', $options) && $options['handle_factory'] !== null) {
@@ -79,14 +167,24 @@
79 167 $sharingMode = \YoastSEO_Vendor\GuzzleHttp\Handler\CurlShareHandleState::normalizeMode($transportSharing, 'transport_sharing');
80 168 if (\array_key_exists('handle_factory', $options) && $options['handle_factory'] !== null) {
81 169 $this->shareHandleState = null;
82 170 $this->factory = $options['handle_factory'];
171 + $this->customHandleFactory = \true;
83 172 } else {
84 173 $this->shareHandleState = $sharingMode !== \YoastSEO_Vendor\GuzzleHttp\TransportSharing::NONE ? \YoastSEO_Vendor\GuzzleHttp\Handler\CurlShareHandleState::fromOption($transportSharing) : null;
85 - $this->factory = $this->shareHandleState !== null ? new \YoastSEO_Vendor\GuzzleHttp\Handler\CurlFactory(50, $this->shareHandleState->mode, $this->shareHandleState->handle) : new \YoastSEO_Vendor\GuzzleHttp\Handler\CurlFactory(50);
174 + $this->factory = $this->shareHandleState !== null ? new \YoastSEO_Vendor\GuzzleHttp\Handler\CurlFactory(50, $this->shareHandleState->mode, $this->shareHandleState) : new \YoastSEO_Vendor\GuzzleHttp\Handler\CurlFactory(50);
86 175 }
87 176 if (isset($options['select_timeout'])) {
88 - $this->selectTimeout = $options['select_timeout'];
177 + $selectTimeout = $options['select_timeout'];
178 + if (!\is_int($selectTimeout) && !\is_float($selectTimeout) && (!\is_string($selectTimeout) || !\is_numeric($selectTimeout))) {
179 + \YoastSEO_Vendor\trigger_deprecation('guzzlehttp/guzzle', '7.14', 'Passing a non-numeric "select_timeout" CurlMultiHandler option is deprecated; guzzlehttp/guzzle 8.0 will reject it.');
180 + } else {
181 + $seconds = (float) $selectTimeout;
182 + if (!\is_finite($seconds) || $seconds < 0 || $seconds > 0 && (int) ($seconds * 1000) === 0) {
183 + \YoastSEO_Vendor\trigger_deprecation('guzzlehttp/guzzle', '7.14', 'Passing a "select_timeout" CurlMultiHandler option that is not 0 or greater than or equal to 0.001 seconds is deprecated; guzzlehttp/guzzle 8.0 will reject it.');
184 + }
185 + }
186 + $this->selectTimeout = $selectTimeout;
89 187 } elseif ($selectTimeout = \YoastSEO_Vendor\GuzzleHttp\Utils::getenv('GUZZLE_CURL_SELECT_TIMEOUT')) {
90 188 \YoastSEO_Vendor\trigger_deprecation('guzzlehttp/guzzle', '7.2', 'The GUZZLE_CURL_SELECT_TIMEOUT environment variable is deprecated; use the "select_timeout" option instead.');
91 189 $this->selectTimeout = (int) $selectTimeout;
92 190 } else {
@@ -91,9 +189,34 @@
91 189 $this->selectTimeout = (int) $selectTimeout;
92 190 } else {
93 191 $this->selectTimeout = 1;
94 192 }
95 - $this->options = $options['options'] ?? [];
193 + $multiOptions = $options['options'] ?? [];
194 + if (\is_array($multiOptions)) {
195 + self::rejectConnectionCapOptionConflicts($options, $multiOptions);
196 + if ($this->multiplexDisabled && \array_key_exists(\CURLMOPT_PIPELINING, $multiOptions)) {
197 + // Key presence alone conflicts, even with an agreeing value:
198 + // the named option is the single multiplexing authority.
199 + throw new \InvalidArgumentException('multiplex conflicts with a CURLMOPT_PIPELINING entry in the "options" array.');
200 + }
201 + self::triggerConflictingCurlMultiOptionDeprecations($multiOptions);
202 + } elseif (self::hasConnectionCapOption($options)) {
203 + throw new \InvalidArgumentException('options must be an array of cURL multi options when using connection cap options.');
204 + } elseif ($this->multiplexDisabled) {
205 + throw new \InvalidArgumentException('options must be an array of cURL multi options when using the "multiplex" option.');
206 + }
207 + $this->options = $multiOptions;
208 + if (\is_array($multiOptions)) {
209 + $this->addConnectionCapOptions($options);
210 + if ($this->multiplexDisabled) {
211 + // CURLPIPE_NOTHING; the constant itself needs libcurl 7.43
212 + // headers, newer than the oldest supported runtimes. The
213 + // option is required: a handler-wide guarantee must fail
214 + // closed rather than warn like the deprecated raw options.
215 + $this->options[\CURLMOPT_PIPELINING] = 0;
216 + $this->requiredOptions[\CURLMOPT_PIPELINING] = \true;
217 + }
218 + }
96 219 // unsetting the property forces the first access to go through
97 220 // __get().
98 221 unset($this->_mh);
99 222 }
@@ -101,10 +224,11 @@
101 224 * @param string $name
102 225 *
103 226 * @return resource|\CurlMultiHandle
104 227 *
105 - * @throws \BadMethodCallException when another field as `_mh` will be gotten
106 - * @throws \RuntimeException when curl can not initialize a multi handle
228 + * @throws \BadMethodCallException when another field as `_mh` will be gotten
229 + * @throws \RuntimeException when curl can not initialize a multi handle
230 + * @throws \InvalidArgumentException when a required cURL multi option cannot be applied
107 231 */
108 232 public function __get($name)
109 233 {
110 234 if ($name !== '_mh') {
@@ -113,13 +237,31 @@
113 237 $multiHandle = \curl_multi_init();
114 238 if (\false === $multiHandle) {
115 239 throw new \RuntimeException('Can not initialize curl multi handle.');
116 240 }
241 + try {
242 + foreach ($this->options as $option => $value) {
243 + if (\true === @\curl_multi_setopt($multiHandle, $option, $value)) {
244 + continue;
245 + }
246 + if (isset($this->requiredOptions[$option])) {
247 + // A first-class option such as a connection cap must
248 + // never be silently dropped.
249 + throw new \InvalidArgumentException(\sprintf('Unable to apply the cURL multi option %s; it was rejected by the runtime libcurl.', self::formatCurlMultiOption($option)));
250 + }
251 + \trigger_error(\sprintf('Unable to apply the cURL multi option %s; it was ignored by the runtime libcurl.', self::formatCurlMultiOption($option)), \E_USER_WARNING);
252 + }
253 + } catch (\Throwable $e) {
254 + // Do not publish a partially configured handle; a later access
255 + // retries the initialization from scratch.
256 + try {
257 + \curl_multi_close($multiHandle);
258 + } catch (\Throwable $ignored) {
259 + // Preserve the original failure.
260 + }
261 + throw $e;
262 + }
117 263 $this->_mh = $multiHandle;
118 - foreach ($this->options as $option => $value) {
119 - // A warning is raised in case of a wrong option.
120 - \curl_multi_setopt($this->_mh, $option, $value);
121 - }
122 264 return $this->_mh;
123 265 }
124 266 public function __destruct()
125 267 {
@@ -134,51 +276,522 @@
134 276 }
135 277 }
136 278 public function __invoke(\YoastSEO_Vendor\Psr\Http\Message\RequestInterface $request, array $options) : \YoastSEO_Vendor\GuzzleHttp\Promise\PromiseInterface
137 279 {
280 + \YoastSEO_Vendor\GuzzleHttp\Handler\HostValidator::assertRequestHost($request);
281 + if ($this->connectionCapsApplied && \defined('CURLOPT_SHARE') && isset($options['curl']) && \is_array($options['curl']) && \array_key_exists((int) \constant('CURLOPT_SHARE'), $options['curl'])) {
282 + // Key presence alone conflicts: Guzzle cannot verify that a
283 + // caller-managed shared connection pool honors the caps.
284 + throw new \InvalidArgumentException('The request-level CURLOPT_SHARE cURL option cannot be combined with CurlMultiHandler connection cap options because Guzzle cannot verify that an external shared connection pool honors cURL multi connection caps.');
285 + }
138 286 $easy = $this->factory->create($request, $options);
287 + try {
288 + $this->rejectMultiplexPipeliningConflict($easy, $options);
289 + $this->applyMultiplexNone($easy, $options);
290 + $this->applyProxyTunnelOwnership($easy);
291 + } catch (\Throwable $e) {
292 + try {
293 + $this->factory->release($easy);
294 + } catch (\Throwable $releaseFailure) {
295 + // Preserve the original failure.
296 + }
297 + throw $e;
298 + }
139 299 $id = (int) $easy->handle;
140 - $promise = new \YoastSEO_Vendor\GuzzleHttp\Promise\Promise([$this, 'execute'], function () use($id) {
141 - return $this->cancel($id);
300 + $waitToken = new \stdClass();
301 + $promise = new \YoastSEO_Vendor\GuzzleHttp\Promise\Promise(function () use($id, $waitToken) : void {
302 + if ($this->multiExecDepth > 0) {
303 + // Waiting cannot drive native cURL while a callback has
304 + // the multi handle busy; fail the wait promptly instead
305 + // of self-deadlocking.
306 + $this->failNestedWait($id, $waitToken);
307 + return;
308 + }
309 + $this->executeUntil($id, $waitToken);
310 + }, function () use($id, $waitToken) {
311 + return $this->cancel($id, $waitToken);
142 312 });
143 - $this->addRequest(['easy' => $easy, 'deferred' => $promise]);
313 + $entry = ['easy' => $easy, 'deferred' => $promise, 'wait_token' => $waitToken];
314 + try {
315 + $this->addRequest($entry);
316 + } catch (\Throwable $e) {
317 + throw $this->discardPendingRequest($id, $entry, $e);
318 + }
144 319 return $promise;
145 320 }
146 321 /**
322 + * The "multiplex" request option sets CURLOPT_PIPEWAIT, which libcurl
323 + * ignores entirely when the multi handle's CURLMOPT_PIPELINING option
324 + * disables multiplexing, so an explicit request for multiplexing on a
325 + * handler configured against it is a configuration error. The required
326 + * family conflicts marker-independently: a required guarantee on a handler
327 + * that disables multiplexing is contradictory even when the transfer would
328 + * not wait. A raw CURLOPT_PIPEWAIT cURL option conflicts with every
329 + * explicit mode on this handler, where waiting is operationally
330 + * meaningful: whatever its value, it is a second wait/eager authority
331 + * applied after the mode's own decision.
332 + */
333 + private function rejectMultiplexPipeliningConflict(\YoastSEO_Vendor\GuzzleHttp\Handler\EasyHandle $easy, array $options) : void
334 + {
335 + $multiplex = $options['multiplex'] ?? null;
336 + if (null === $multiplex) {
337 + return;
338 + }
339 + if (\defined('CURLOPT_PIPEWAIT') && isset($options['curl']) && \is_array($options['curl']) && \array_key_exists((int) \constant('CURLOPT_PIPEWAIT'), $options['curl'])) {
340 + // Key presence alone conflicts, and it must be rejected before
341 + // the marker below is consulted: the marker reflects the final
342 + // merged configuration, which the raw value has falsified.
343 + throw new \InvalidArgumentException('The "multiplex" request option cannot be combined with the raw CURLOPT_PIPEWAIT cURL option on the cURL multi handler; remove the raw option.');
344 + }
345 + if (\YoastSEO_Vendor\GuzzleHttp\Multiplexing::WAIT === $multiplex && !$easy->usesPipewait) {
346 + // Explicit wait only conflicts when the transfer would actually
347 + // wait; an HTTP/1.1 wait request never sets the marker.
348 + return;
349 + }
350 + if (!\in_array($multiplex, [\YoastSEO_Vendor\GuzzleHttp\Multiplexing::WAIT, \YoastSEO_Vendor\GuzzleHttp\Multiplexing::REQUIRE_EAGER, \YoastSEO_Vendor\GuzzleHttp\Multiplexing::REQUIRE_WAIT], \true)) {
351 + return;
352 + }
353 + if ($this->multiplexDisabled) {
354 + // Checked before the raw option: the handler wrote its own
355 + // CURLMOPT_PIPELINING value when "multiplex" disabled it.
356 + throw new \InvalidArgumentException('The "multiplex" request option cannot be combined with a CurlMultiHandler whose "multiplex" option is Multiplexing::NONE; remove the handler option or set the request option to "eager".');
357 + }
358 + if (!\is_array($this->options) || !\array_key_exists(\CURLMOPT_PIPELINING, $this->options)) {
359 + // A legacy non-array "options" value is tolerated by the
360 + // constructor and cannot contain the option.
361 + return;
362 + }
363 + $pipelining = $this->options[\CURLMOPT_PIPELINING];
364 + if (!\is_scalar($pipelining)) {
365 + // ext-curl derives the integer mask from non-scalar values with
366 + // type-dependent zval semantics, so the effective mask cannot be
367 + // predicted here; require an explicit integer instead.
368 + throw new \InvalidArgumentException('The CurlMultiHandler CURLMOPT_PIPELINING option must be an integer when combined with the "multiplex" request option.');
369 + }
370 + $multiplexBit = \defined('CURLPIPE_MULTIPLEX') ? \CURLPIPE_MULTIPLEX : 2;
371 + if (((int) $pipelining & $multiplexBit) !== 0) {
372 + return;
373 + }
374 + throw new \InvalidArgumentException('The "multiplex" request option cannot be combined with a CurlMultiHandler CURLMOPT_PIPELINING option that disables multiplexing; set CURLMOPT_PIPELINING to CURLPIPE_MULTIPLEX, remove the option, or set the "multiplex" option to "eager".');
375 + }
376 + /**
377 + * A Multiplexing::NONE request option is a sole-use guarantee: the
378 + * transfer must not share its connection with any concurrent transfer.
379 + * It holds structurally on a handler whose "multiplex" option is
380 + * Multiplexing::NONE, and for HTTP/1.x transfers, which never join a
381 + * multiplexed connection and open connections nothing can join. An
382 + * HTTP/2 request on a handler that multiplexes is rejected, as is any
383 + * configuration under which the guarantee cannot be verified (custom
384 + * handle factories control the native handle) or cannot be hardened
385 + * (challenge-response authentication retries and Expect 417 retries
386 + * re-enter connection selection as internal follows, which disarm
387 + * CURLOPT_FRESH_CONNECT). A raw CURLMOPT_PIPELINING multi option, and
388 + * deprecated-but-applied raw cURL options that can defeat the declared
389 + * protocol version, retry through internal follows, or replace the
390 + * managed header list, are rejected by key presence. On runtimes whose
391 + * matcher can hand an HTTP/1.x transfer an idle multiplexed connection
392 + * (below libcurl 7.77.0, and 8.11.0-8.12.1), accepted transfers force
393 + * a fresh connection.
394 + */
395 + private function applyMultiplexNone(\YoastSEO_Vendor\GuzzleHttp\Handler\EasyHandle $easy, array $options) : void
396 + {
397 + if (\YoastSEO_Vendor\GuzzleHttp\Multiplexing::NONE !== ($options['multiplex'] ?? null) || $this->multiplexDisabled) {
398 + return;
399 + }
400 + if (\defined('CURLMOPT_PIPELINING') && \is_array($this->options) && \array_key_exists(\CURLMOPT_PIPELINING, $this->options)) {
401 + // Key presence alone conflicts, matching the constructor's rule
402 + // for the named option: raw multi options that fail to apply only
403 + // warn (they are not in requiredOptions), so even an agreeing
404 + // zero mask cannot prove the guarantee. is_array: legacy non-array
405 + // "options" values are deprecated but still stored.
406 + throw new \InvalidArgumentException('The "multiplex" request option cannot be Multiplexing::NONE alongside a raw CURLMOPT_PIPELINING cURL multi option; replace the raw option with the "multiplex" cURL multi handler option.');
407 + }
408 + if ($this->customHandleFactory) {
409 + throw new \InvalidArgumentException('The "multiplex" request option can only be Multiplexing::NONE on a CurlMultiHandler with a custom "handle_factory" when the handler\'s own "multiplex" option is Multiplexing::NONE, because the guarantee is enforced against the native easy handle the factory controls.');
410 + }
411 + $version = $easy->request->getProtocolVersion();
412 + if ('2' === $version || '2.0' === $version) {
413 + throw new \InvalidArgumentException('The "multiplex" request option can only be Multiplexing::NONE for an HTTP/1.x request on a CurlMultiHandler that permits multiplexing; set the "multiplex" client or CurlMultiHandler constructor option to Multiplexing::NONE to disable multiplexing for every transfer, or send the request with its "version" option set to "1.1".');
414 + }
415 + if (isset($options['curl']) && \is_array($options['curl'])) {
416 + foreach (['CURLOPT_HTTP_VERSION', 'CURLOPT_HTTPAUTH', 'CURLOPT_PROXYAUTH', 'CURLOPT_FOLLOWLOCATION', 'CURLOPT_HTTPHEADER', 'CURLOPT_ALTSVC', 'CURLOPT_ALTSVC_CTRL', 'CURLOPT_PROXYTYPE'] as $constant) {
417 + if (\defined($constant) && \array_key_exists((int) \constant($constant), $options['curl'])) {
418 + // Key presence alone conflicts. A raw CURLOPT_HTTP_VERSION
419 + // overrides the declared version after the factory
420 + // mapping, and raw alt-svc options or an HTTPS2 proxy
421 + // type can put a declared-HTTP/1.x transfer on a joinable
422 + // HTTP/2 connection; raw challenge-response
423 + // authentication (origin 401 or proxy 407) and native
424 + // redirects re-enter connection selection as internal
425 + // follows, which disarm CURLOPT_FRESH_CONNECT, so the
426 + // hardening below cannot cover them; a raw
427 + // CURLOPT_HTTPHEADER replaces the managed header list,
428 + // including the Expect suppression the check below
429 + // relies on.
430 + throw new \InvalidArgumentException(\sprintf('The "multiplex" request option cannot be Multiplexing::NONE combined with the raw %s cURL option on a CurlMultiHandler that permits multiplexing; remove the raw option, or set the "multiplex" client or CurlMultiHandler constructor option to Multiplexing::NONE.', $constant));
431 + }
432 + }
433 + }
434 + if (\YoastSEO_Vendor\GuzzleHttp\Psr7\Utils::caselessContains($easy->request->getHeaderLine('Expect'), '100-continue')) {
435 + // libcurl arms its Expect handling by a caseless substring scan
436 + // of the header value (Curl_compareheader), so any value
437 + // containing 100-continue can make a 417 response retry as an
438 + // internal follow, which disarms CURLOPT_FRESH_CONNECT; requests
439 + // without the header are safe because the factory suppresses
440 + // libcurl's automatic Expect.
441 + throw new \InvalidArgumentException('The "multiplex" request option cannot be Multiplexing::NONE for a request carrying an "Expect: 100-continue" header on a CurlMultiHandler that permits multiplexing; remove the explicitly supplied "Expect" header, set the "expect" request option to false to prevent it being added automatically, or set the "multiplex" client or CurlMultiHandler constructor option to Multiplexing::NONE.');
442 + }
443 + if (\YoastSEO_Vendor\GuzzleHttp\Handler\CurlVersion::supportsHttpVersionReuseMatching()) {
444 + return;
445 + }
446 + // Unqualified curl_setopt so the test bootstrap shadow records it.
447 + if (\true !== \curl_setopt($easy->handle, \CURLOPT_FRESH_CONNECT, \true)) {
448 + // The hardening is the guarantee on these runtimes; failing to
449 + // apply it must fail closed, mirroring applyCurlOptions().
450 + throw new \InvalidArgumentException('Unable to set cURL option CURLOPT_FRESH_CONNECT.');
451 + }
452 + }
453 + /**
454 + * @param array<mixed> $options
455 + */
456 + private static function triggerConflictingCurlMultiOptionDeprecations(array $options) : void
457 + {
458 + if ($options === []) {
459 + return;
460 + }
461 + $conflictingOptions = self::conflictingCurlMultiOptions();
462 + $sinceOverrides = self::conflictingCurlMultiOptionSinceOverrides();
463 + foreach ($options as $option => $_) {
464 + if (\array_key_exists($option, $conflictingOptions)) {
465 + \YoastSEO_Vendor\trigger_deprecation('guzzlehttp/guzzle', $sinceOverrides[$option] ?? '7.14', \sprintf('Passing %s in the cURL multi handler "options" is deprecated; guzzlehttp/guzzle 8.0 will reject this option. Use %s instead.', self::formatCurlMultiOption($option), $conflictingOptions[$option]));
466 + }
467 + }
468 + }
469 + /**
470 + * @return array<int, string>
471 + */
472 + private static function conflictingCurlMultiOptionSinceOverrides() : array
473 + {
474 + if (!\defined('CURLMOPT_PIPELINING')) {
475 + // Matches conflictingCurlMultiOptions(): ext-curl builds against
476 + // pre-7.16 libcurl headers do not define the constant.
477 + return [];
478 + }
479 + return [\CURLMOPT_PIPELINING => '7.15'];
480 + }
481 + /**
482 + * @param array<mixed> $options
483 + */
484 + private static function hasConnectionCapOption(array $options) : bool
485 + {
486 + foreach (self::CONNECTION_CAP_OPTIONS as $name => $_) {
487 + if (($options[$name] ?? null) !== null) {
488 + return \true;
489 + }
490 + }
491 + return \false;
492 + }
493 + /**
494 + * @param array<mixed> $constructorOptions
495 + * @param array<mixed> $multiOptions
496 + */
497 + private static function rejectConnectionCapOptionConflicts(array $constructorOptions, array $multiOptions) : void
498 + {
499 + foreach (self::CONNECTION_CAP_OPTIONS as $name => $constant) {
500 + if (($constructorOptions[$name] ?? null) === null || !\defined($constant)) {
501 + continue;
502 + }
503 + $option = \constant($constant);
504 + if (\array_key_exists($option, $multiOptions)) {
505 + throw new \InvalidArgumentException(\sprintf('%s conflicts with a %s entry in the "options" array.', $name, $constant));
506 + }
507 + }
508 + }
509 + /**
510 + * @param array<mixed> $options
511 + */
512 + private function addConnectionCapOptions(array $options) : void
513 + {
514 + foreach (self::CONNECTION_CAP_OPTIONS as $name => $constant) {
515 + $value = $options[$name] ?? null;
516 + if ($value === null) {
517 + continue;
518 + }
519 + if (!\is_int($value) || $value < 1) {
520 + throw new \InvalidArgumentException(\sprintf('%s must be a positive integer.', $name));
521 + }
522 + \YoastSEO_Vendor\GuzzleHttp\Handler\CurlVersion::ensureConnectionCapsSupported($name);
523 + $option = \constant($constant);
524 + if (\array_key_exists($option, $this->options)) {
525 + throw new \InvalidArgumentException(\sprintf('%s conflicts with a %s entry in the "options" array.', $name, $constant));
526 + }
527 + $this->options[$option] = $value;
528 + $this->requiredOptions[$option] = \true;
529 + $this->connectionCapsApplied = \true;
530 + }
531 + }
532 + /**
533 + * @param int|string $option
534 + */
535 + private static function formatCurlMultiOption($option) : string
536 + {
537 + if (!\is_int($option)) {
538 + return \sprintf('"%s"', $option);
539 + }
540 + static $names = null;
541 + if (null === $names) {
542 + $names = [];
543 + foreach (\get_defined_constants(\true)['curl'] ?? [] as $name => $value) {
544 + if (\is_int($value) && \strpos($name, 'CURLMOPT_') === 0 && !isset($names[$value])) {
545 + $names[$value] = $name;
546 + }
547 + }
548 + }
549 + if (isset($names[$option])) {
550 + return \sprintf('%s (%d)', $names[$option], $option);
551 + }
552 + return (string) $option;
553 + }
554 + /**
555 + * @return array<int, string>
556 + */
557 + private static function conflictingCurlMultiOptions() : array
558 + {
559 + static $options = null;
560 + if ($options !== null) {
561 + return $options;
562 + }
563 + $options = [];
564 + self::addConflictingCurlMultiOption($options, 'CURLMOPT_MAX_HOST_CONNECTIONS', 'the "max_host_connections" client option or cURL multi handler option');
565 + self::addConflictingCurlMultiOption($options, 'CURLMOPT_MAX_TOTAL_CONNECTIONS', 'the "max_total_connections" client option or cURL multi handler option');
566 + self::addConflictingCurlMultiOption($options, 'CURLMOPT_PIPELINING', 'Multiplexing::NONE via the "multiplex" cURL multi handler or client option to disable multiplexing, or remove the raw option for the runtime default (multiplexing defaults on from libcurl 7.62, except 7.65.0 and 7.65.1)');
567 + return $options;
568 + }
569 + /**
570 + * @param array<int, string> $options
571 + */
572 + private static function addConflictingCurlMultiOption(array &$options, string $constant, string $replacement) : void
573 + {
574 + if (!\defined($constant)) {
575 + return;
576 + }
577 + $value = \constant($constant);
578 + if (\is_int($value)) {
579 + $options[$value] = $replacement;
580 + }
581 + }
582 + /**
583 + * Isolates the connection cache when the request's proxy tunnel section
584 + * differs from the one the multi handle's cache may already hold.
585 + */
586 + private function applyProxyTunnelOwnership(\YoastSEO_Vendor\GuzzleHttp\Handler\EasyHandle $easy) : void
587 + {
588 + $signature = $easy->proxyTunnelSignature;
589 + if ($signature === null || $signature === $this->proxyTunnelOwner) {
590 + return;
591 + }
592 + if ($this->proxyTunnelOwner === null) {
593 + // No in-domain transfer has ever run on this multi handle: latch
594 + // the owner without destroying pooled direct connections.
595 + $this->proxyTunnelOwner = $signature;
596 + return;
597 + }
598 + if ($this->handles === [] && 0 === $this->multiExecDepth && 0 === $this->messageProcessingDepth && $this->deferredCancels === []) {
599 + // Idle: hand the connection cache over by recreating the multi
600 + // handle (unsetting re-arms the lazy __get initializer, which
601 + // re-applies the CURLMOPT_* options).
602 + if (isset($this->_mh)) {
603 + \curl_multi_close($this->_mh);
604 + unset($this->_mh);
605 + }
606 + $this->proxyTunnelOwner = $signature;
607 + return;
608 + }
609 + // Busy: isolate this transfer from the owner's pooled tunnels.
610 + $this->isolateProxyTunnelTransfer($easy);
611 + }
612 + private function addCurlHandle(\YoastSEO_Vendor\GuzzleHttp\Handler\EasyHandle $easy) : void
613 + {
614 + $this->isolateFromForeignActiveProxyTunnel($easy);
615 + // Unqualified curl_multi_add_handle so the test bootstrap shadow can
616 + // override the result.
617 + $result = \curl_multi_add_handle($this->_mh, $easy->handle);
618 + if (\CURLM_OK !== $result) {
619 + if (\PHP_VERSION_ID < 80226 || \PHP_VERSION_ID >= 80300 && \PHP_VERSION_ID < 80314) {
620 + // Before PHP 8.2.26 and 8.3.14, ext-curl kept the easy handle
621 + // in its multi bookkeeping even when the native add failed
622 + // (https://github.com/php/php-src/pull/16302); remove it so
623 + // the handle can be pooled or closed safely.
624 + \curl_multi_remove_handle($this->_mh, $easy->handle);
625 + }
626 + throw new \YoastSEO_Vendor\GuzzleHttp\Exception\RequestException(\sprintf('Unable to add the cURL handle to the cURL multi handler: %s (%d).', (string) \curl_multi_strerror($result), $result), $easy->request);
627 + }
628 + $this->markProxyTunnelActive($easy);
629 + $id = (int) $easy->handle;
630 + if (isset($this->handles[$id])) {
631 + $this->handles[$id]['attached'] = \true;
632 + }
633 + }
634 + /**
635 + * @param resource|\CurlHandle $handle
636 + */
637 + private function removeCompletedHandleFromMulti(int $id, $handle) : void
638 + {
639 + $this->removeHandleFromMulti($handle);
640 + $this->unmarkProxyTunnelActiveById($id);
641 + }
642 + /**
643 + * Removes a transfer from the multi handle under the native execution
644 + * guard: removing a still-running transfer performs a final progress
645 + * update that can run a user progress callback.
646 + *
647 + * @param resource|\CurlHandle $handle
648 + */
649 + private function removeHandleFromMulti($handle) : void
650 + {
651 + ++$this->multiExecDepth;
652 + try {
653 + \curl_multi_remove_handle($this->_mh, $handle);
654 + } finally {
655 + --$this->multiExecDepth;
656 + $this->finishDeferredWork();
657 + }
658 + }
659 + private function isolateFromForeignActiveProxyTunnel(\YoastSEO_Vendor\GuzzleHttp\Handler\EasyHandle $easy) : void
660 + {
661 + $signature = $easy->proxyTunnelSignature;
662 + if ($signature === null || $this->activeProxyTunnelSignatures === []) {
663 + return;
664 + }
665 + if (\count($this->activeProxyTunnelSignatures) === 1 && isset($this->activeProxyTunnelSignatures[$signature])) {
666 + return;
667 + }
668 + $this->isolateProxyTunnelTransfer($easy);
669 + }
670 + private function isolateProxyTunnelTransfer(\YoastSEO_Vendor\GuzzleHttp\Handler\EasyHandle $easy) : void
671 + {
672 + foreach (self::PROXY_TUNNEL_ISOLATION_OPTIONS as $name) {
673 + try {
674 + // Unqualified curl_setopt so the test bootstrap shadow records it.
675 + $applied = \curl_setopt($easy->handle, (int) \constant($name), \true);
676 + } catch (\Throwable $e) {
677 + throw new \YoastSEO_Vendor\GuzzleHttp\Exception\RequestException(self::proxyTunnelIsolationFailureMessage($name), $easy->request, null, $e);
678 + }
679 + if (\true !== $applied) {
680 + throw new \YoastSEO_Vendor\GuzzleHttp\Exception\RequestException(self::proxyTunnelIsolationFailureMessage($name), $easy->request);
681 + }
682 + }
683 + }
684 + private static function proxyTunnelIsolationFailureMessage(string $name) : string
685 + {
686 + return \sprintf('Unable to apply the %s cURL option required to isolate the transfer from foreign proxy tunnel connections.', $name);
687 + }
688 + private function markProxyTunnelActive(\YoastSEO_Vendor\GuzzleHttp\Handler\EasyHandle $easy) : void
689 + {
690 + $signature = $easy->proxyTunnelSignature;
691 + if ($signature === null) {
692 + return;
693 + }
694 + $id = (int) $easy->handle;
695 + if (isset($this->activeProxyTunnelHandles[$id])) {
696 + if ($this->activeProxyTunnelHandles[$id] === $signature) {
697 + return;
698 + }
699 + $this->unmarkProxyTunnelActiveById($id);
700 + }
701 + $this->activeProxyTunnelHandles[$id] = $signature;
702 + $this->activeProxyTunnelSignatures[$signature] = ($this->activeProxyTunnelSignatures[$signature] ?? 0) + 1;
703 + }
704 + private function unmarkProxyTunnelActive(\YoastSEO_Vendor\GuzzleHttp\Handler\EasyHandle $easy) : void
705 + {
706 + $this->unmarkProxyTunnelActiveById((int) $easy->handle);
707 + }
708 + private function unmarkProxyTunnelActiveById(int $id) : void
709 + {
710 + if (!isset($this->activeProxyTunnelHandles[$id])) {
711 + return;
712 + }
713 + $signature = $this->activeProxyTunnelHandles[$id];
714 + unset($this->activeProxyTunnelHandles[$id]);
715 + if (!isset($this->activeProxyTunnelSignatures[$signature])) {
716 + return;
717 + }
718 + --$this->activeProxyTunnelSignatures[$signature];
719 + if ($this->activeProxyTunnelSignatures[$signature] <= 0) {
720 + unset($this->activeProxyTunnelSignatures[$signature]);
721 + }
722 + }
723 + /**
147 724 * Ticks the curl event loop.
148 725 */
149 726 public function tick() : void
150 727 {
151 - // Add any delayed handles if needed.
152 - if ($this->delays) {
728 + $this->tickFor(null, null);
729 + }
730 + /**
731 + * Ticks the curl event loop, returning before the blocking select if the
732 + * targeted transfer has settled, been canceled, or been replaced by a
733 + * request that reused its native handle ID.
734 + */
735 + private function tickFor(?int $targetId, ?object $waitToken) : void
736 + {
737 + // Add any delayed handles if needed. Attachment is skipped while a
738 + // callback has native execution busy; the outer frame attaches due
739 + // transfers once it unwinds.
740 + if ($this->delays && 0 === $this->multiExecDepth) {
153 741 $currentTime = \YoastSEO_Vendor\GuzzleHttp\Utils::currentTime();
154 742 foreach ($this->delays as $id => $delay) {
155 743 if ($currentTime >= $delay) {
744 + $entry = $this->handles[$id];
156 745 unset($this->delays[$id]);
157 - \curl_multi_add_handle($this->_mh, $this->handles[$id]['easy']->handle);
746 + try {
747 + $this->addCurlHandle($entry['easy']);
748 + } catch (\Throwable $e) {
749 + // The promise has already escaped, so reject it
750 + // rather than throw.
751 + $rejection = $this->discardPendingRequest($id, $entry, $e);
752 + if (\YoastSEO_Vendor\GuzzleHttp\Promise\Is::pending($entry['deferred'])) {
753 + $entry['deferred']->reject($rejection);
754 + }
755 + }
158 756 }
159 757 }
160 758 }
161 - // Run curl_multi_exec in the queue to enable other async tasks to run
162 - \YoastSEO_Vendor\GuzzleHttp\Promise\Utils::queue()->add(\Closure::fromCallable([$this, 'tickInQueue']));
163 - // Step through the task queue which may add additional requests.
164 - \YoastSEO_Vendor\GuzzleHttp\Promise\Utils::queue()->run();
165 - if ($this->active && \curl_multi_select($this->_mh, $this->selectTimeout) === -1) {
759 + // Run curl_multi_exec in the queue to enable other async tasks to
760 + // run, surface completions, and drain any work they queued so a
761 + // ready cancellation or new transfer is not held behind the select.
762 + do {
763 + \YoastSEO_Vendor\GuzzleHttp\Promise\Utils::queue()->add(\Closure::fromCallable([$this, 'tickInQueue']));
764 + // Step through the task queue which may add additional requests.
765 + \YoastSEO_Vendor\GuzzleHttp\Promise\Utils::queue()->run();
766 + if ($this->multiExecDepth > 0) {
767 + // A cURL callback re-entered the handler while native
768 + // execution is running; the outer frame drives native cURL
769 + // once it unwinds.
770 + return;
771 + }
772 + if (isset($this->_mh)) {
773 + $this->processMessages();
774 + }
775 + } while (!\YoastSEO_Vendor\GuzzleHttp\Promise\Utils::queue()->isEmpty());
776 + if (!isset($this->_mh)) {
777 + // Nothing is attached natively (or initialization just failed);
778 + // there is nothing to run and nothing to recreate the handle for.
779 + return;
780 + }
781 + if ($targetId !== null && !$this->hasRequest($targetId, $waitToken)) {
782 + return;
783 + }
784 + if ($this->active && \curl_multi_select($this->_mh, $this->effectiveSelectTimeout()) === -1) {
166 785 // Perform a usleep if a select returns -1.
167 786 // See: https://bugs.php.net/bug.php?id=61141
168 787 \usleep(250);
169 788 }
170 789 do {
171 - $this->executingMulti = \true;
172 - try {
173 - $exec = \curl_multi_exec($this->_mh, $this->active);
174 - } finally {
175 - $this->executingMulti = \false;
176 - $this->cleanupDeferredCancels();
177 - }
790 + $exec = $this->executeMulti();
178 791 // Prevent busy looping for slow HTTP requests.
179 792 if ($exec === \CURLM_CALL_MULTI_PERFORM) {
180 - \curl_multi_select($this->_mh, $this->selectTimeout);
793 + \curl_multi_select($this->_mh, $this->effectiveSelectTimeout());
181 794 }
182 795 } while ($exec === \CURLM_CALL_MULTI_PERFORM);
183 796 $this->processMessages();
184 797 }
@@ -186,15 +799,19 @@
186 799 * Runs \curl_multi_exec() inside the event loop, to prevent busy looping
187 800 */
188 801 private function tickInQueue() : void
189 802 {
190 - $this->executingMulti = \true;
191 - try {
192 - $exec = \curl_multi_exec($this->_mh, $this->active);
193 - } finally {
194 - $this->executingMulti = \false;
195 - $this->cleanupDeferredCancels();
803 + if ($this->multiExecDepth > 0) {
804 + // A cURL callback re-entered the handler while native execution
805 + // is running; the outer frame drives native cURL once it unwinds.
806 + return;
196 807 }
808 + if (!isset($this->_mh)) {
809 + // Nothing is attached natively (or initialization just failed);
810 + // there is nothing to run and nothing to recreate the handle for.
811 + return;
812 + }
813 + $exec = $this->executeMulti();
197 814 if ($exec === \CURLM_CALL_MULTI_PERFORM) {
198 815 \curl_multi_select($this->_mh, 0);
199 816 \YoastSEO_Vendor\GuzzleHttp\Promise\Utils::queue()->add(\Closure::fromCallable([$this, 'tickInQueue']));
200 817 }
@@ -199,58 +816,220 @@
199 816 \YoastSEO_Vendor\GuzzleHttp\Promise\Utils::queue()->add(\Closure::fromCallable([$this, 'tickInQueue']));
200 817 }
201 818 }
202 819 /**
820 + * @phpstan-impure
821 + */
822 + private function executeMulti() : int
823 + {
824 + ++$this->multiExecDepth;
825 + try {
826 + return \curl_multi_exec($this->_mh, $this->active);
827 + } finally {
828 + --$this->multiExecDepth;
829 + $this->finishDeferredWork();
830 + }
831 + }
832 + /**
833 + * Flushes cancels and attachments deferred while the multi handle was
834 + * busy executing transfers or removing a handle.
835 + */
836 + private function finishDeferredWork() : void
837 + {
838 + if ($this->multiExecDepth > 0 || $this->finishingDeferredWork) {
839 + // A nested frame (a cURL callback re-entered the handler) must
840 + // not flush while an outer frame is still using the multi
841 + // handle; the outermost frame flushes once it unwinds.
842 + return;
843 + }
844 + $this->finishingDeferredWork = \true;
845 + try {
846 + $failure = null;
847 + // Removing a cancelled transfer runs its final progress update,
848 + // whose callback can cancel other transfers or create requests;
849 + // drain until no deferred work remains.
850 + do {
851 + $this->cleanupDeferredCancels($failure);
852 + $this->flushDeferredAdds();
853 + } while ($this->deferredCancels !== [] || $this->deferredAdds !== []);
854 + if ($failure !== null) {
855 + throw $failure;
856 + }
857 + } finally {
858 + $this->finishingDeferredWork = \false;
859 + }
860 + }
861 + /**
203 862 * Runs until all outstanding connections have completed.
204 863 */
205 864 public function execute() : void
206 865 {
866 + if ($this->multiExecDepth > 0) {
867 + // Native cURL cannot be driven while a callback has it busy, so
868 + // the loop would spin without ever progressing.
869 + throw new \LogicException('Cannot run the cURL multi event loop from inside a cURL callback; the callback must return before transfers can progress.');
870 + }
207 871 $queue = \YoastSEO_Vendor\GuzzleHttp\Promise\Utils::queue();
208 872 while ($this->handles || !$queue->isEmpty()) {
209 - // If there are no transfers, then sleep for the next delay
210 - if (!$this->active && $this->delays) {
873 + // If there are no transfers, then sleep for the next delay,
874 + // unless ready queue work could change what is pending.
875 + if (!$this->active && $this->delays && $queue->isEmpty()) {
211 876 \usleep($this->timeToNext());
212 877 }
213 878 $this->tick();
214 879 }
215 880 }
881 + /**
882 + * Runs the event loop until the given transfer has finished, so waiting
883 + * on a promise does not wait for every other transfer on the handler
884 + * like execute() does.
885 + *
886 + * The native cURL handle ID can be reused by a request created from a
887 + * completion callback, so the wait token guards against waiting on an
888 + * unrelated transfer that inherited the ID.
889 + */
890 + private function executeUntil(int $id, object $waitToken) : void
891 + {
892 + $queue = \YoastSEO_Vendor\GuzzleHttp\Promise\Utils::queue();
893 + while ($this->hasRequest($id, $waitToken)) {
894 + // If the transfer is delayed, then sleep until it is due, unless
895 + // ready queue work could cancel or replace it first.
896 + if (!$this->active && isset($this->delays[$id]) && $queue->isEmpty()) {
897 + \usleep($this->timeToNext());
898 + }
899 + $this->tickFor($id, $waitToken);
900 + }
901 + if (!$queue->isEmpty()) {
902 + $queue->run();
903 + }
904 + }
905 + /**
906 + * Checks that the request with the given handle ID is still pending and,
907 + * when a wait token is given, has not been replaced by a request that
908 + * reused the ID.
909 + */
910 + private function hasRequest(int $id, ?object $waitToken = null) : bool
911 + {
912 + if (!isset($this->handles[$id])) {
913 + return \false;
914 + }
915 + return $waitToken === null || ($this->handles[$id]['wait_token'] ?? null) === $waitToken;
916 + }
216 917 private function addRequest(array $entry) : void
217 918 {
218 919 $easy = $entry['easy'];
219 920 $id = (int) $easy->handle;
921 + $entry['attached'] = \false;
220 922 $this->handles[$id] = $entry;
221 - if (empty($easy->options['delay'])) {
222 - \curl_multi_add_handle($this->_mh, $easy->handle);
923 + if (!empty($easy->options['delay'])) {
924 + $this->delays[$id] = \YoastSEO_Vendor\GuzzleHttp\Utils::currentTime() + $easy->options['delay'] / 1000;
925 + } elseif ($this->multiExecDepth > 0) {
926 + // A request created from inside a cURL callback cannot be added
927 + // natively while curl_multi_exec() is running; libcurl 7.59+
928 + // rejects the recursive call. Attach it once the outermost
929 + // native execution unwinds.
930 + $this->deferredAdds[$id] = $entry['wait_token'] ?? null;
223 931 } else {
224 - $this->delays[$id] = \YoastSEO_Vendor\GuzzleHttp\Utils::currentTime() + $easy->options['delay'] / 1000;
932 + $this->addCurlHandle($easy);
225 933 }
226 934 }
227 935 /**
936 + * Rolls back a request that can no longer be attached, releasing the
937 + * easy handle exactly once and preserving the original failure.
938 + *
939 + * @param array{easy: EasyHandle, deferred: Promise, wait_token?: object|null, attached?: bool} $entry
940 + */
941 + private function discardPendingRequest(int $id, array $entry, \Throwable $failure) : \Throwable
942 + {
943 + unset($this->handles[$id], $this->delays[$id], $this->deferredAdds[$id]);
944 + try {
945 + $this->factory->release($entry['easy']);
946 + } catch (\Throwable $e) {
947 + // Preserve the original failure.
948 + }
949 + return $failure;
950 + }
951 + /**
952 + * Fails a synchronous wait attempted from inside a cURL callback, where
953 + * native execution cannot progress until the callback returns.
954 + */
955 + private function failNestedWait(int $id, object $token) : void
956 + {
957 + if (!$this->hasRequest($id, $token)) {
958 + return;
959 + }
960 + $entry = $this->handles[$id];
961 + $failure = new \YoastSEO_Vendor\GuzzleHttp\Exception\RequestException('Cannot synchronously wait for a transfer from inside a cURL callback on the same cURL multi handler; the callback must return before the transfer can progress.', $entry['easy']->request, $entry['easy']->response);
962 + if (!empty($entry['attached'])) {
963 + // Native removal must wait until the outermost execution unwinds.
964 + unset($this->handles[$id], $this->delays[$id], $this->deferredAdds[$id]);
965 + $this->deferredCancels[$id] = ['easy' => $entry['easy'], 'attached' => \true];
966 + } else {
967 + $this->discardPendingRequest($id, $entry, $failure);
968 + }
969 + $entry['deferred']->reject($failure);
970 + }
971 + /**
972 + * Attaches requests whose native attachment was deferred because they
973 + * were created from inside a cURL callback.
974 + */
975 + private function flushDeferredAdds() : void
976 + {
977 + if ($this->deferredAdds === []) {
978 + return;
979 + }
980 + $adds = $this->deferredAdds;
981 + $this->deferredAdds = [];
982 + foreach ($adds as $id => $token) {
983 + if (!$this->hasRequest($id, $token)) {
984 + // Cancelled or replaced while the attachment was deferred.
985 + continue;
986 + }
987 + $entry = $this->handles[$id];
988 + try {
989 + $this->addCurlHandle($entry['easy']);
990 + } catch (\Throwable $e) {
991 + // The promise has already escaped, so reject it rather than
992 + // throw. User code may have settled it directly; a settled
993 + // promise must not abort the rest of the snapshot.
994 + $rejection = $this->discardPendingRequest($id, $entry, $e);
995 + if (\YoastSEO_Vendor\GuzzleHttp\Promise\Is::pending($entry['deferred'])) {
996 + $entry['deferred']->reject($rejection);
997 + }
998 + }
999 + }
1000 + }
1001 + /**
228 1002 * Cancels a handle from sending and removes references to it.
229 1003 *
230 - * @param int $id Handle ID to cancel and remove.
1004 + * @param int $id Handle ID to cancel and remove.
1005 + * @param object|null $waitToken Identity token that must still match the
1006 + * entry when given.
231 1007 *
232 1008 * @return bool True on success, false on failure.
233 1009 */
234 - private function cancel($id) : bool
1010 + private function cancel($id, ?object $waitToken = null) : bool
235 1011 {
236 1012 if (!\is_int($id)) {
237 1013 \YoastSEO_Vendor\trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing an int to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__);
238 1014 }
239 - // Cannot cancel if it has been processed.
240 - if (!isset($this->handles[$id])) {
1015 + // Cannot cancel if it has been processed or replaced by a request
1016 + // that reused the native handle ID.
1017 + if (!isset($this->handles[$id]) || $waitToken !== null && ($this->handles[$id]['wait_token'] ?? null) !== $waitToken) {
241 1018 return \false;
242 1019 }
243 - $easy = $this->handles[$id]['easy'];
244 - unset($this->delays[$id], $this->handles[$id]);
245 - if ($this->executingMulti) {
246 - $this->deferredCancels[$id] = $easy;
1020 + $entry = $this->handles[$id];
1021 + $easy = $entry['easy'];
1022 + $attached = !empty($entry['attached']);
1023 + unset($this->delays[$id], $this->deferredAdds[$id], $this->handles[$id]);
1024 + if ($this->multiExecDepth > 0) {
1025 + $this->deferredCancels[$id] = ['easy' => $easy, 'attached' => $attached];
247 1026 return \true;
248 1027 }
249 - $this->cleanupCancelledHandle($easy);
1028 + $this->cleanupCancelledHandle($easy, $attached);
250 1029 return \true;
251 1030 }
252 - private function cleanupDeferredCancels() : void
1031 + private function cleanupDeferredCancels(?\Throwable &$failure) : void
253 1032 {
254 1033 if ($this->deferredCancels === []) {
255 1034 return;
256 1035 }
@@ -255,59 +1034,128 @@
255 1034 return;
256 1035 }
257 1036 $entries = $this->deferredCancels;
258 1037 $this->deferredCancels = [];
259 - foreach ($entries as $easy) {
260 - $this->cleanupCancelledHandle($easy);
1038 + foreach ($entries as $entry) {
1039 + try {
1040 + $this->cleanupCancelledHandle($entry['easy'], $entry['attached']);
1041 + } catch (\Throwable $e) {
1042 + // A final progress update can run a throwing user callback;
1043 + // clean the remaining entries and surface the first failure
1044 + // once the drain completes.
1045 + if ($failure === null) {
1046 + $failure = $e;
1047 + }
1048 + }
261 1049 }
262 1050 }
263 - private function cleanupCancelledHandle(\YoastSEO_Vendor\GuzzleHttp\Handler\EasyHandle $easy) : void
1051 + private function cleanupCancelledHandle(\YoastSEO_Vendor\GuzzleHttp\Handler\EasyHandle $easy, bool $attached) : void
264 1052 {
265 1053 $handle = $easy->handle;
266 - \curl_multi_remove_handle($this->_mh, $handle);
1054 + $failure = null;
1055 + if ($attached) {
1056 + try {
1057 + $this->removeHandleFromMulti($handle);
1058 + } catch (\Throwable $e) {
1059 + // The native detach completes even when its final progress
1060 + // callback throws; finish this entry before rethrowing.
1061 + $failure = $e;
1062 + }
1063 + }
1064 + $this->unmarkProxyTunnelActive($easy);
267 1065 if (\PHP_VERSION_ID < 80000) {
268 - \curl_close($handle);
1066 + try {
1067 + \curl_close($handle);
1068 + } catch (\Throwable $e) {
1069 + // An error handler can promote the close warning; keep the
1070 + // first failure.
1071 + if ($failure === null) {
1072 + $failure = $e;
1073 + }
1074 + }
269 1075 }
1076 + if ($failure !== null) {
1077 + throw $failure;
1078 + }
270 1079 }
271 1080 private function processMessages() : void
272 1081 {
273 - while ($done = \curl_multi_info_read($this->_mh)) {
274 - if ($done['msg'] !== \CURLMSG_DONE) {
275 - // if it's not done, then it would be premature to remove the handle. ref https://github.com/guzzle/guzzle/pull/2892#issuecomment-945150216
276 - continue;
1082 + // CurlFactory::finish can retry a transfer by re-invoking this handler
1083 + // from inside this loop; the guard keeps that re-entry from recreating
1084 + // the multi handle mid-iteration (see applyProxyTunnelOwnership). A
1085 + // depth is tracked because a completion callback can re-enter tick(),
1086 + // and the nested frame must not clear the outer loop's guard.
1087 + ++$this->messageProcessingDepth;
1088 + try {
1089 + while ($done = \curl_multi_info_read($this->_mh)) {
1090 + if ($done['msg'] !== \CURLMSG_DONE) {
1091 + // if it's not done, then it would be premature to remove the handle. ref https://github.com/guzzle/guzzle/pull/2892#issuecomment-945150216
1092 + continue;
1093 + }
1094 + if (!isset($done['handle'])) {
1095 + // Work around a PHP issue where cancelled transfers may omit the handle.
1096 + // Remove this once we no longer support PHP versions before the fix in
1097 + // https://github.com/php/php-src/pull/16302.
1098 + continue;
1099 + }
1100 + $id = (int) $done['handle'];
1101 + $this->removeCompletedHandleFromMulti($id, $done['handle']);
1102 + if (!isset($this->handles[$id])) {
1103 + // Probably was cancelled.
1104 + continue;
1105 + }
1106 + $entry = $this->handles[$id];
1107 + unset($this->handles[$id], $this->delays[$id]);
1108 + $entry['easy']->errno = $done['result'];
1109 + // finish() can run completion callbacks that cancel this
1110 + // promise; a settled promise must not be settled again.
1111 + try {
1112 + $result = \YoastSEO_Vendor\GuzzleHttp\Handler\CurlFactory::finish($this, $entry['easy'], $this->factory);
1113 + } catch (\Throwable $e) {
1114 + if (\YoastSEO_Vendor\GuzzleHttp\Promise\Is::pending($entry['deferred'])) {
1115 + $entry['deferred']->reject($e);
1116 + }
1117 + continue;
1118 + }
1119 + if (\YoastSEO_Vendor\GuzzleHttp\Promise\Is::pending($entry['deferred'])) {
1120 + $entry['deferred']->resolve($result);
1121 + }
277 1122 }
278 - if (!isset($done['handle'])) {
279 - // Work around a PHP issue where cancelled transfers may omit the handle.
280 - // Remove this once we no longer support PHP versions before the fix in
281 - // https://github.com/php/php-src/pull/16302.
282 - continue;
283 - }
284 - $id = (int) $done['handle'];
285 - \curl_multi_remove_handle($this->_mh, $done['handle']);
286 - if (!isset($this->handles[$id])) {
287 - // Probably was cancelled.
288 - continue;
289 - }
290 - $entry = $this->handles[$id];
291 - unset($this->handles[$id], $this->delays[$id]);
292 - $entry['easy']->errno = $done['result'];
293 - try {
294 - $result = \YoastSEO_Vendor\GuzzleHttp\Handler\CurlFactory::finish($this, $entry['easy'], $this->factory);
295 - } catch (\Throwable $e) {
296 - $entry['deferred']->reject($e);
297 - continue;
298 - }
299 - $entry['deferred']->resolve($result);
1123 + } finally {
1124 + --$this->messageProcessingDepth;
300 1125 }
301 1126 }
302 - private function timeToNext() : int
1127 + /**
1128 + * Bounds a blocking select by the earliest pending request delay so a
1129 + * delayed transfer becoming due does not wait out an unrelated
1130 + * transfer's full select timeout.
1131 + *
1132 + * @return float|int
1133 + */
1134 + private function effectiveSelectTimeout()
303 1135 {
1136 + if ($this->delays === []) {
1137 + return $this->selectTimeout;
1138 + }
1139 + return \min($this->selectTimeout, $this->secondsToNext());
1140 + }
1141 + /**
1142 + * @return float Seconds until the earliest pending delay is due
1143 + */
1144 + private function secondsToNext() : float
1145 + {
304 1146 $currentTime = \YoastSEO_Vendor\GuzzleHttp\Utils::currentTime();
305 - $nextTime = \PHP_INT_MAX;
1147 + $nextTime = \PHP_FLOAT_MAX;
306 1148 foreach ($this->delays as $time) {
307 1149 if ($time < $nextTime) {
308 1150 $nextTime = $time;
309 1151 }
310 1152 }
311 - return (int) \max(0, $nextTime - $currentTime) * 1000000;
1153 + return \max(0.0, $nextTime - $currentTime);
1154 + }
1155 + private function timeToNext() : int
1156 + {
1157 + // PHP_INT_MAX first: min() then returns the int operand whenever the
1158 + // microseconds exceed it, so the cast never sees an oversized float.
1159 + return (int) \min(\PHP_INT_MAX, $this->secondsToNext() * 1000000);
312 1160 }
313 1161 }