From ffba7f5f041c6b5b6f3fe7633bc156f8d907b184 Mon Sep 17 00:00:00 2001 From: kafkiansky Date: Sun, 11 Oct 2026 19:13:42 +0300 Subject: [PATCH] Close HTTP/2 socket on shutdown without active streams Http2ConnectionProcessor::shutdown() queues GOAWAY and releases the active streams, but the socket is closed only in releaseStream() once the last stream is released. Closing a connection without active streams, e.g. an idle one, therefore only sent GOAWAY and left the socket open until the server closed it, which some servers (grpc-go) never do. The pool also kept waiting for such a connection to close, so the next request to the same authority could hang. Close the socket once the GOAWAY frame has been written. --- .../Internal/Http2ConnectionProcessor.php | 6 +++++- test/Connection/Http2ConnectionTest.php | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/Connection/Internal/Http2ConnectionProcessor.php b/src/Connection/Internal/Http2ConnectionProcessor.php index 71fe070e..727426c9 100644 --- a/src/Connection/Internal/Http2ConnectionProcessor.php +++ b/src/Connection/Internal/Http2ConnectionProcessor.php @@ -1480,7 +1480,11 @@ private function shutdown(?HttpException $reason = null, ?int $lastId = null): v default => null, }; - $this->writeFrame(Http2Parser::GOAWAY, data: \pack('NN', 0, $code) . $message)->ignore(); + // Without active streams nothing else closes the socket, see releaseStream(). Closing it right away + // would discard the GOAWAY frame still waiting in the write queue. + $this->writeFrame(Http2Parser::GOAWAY, data: \pack('NN', 0, $code) . $message) + ->finally($this->socket->close(...)) + ->ignore(); foreach ($this->streams as $id => $_stream) { $reason ??= $this->makeDefaultShutdownReason(); diff --git a/test/Connection/Http2ConnectionTest.php b/test/Connection/Http2ConnectionTest.php index 3f4ff6cd..10a751de 100644 --- a/test/Connection/Http2ConnectionTest.php +++ b/test/Connection/Http2ConnectionTest.php @@ -591,6 +591,22 @@ public function testServerGoAwayFrame(): void } } + public function testCloseWithoutStreamsClosesSocket(): void + { + $this->connection->close(); + + $received = ''; + while (null !== $chunk = $this->server->read(new TimeoutCancellation(1))) { + $received .= $chunk; + } + + self::assertTrue($this->client->isClosed()); + self::assertStringEndsWith( + self::packFrame(\pack('NN', 0, Http2Parser::GRACEFUL_SHUTDOWN), Http2Parser::GOAWAY), + $received, + ); + } + public function testServerEarlyResponse(): void { $request = new Request('http://localhost/', 'POST', BufferedContent::fromString(\str_repeat('a', 2 ** 10)));