Skip to content

Close the client when the HTTP driver returns normally - #391

Open
wtsergo wants to merge 1 commit into
amphp:3.xfrom
wtsergo:fix/close-client-on-driver-return
Open

Close the client when the HTTP driver returns normally#391
wtsergo wants to merge 1 commit into
amphp:3.xfrom
wtsergo:fix/close-client-on-driver-return

Conversation

@wtsergo

@wtsergo wtsergo commented Aug 19, 2026

Copy link
Copy Markdown

Fixes #390.

SocketHttpServer::handleClient() closes the client on only three paths: a failed TLS handshake, a not-yet-started server, or an exception reaching the catch block. When $driver->handleClient() returns normally the finally block merely unsets the driver from the map, and the socket is never closed:

try {
    $driver->handleClient($client, $socket, $socket);
} finally {
    unset($this->drivers[$id]);   // no $client->close()
}

Nothing frees the descriptor afterwards. Neither ReadableResourceStream::close() nor WritableResourceStream::close() calls fclose() on a socket — each does stream_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 no close() 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 watcher finally, 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 30k EPIPE writes/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 the finally.
  • test/SocketHttpServerTest.php — regression test using a no-op HttpDriver that simply returns from handleClient(), reproducing the same control flow deterministically (no timing race). It fails on 3.x without the fix and passes with it.

The full suite passes (126 tests, 488 assertions).

Branched off the fork's 3.x rather than current upstream 3.x only to avoid touching .github/workflows/ci.yml (token scope); src/SocketHttpServer.php is identical between the two, so the diff is unaffected.

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
wtsergo force-pushed the fix/close-client-on-driver-return branch from c1c734f to 6a16319 Compare August 19, 2026 09:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

SocketHttpServer never closes the client when the HTTP driver returns normally (fd leak, can pin a core at 100%)

1 participant