Skip to content

Close the client when the HTTP/1.x driver finishes, unless upgraded - #397

Open
lav45 wants to merge 1 commit into
amphp:3.xfrom
lav45:fix/http1-driver-closes-client
Open

lav45 wants to merge 1 commit into
amphp:3.xfrom
lav45:fix/http1-driver-closes-client

Conversation

@lav45

@lav45 lav45 commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #390. Alternative to #391.

Problem

HttpDriver::handleClient() returns normally in two different situations, and SocketHttpServer can't tell them apart:

  1. The connection is over (peer FIN/RST, idle keep-alive, server stopping). The client must be closed, otherwise the socket and its event-loop watcher leak (SocketHttpServer never closes the client when the HTTP driver returns normally (fd leak, can pin a core at 100%) #390).
  2. The socket was handed to an upgrade handler. The client must stay open.

#391 closes the client in SocketHttpServer's finally, which also covers case 2. As noted in the review there, this breaks amphp/websocket-server. I reproduced it: the handshake completes, and then the client immediately gets 1006 ABNORMAL_CLOSE ("Writing to the client failed"). Websocket::reapClient() only queues the client handler and returns, so by the time finally runs, the server is closing a socket that now belongs to the upgrade handler.

Fix

Only the driver knows whether it handed the socket off, so the driver should own the close. Http2Driver already does this: shutdown() calls $this->client->close(). Only Http1Driver leaves the client open. This PR makes it consistent
with Http2Driver:

private bool $upgraded = false;                                                                                                                                                                                                                   
                                                                                                                                                                                                                                                  
// handleClient(), in the existing pendingResponse->finally() callback                                                                                                                                                                            
if (!$this->upgraded) {                                                                                                                                                                                                                           
    $this->client->close();                                                                                                                                                                                                                       
}                                                                                                                                                                                                                                                 
                                                                                                                                                                                                                                                  
// upgrade(), at the point where ownership is handed off                                                                                                                                                                                          
$socket = new UpgradedSocket($client, $stream, $this->writableStream);                                                                                                                                                                            
$this->upgraded = true;                                                                                                                                                                                                                           
  • The close runs in pendingResponse->finally(), so it happens only after the last response has been written and after any upgrade has taken place.
  • If the upgrade handler throws, upgrade() already closes the client, as before.
  • SocketHttpServer and the HttpDriver interface are unchanged, so there is no BC break and third-party drivers behave exactly as before.

Verification

Scripts against a real server, with amphp/websocket-server and amphp/websocket-client for the upgrade case:

websocket echo server-side client closed after peer FIN / RST on keep-alive
3.x works no (leak)
#391 breaks after handshake (1006) yes
this PR works yes

Tests

In test/Driver/Http1DriverTest.php:

  • testClientClosedWhenConnectionEnds (new): the client is closed when the connection ends normally. Fails on 3.x.
  • testUpgradedClientNotClosed (new): the client is not closed after an upgrade. This is the websocket case from Close the client when the HTTP driver returns normally #391.
  • testTimeoutSuspendedDuringRequestHandler (adjusted): it asserted that close() is never called. What it actually means to check is that the connection timeout does not close the client while the request handler is running. It now asserts
    that close() is called exactly once, after the handler has finished. Fails on 3.x.

This branch has not been deployed

No deployments
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