fix: close HTTP sockets after responding - #375
Merged
Merged
Conversation
## What Every HTTP request leaks a file descriptor. `handler.client` calls `read_stop()` as soon as a request is buffered, so the EOF branch that would close the socket never runs. `send_http_response` only writes, and never closes either. The sockets sit in `CLOSE_WAIT` until the process dies. ## Why it matters A markdown page load is about 10 requests: the file itself, plus the katex, mermaid, highlight and markdown-it assets. macOS defaults to 256 descriptors, so a normal editing session reaches the limit in an afternoon. Past that point the server stops accepting connections, and `processes_listening_on_port` throws `EMFILE` out of `vim.system`. It also snowballs. `ws-client.js` reloads the page whenever its socket drops, and retries every second. Each reload is another 10 requests. ## How **HTTP.** Close the socket in `send_http_response` once the write drains. `shutdown()` rather than `close()`, otherwise large responses get truncated. **WebSocket.** Same cause: reads stop after the upgrade, so a closed tab is never noticed. Re-arm reads purely to watch for EOF. Two related fixes while in here: - `connecting_clients` now only holds WebSocket clients. It exists to broadcast WebSocket frames, but every accepted socket was added to it, so `send_json` was writing frames into plain HTTP connections. - `Server:stop` closes the remaining clients, instead of leaving their descriptors open until Neovim exits. ## Evidence Load only this plugin, start a server on 5701, then: ```sh for i in $(seq 1 60); do curl -s -o /dev/null http://127.0.0.1:5701/index.md done lsof -p $PID | grep IPv4 | awk '{print $NF}' | sort | uniq -c ``` | | sockets after 60 requests | | --- | --- | | without the fix | 60 `CLOSE_WAIT` + 1 `LISTEN` | | with the fix | 1 `LISTEN` | No regressions found: a 892KB markdown file still comes back whole (914542 bytes, closing `</html>` intact), and four WebSockets stay open while connected, then get reaped once the peers disconnect.
Contributor
Author
|
@brianhuster Hi, could you take a moment to look at it, thanks? |
Owner
|
Thank u, lgtm |
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.
What
Every HTTP request leaks a file descriptor.
handler.clientcallsread_stop()as soon as a request is buffered, so the EOF branch that would close the socket never runs.send_http_responseonly writes, and never closes either. The sockets sit inCLOSE_WAITuntil the process dies.Why it matters
A markdown page load is about 10 requests: the file itself, plus the katex, mermaid, highlight and markdown-it assets.
macOS defaults to 256 descriptors, so a normal editing session reaches the limit in an afternoon.
Past that point the server stops accepting connections, and
processes_listening_on_portthrowsEMFILEout ofvim.system.It also snowballs.
ws-client.jsreloads the page whenever its socket drops, and retries every second. Each reload is another 10 requests.How
HTTP. Close the socket in
send_http_responseonce the write drains.shutdown()rather thanclose(), otherwise large responses get truncated.WebSocket. Same cause: reads stop after the upgrade, so a closed tab is never noticed. Re-arm reads purely to watch for EOF.
Two related fixes while in here:
connecting_clientsnow only holds WebSocket clients. It exists to broadcast WebSocket frames, but every accepted socket was added to it, sosend_jsonwas writing frames into plain HTTP connections.Server:stopcloses the remaining clients, instead of leaving their descriptors open until Neovim exits.Evidence
Load only this plugin, start a server on 5701, then:
CLOSE_WAIT+ 1LISTENNo regressions found: a 892KB markdown file still comes back whole (914542 bytes, closing
</html>intact), and four WebSockets stay open while connected, then get reaped once the peers disconnect.