diff --git a/src/Composer/Util/Http/CurlDownloader.php b/src/Composer/Util/Http/CurlDownloader.php index ddba44a..7c5d3cf 100644 --- a/src/Composer/Util/Http/CurlDownloader.php +++ b/src/Composer/Util/Http/CurlDownloader.php @@ -69,6 +69,12 @@ class CurlDownloader * @var array */ private $retryAfterOrigins = []; + /** + * When tick() last waited on the transfers, they are not driven again until the next tick(), which a prompt can hold up + * + * @var float|null + */ + private $lastSelectAt = null; /** @var IOInterface */ private $io; /** @var Config */ @@ -397,12 +403,14 @@ class CurlDownloader return; } + $blockedFor = null !== $this->lastSelectAt ? microtime(true) - $this->lastSelectAt : 0.0; $active = true; $this->checkCurlResult(curl_multi_exec($this->multiHandle, $active)); if (-1 === curl_multi_select($this->multiHandle, $this->getSelectTimeout())) { // sleep in case select returns -1 as it can happen on old php versions or some platforms where curl does not manage to do the select usleep(150); } + $this->lastSelectAt = microtime(true); while ($progress = curl_multi_info_read($this->multiHandle)) { $curlHandle = $progress['handle']; @@ -437,6 +445,18 @@ class CurlDownloader } $progress['error_code'] = $errno; + // a request which spent most of its time blocked between two ticks, e.g. by a prompt for credentials, did not time out + // on its own, so it is retried without counting towards maxRetries, see https://github.com/composer/composer/issues/12402 + if ( + $errno === 28 /* CURLE_OPERATION_TIMEDOUT */ + && $blockedFor > $progress['total_time'] / 2 + && (!isset($job['options']['http']['method']) || $job['options']['http']['method'] === 'GET') + ) { + $this->io->writeError('Retrying ' . Url::sanitize($job['url']) . ' due to curl error 28 while the download loop was blocked', true, IOInterface::DEBUG); + $this->restartJob($job, $job['url']); + continue; + } + if ( (!isset($job['options']['http']['method']) || $job['options']['http']['method'] === 'GET') && ( diff --git a/tests/Composer/Test/Util/Http/CurlDownloaderTest.php b/tests/Composer/Test/Util/Http/CurlDownloaderTest.php index d893d46..0e92b27 100644 --- a/tests/Composer/Test/Util/Http/CurlDownloaderTest.php +++ b/tests/Composer/Test/Util/Http/CurlDownloaderTest.php @@ -200,6 +200,38 @@ class CurlDownloaderTest extends TestCase self::assertSame([], $this->readProperty($downloader, 'jobs'), 'a cancelled request must not be sent again'); } + public function testTimeoutWhileTheLoopWasBlockedDoesNotUseUpARetry(): void + { + // the kernel accepts the connection but nothing ever answers, so the request can only time out + $server = stream_socket_server('tcp://127.0.0.1:0'); + self::assertIsResource($server); + $address = stream_socket_get_name($server, false); + self::assertIsString($address); + + $downloader = $this->createDownloader(); + $failure = null; + // a request which has no retries left, e.g. after timing out during earlier prompts + self::callPrivate($downloader, 'initDownload', static function (): void { + }, static function (\Exception $e) use (&$failure): void { + $failure = $e->getMessage(); + }, $address, 'http://'.$address.'/packages.json', ['http' => ['timeout' => 1]], null, ['retries' => 3]); + + $downloader->tick(); + // a prompt for credentials keeps tick() from being called for longer than the request may take + usleep(1500000); + $downloader->tick(); + + self::assertNull($failure); + $jobs = $this->readProperty($downloader, 'jobs'); + self::assertIsArray($jobs); + self::assertCount(1, $jobs, 'the request must have been sent again'); + + foreach (array_keys($jobs) as $id) { + $downloader->abortRequest((int) $id); + } + fclose($server); + } + public function testRetryWaitsForTheLatestRetryAfterOfItsOrigin(): void { $io = new BufferIO();