From 6a16319bc77f1ca109b6101c0fc3131f2565a46f Mon Sep 17 00:00:00 2001 From: wtsergo Date: Wed, 19 Aug 2026 05:19:11 -0400 Subject: [PATCH] Close the client when the HTTP driver returns normally SocketHttpServer::handleClient() closed the client only on a failed TLS handshake, a not-yet-started server, or an exception reaching the catch block. When the driver returned normally the finally block merely unset the driver from the map, leaving the socket open. Nothing frees the descriptor afterwards, since neither Readable- nor WritableResourceStream::close() calls fclose() on a socket; each does stream_socket_shutdown() and drops its own reference, so the fd is released only once both halves free it. If a write was still queued at that point, WritableResourceStream leaves its writability watcher enabled. A dead socket is permanently writable to epoll, so the watcher then retries a failing write indefinitely. --- src/SocketHttpServer.php | 1 + test/SocketHttpServerTest.php | 104 ++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 test/SocketHttpServerTest.php diff --git a/src/SocketHttpServer.php b/src/SocketHttpServer.php index 5f828ab7..22aa9592 100644 --- a/src/SocketHttpServer.php +++ b/src/SocketHttpServer.php @@ -365,6 +365,7 @@ private function handleClient( try { $driver->handleClient($client, $socket, $socket); } finally { + $client->close(); unset($this->drivers[$id]); } } catch (\Throwable $exception) { diff --git a/test/SocketHttpServerTest.php b/test/SocketHttpServerTest.php new file mode 100644 index 00000000..537c4b4e --- /dev/null +++ b/test/SocketHttpServerTest.php @@ -0,0 +1,104 @@ +client = $client; + // Return normally, as a driver does when the connection ends + // without throwing. + } + + public function getPendingRequestCount(): int + { + return 0; + } + + public function getApplicationLayerProtocols(): array + { + return []; + } + + public function stop(): void + { + } + }; + + $driverFactory = new class($driver) implements HttpDriverFactory { + public function __construct(private readonly HttpDriver $driver) + { + } + + public function createHttpDriver( + RequestHandler $requestHandler, + ErrorHandler $errorHandler, + Client $client, + ): HttpDriver { + return $this->driver; + } + + public function getApplicationLayerProtocols(): array + { + return []; + } + }; + + $server = new SocketHttpServer( + new NullLogger(), + new Socket\ResourceServerSocketFactory(), + new SocketClientFactory(new NullLogger()), + httpDriverFactory: $driverFactory, + ); + + $server->expose(Socket\SocketAddress\fromString('127.0.0.1:0')); + $server->start( + new ClosureRequestHandler(fn () => new Response(HttpStatus::OK)), + new DefaultErrorHandler(), + ); + + try { + $client = Socket\connect($server->getServers()[0]->getAddress()->toString()); + $client->write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); + delay(0.1); + $client->close(); + delay(0.1); + + self::assertNotNull($driver->client, 'Driver did not receive a client'); + self::assertTrue( + $driver->client->isClosed(), + 'Client was not closed after the driver returned; its socket and ' + . 'event-loop watcher leak for the lifetime of the process', + ); + } finally { + $server->stop(); + } + } +}