OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-svc commits / 4f75b26

uds: fix fd/buffer leak — close half-open sockets on peer disconnect
bare-net defaults allowHalfOpen:true (unlike Node) and its net.Socket never self-emits 'close'. UdsServer relied solely on 'close' to clean up and never destroyed sockets itself, so every client disconnect on the UDS permanently pinned a socket AND its 64KB read buffer in _connections. On pgh-sidecar's ~/.pgh/svc.sock this grew to ~205k leaked sockets / ~14GB RSS over 1.5 days (every pgh CLI call + OTA poll leaks ~2: udsReachable probe + real connect). Fix: create the server with allowHalfOpen:false so peer disconnect fires 'end', and destroy() the socket on 'end' so 'close' fires and the fd + object + buffer are released. Regression test asserts _connections drains to 0 after 30 open/close cycles (was 60 without the fix). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4f75b265b6a84f34c252c191ccbf738303088f63
by plankatron <fleeky@gmail.com> · 2026-06-09T23:02:52.000Z

Changed files (3) +38 -2

lib/transports/uds.js +13 -1
……@@ -58,7 +58,13 @@ export class UdsServer {
5858 if (err.message && err.message.indexOf('already in use') !== -1) throw err
5959 }
6060
61 this._server = net.createServer((sock) => this._onConnection(sock))
61 // allowHalfOpen:false is CRITICAL. bare-net defaults it to true (unlike
62 // Node), which means when a client disconnects the accepted socket never
63 // sees 'end' and never releases — the fd AND its 64KB read buffer leak.
64 // With false, peer disconnect surfaces as 'end', which _onConnection
65 // turns into a destroy() so the socket fully closes. See the fd-leak that
66 // grew pgh-sidecar to ~205k open sockets / 14GB RSS (2026-06-09).
67 this._server = net.createServer({ allowHalfOpen: false }, (sock) => this._onConnection(sock))
6268
6369 await new Promise((resolve, reject) => {
6470 this._server.once('error', reject)
……@@ -89,6 +95,12 @@ export class UdsServer {
8995 this._connections.add(sock)
9096 sock.on('close', () => this._connections.delete(sock))
9197
98 // Peer closed their write side (client finished + disconnected). bare-net
99 // does NOT emit 'close' on its own here — we must destroy() to release the
100 // fd and drop the socket (and its read buffer) from _connections. Safe even
101 // mid-dispatch: a late _handleRequest write is already wrapped in try/catch.
102 sock.on('end', () => { try { sock.destroy() } catch {} })
103
92104 const decoder = new FrameDecoder((req) => this._handleRequest(sock, req))
93105
94106 sock.on('data', (chunk) => {
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "hyper-svc",
3 "version": "0.1.2",
3 "version": "0.1.3",
44 "description": "Reusable per-app daemon library — UDS + HTTP for local RPC, Hyperswarm for peer mirroring",
55 "type": "module",
66 "main": "index.js",
test/basic.test.js +24 -0
……@@ -230,6 +230,30 @@ test('UDS pipelined requests', async (t) => {
230230 t.alike(results, [2, 4, 6, 8, 10])
231231})
232232
233test('UDS server reaps disconnected connections (no fd/buffer leak)', async (t) => {
234 // Regression for the 2026-06-09 pgh-sidecar leak: bare-net defaults
235 // allowHalfOpen:true and never emits 'close' on its own, so every client
236 // disconnect used to pin a socket (+ its 64KB read buffer) in the server's
237 // _connections set forever — 205k leaked sockets / 14GB RSS in production.
238 const sock = tmpSockPath()
239 const svc = new Service({ name: 'test', uds: sock })
240 svc.handle('echo', async (ctx, params) => params)
241 await svc.listen()
242 t.teardown(async () => { try { await svc.close() } catch {} })
243
244 const N = 30
245 for (let i = 0; i < N; i++) {
246 const client = await Client.discover({ uds: sock })
247 await client.call('echo', { i })
248 await client.close()
249 }
250
251 // Give the server a tick to process the disconnects.
252 await new Promise((r) => setTimeout(r, 200))
253
254 t.is(svc.uds._connections.size, 0, 'all disconnected connections were reaped')
255})
256
233257// ── HTTP transport ───────────────────────────────────────────
234258
235259test('HTTP round-trip', async (t) => {