Skip to content

fix: close HTTP sockets after responding - #375

Merged
brianhuster merged 1 commit into
brianhuster:mainfrom
alswl:fix/socket-fd-leak
Aug 25, 2026
Merged

fix: close HTTP sockets after responding#375
brianhuster merged 1 commit into
brianhuster:mainfrom
alswl:fix/socket-fd-leak

Conversation

@alswl

@alswl alswl commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

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:

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

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.

## 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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your pull request! We appreciate your contribution. Please ensure that your changes are well documented and tested, and are formatted as noexpandtab.

@alswl

alswl commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

@brianhuster Hi, could you take a moment to look at it, thanks?

@brianhuster

Copy link
Copy Markdown
Owner

Thank u, lgtm

@brianhuster
brianhuster merged commit a6307fa into brianhuster:main Aug 25, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants