diff --git a/src/Symfony/Component/HttpClient/Internal/ClientState.php b/src/Symfony/Component/HttpClient/Internal/ClientState.php index c316e7b078920..52fe3c8c054c7 100644 --- a/src/Symfony/Component/HttpClient/Internal/ClientState.php +++ b/src/Symfony/Component/HttpClient/Internal/ClientState.php @@ -22,4 +22,5 @@ class ClientState { public $handlesActivity = []; public $openHandles = []; + public $lastTimeout; } diff --git a/src/Symfony/Component/HttpClient/Response/ResponseTrait.php b/src/Symfony/Component/HttpClient/Response/ResponseTrait.php index 69caabf4b85d0..0f041d4d02d21 100644 --- a/src/Symfony/Component/HttpClient/Response/ResponseTrait.php +++ b/src/Symfony/Component/HttpClient/Response/ResponseTrait.php @@ -233,15 +233,15 @@ abstract protected static function perform(ClientState $multi, array &$responses */ abstract protected static function select(ClientState $multi, float $timeout): int; - private static function initialize(self $response): void + private static function initialize(self $response, float $timeout = null): void { if (null !== $response->info['error']) { throw new TransportException($response->info['error']); } try { - if (($response->initializer)($response)) { - foreach (self::stream([$response]) as $chunk) { + if (($response->initializer)($response, $timeout)) { + foreach (self::stream([$response], $timeout) as $chunk) { if ($chunk->isFirst()) { break; } @@ -304,7 +304,7 @@ private function doDestruct() $this->shouldBuffer = true; if ($this->initializer && null === $this->info['error']) { - self::initialize($this); + self::initialize($this, -0.0); $this->checkStatusCode(); } } @@ -325,6 +325,12 @@ public static function stream(iterable $responses, float $timeout = null): \Gene $lastActivity = microtime(true); $elapsedTimeout = 0; + if ($fromLastTimeout = 0.0 === $timeout && '-0' === (string) $timeout) { + $timeout = null; + } elseif ($fromLastTimeout = 0 > $timeout) { + $timeout = -$timeout; + } + while (true) { $hasActivity = false; $timeoutMax = 0; @@ -340,13 +346,18 @@ public static function stream(iterable $responses, float $timeout = null): \Gene $timeoutMin = min($timeoutMin, $response->timeout, 1); $chunk = false; + if ($fromLastTimeout && null !== $multi->lastTimeout) { + $elapsedTimeout = microtime(true) - $multi->lastTimeout; + } + if (isset($multi->handlesActivity[$j])) { - // no-op + $multi->lastTimeout = null; } elseif (!isset($multi->openHandles[$j])) { unset($responses[$j]); continue; } elseif ($elapsedTimeout >= $timeoutMax) { $multi->handlesActivity[$j] = [new ErrorChunk($response->offset, sprintf('Idle timeout reached for "%s".', $response->getInfo('url')))]; + $multi->lastTimeout ?? $multi->lastTimeout = $lastActivity; } else { continue; } diff --git a/src/Symfony/Component/HttpClient/Tests/HttpClientTestCase.php b/src/Symfony/Component/HttpClient/Tests/HttpClientTestCase.php index a3782df49e5ed..85466d33b5438 100644 --- a/src/Symfony/Component/HttpClient/Tests/HttpClientTestCase.php +++ b/src/Symfony/Component/HttpClient/Tests/HttpClientTestCase.php @@ -19,6 +19,15 @@ abstract class HttpClientTestCase extends BaseHttpClientTestCase { + public function testTimeoutOnDestruct() + { + if (!method_exists(parent::class, 'testTimeoutOnDestruct')) { + $this->markTestSkipped('BaseHttpClientTestCase doesn\'t have testTimeoutOnDestruct().'); + } + + parent::testTimeoutOnDestruct(); + } + public function testAcceptHeader() { $client = $this->getHttpClient(__FUNCTION__); diff --git a/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php index 27cc87f0ccfbd..75bf16d3e6c86 100644 --- a/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php @@ -115,6 +115,10 @@ protected function getHttpClient(string $testCase): HttpClientInterface $this->markTestSkipped('Real transport required'); break; + case 'testTimeoutOnDestruct': + $this->markTestSkipped('Real transport required'); + break; + case 'testDestruct': $this->markTestSkipped("MockHttpClient doesn't timeout on destruct"); break; diff --git a/src/Symfony/Component/HttpClient/Tests/NativeHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/NativeHttpClientTest.php index bcfab64bdcace..2f76cc91c609f 100644 --- a/src/Symfony/Component/HttpClient/Tests/NativeHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/NativeHttpClientTest.php @@ -25,4 +25,9 @@ public function testInformationalResponseStream() { $this->markTestSkipped('NativeHttpClient doesn\'t support informational status codes.'); } + + public function testTimeoutOnDestruct() + { + $this->markTestSkipped('NativeHttpClient doesn\'t support opening concurrent requests.'); + } } diff --git a/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php b/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php index f3e75c9337c2c..1062c7c024b4d 100644 --- a/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php +++ b/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php @@ -810,6 +810,38 @@ public function testTimeoutWithActiveConcurrentStream() } } + public function testTimeoutOnDestruct() + { + $p1 = TestHttpServer::start(8067); + $p2 = TestHttpServer::start(8077); + + $client = $this->getHttpClient(__FUNCTION__); + $start = microtime(true); + $responses = []; + + $responses[] = $client->request('GET', 'http://localhost:8067/timeout-header', ['timeout' => 0.25]); + $responses[] = $client->request('GET', 'http://localhost:8077/timeout-header', ['timeout' => 0.25]); + $responses[] = $client->request('GET', 'http://localhost:8067/timeout-header', ['timeout' => 0.25]); + $responses[] = $client->request('GET', 'http://localhost:8077/timeout-header', ['timeout' => 0.25]); + + try { + while ($response = array_shift($responses)) { + try { + unset($response); + $this->fail(TransportExceptionInterface::class.' expected'); + } catch (TransportExceptionInterface $e) { + } + } + + $duration = microtime(true) - $start; + + $this->assertLessThan(1.0, $duration); + } finally { + $p1->stop(); + $p2->stop(); + } + } + public function testDestruct() { $client = $this->getHttpClient(__FUNCTION__);