Close the client when the HTTP driver returns normally - #391
Open
wtsergo wants to merge 1 commit into
Open
Conversation
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.
wtsergo
force-pushed
the
fix/close-client-on-driver-return
branch
from
August 19, 2026 09:22
c1c734f to
6a16319
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #390.
SocketHttpServer::handleClient()closes the client on only three paths: a failed TLS handshake, a not-yet-started server, or an exception reaching thecatchblock. When$driver->handleClient()returns normally thefinallyblock merely unsets the driver from the map, and the socket is never closed:Nothing frees the descriptor afterwards. Neither
ReadableResourceStream::close()norWritableResourceStream::close()callsfclose()on a socket — each doesstream_socket_shutdown(SHUT_RD/SHUT_WR)and drops its own reference, so the fd is released only once both halves free it and the refcount reaches zero. With noclose()at all, the descriptor and its event-loop watcher are retained for the lifetime of the process.The severity depends on the write queue at the moment of abandonment. In
WritableResourceStream's watcherfinally, an empty queue leaves the watcher disabled (an idle leak), but a non-empty queue leaves it enabled. A dead socket is permanently writable as far as epoll is concerned, so the watcher then retries a failing write forever — measured at roughly 30kEPIPEwrites/second per leaked descriptor.In production this showed up as two cluster workers pinned at 100% CPU continuously for 19 days, holding 35 and 37 orphaned descriptors. The trigger is a peer that RSTs a connection sitting idle in HTTP/1.1 keep-alive, i.e. ordinary internet scanner traffic against a public listener; roughly 60% of such connections leaked in testing. After applying this change the same production deployment sits at 0% idle CPU with zero orphaned descriptors.
Changes
src/SocketHttpServer.php— close the client in thefinally.test/SocketHttpServerTest.php— regression test using a no-opHttpDriverthat simply returns fromhandleClient(), reproducing the same control flow deterministically (no timing race). It fails on3.xwithout the fix and passes with it.The full suite passes (126 tests, 488 assertions).
Branched off the fork's
3.xrather than current upstream3.xonly to avoid touching.github/workflows/ci.yml(token scope);src/SocketHttpServer.phpis identical between the two, so the diff is unaffected.