OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-svc commits / e640c26

uds: destroy socket on 'error' too — fixes abrupt-disconnect fd leak (0.1.4)
The 0.1.3 fix destroyed the accepted socket on 'end' (clean FIN), but the 'error' handler still swallowed abrupt disconnects (ECONNRESET / client killed / timed-out mid-request) WITHOUT destroying. bare-net's net.Socket only emits 'close' on an explicit .destroy(), so those sockets — fd + 64KB read buffer + _connections entry — leaked forever. A client hammering the UDS with timed-out/killed calls accumulated thousands (observed: 6645 on a prod sidecar), starving swarm connections. Fix: the 'error' handler destroys the socket, same as 'end'. Regression test in test/uds-leak.test.js (abrupt disconnect + 25-cycle accumulation), 2/2 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
e640c267ac7a8b77daf565b57ad218566dc1b21e
by plankatron <fleeky@gmail.com> · 2026-07-02T14:06:35.000Z

Changed files (3) +70 -2

lib/transports/uds.js +9 -1
……@@ -112,7 +112,15 @@ export class UdsServer {
112112 }
113113 })
114114
115 sock.on('error', () => { /* swallow — close handler runs anyway */ })
115 // Abrupt disconnect (ECONNRESET, or the client process killed / timed out
116 // mid-request). bare-net does NOT emit 'close' on its own (see the 'end'
117 // note above) — and unlike a clean FIN there is no 'end' either — so we
118 // MUST destroy() here to release the fd + 64KB read buffer and drop the
119 // socket from _connections. Without this, every abrupt client disconnect
120 // leaks a half-open socket (the 0.1.3 'end'-only fix covered clean closes
121 // but not this path — the cause of the 6645-socket accumulation seen when
122 // a client hammers the UDS with timed-out/killed calls).
123 sock.on('error', () => { try { sock.destroy() } catch {} })
116124 }
117125
118126 async _handleRequest (sock, req) {
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "hyper-svc",
3 "version": "0.1.3",
3 "version": "0.1.4",
44 "description": "Reusable per-app daemon library — UDS + HTTP for local RPC, Hyperswarm for peer mirroring",
55 "type": "module",
66 "main": "index.js",
test/uds-leak.test.js +60 -0
……@@ -0,0 +1,60 @@
1// UDS fd-leak regression: abrupt client disconnect must release the socket.
2//
3// bare-net's net.Socket does NOT emit 'close' on its own — only on an explicit
4// .destroy(). The 0.1.3 fix destroyed on 'end' (clean FIN), but the 'error'
5// handler swallowed abrupt disconnects (ECONNRESET / client killed / timed-out)
6// WITHOUT destroying — so those sockets (fd + 64KB read buffer + _connections
7// entry) leaked forever. Hammering the UDS with timed-out/killed calls
8// accumulated thousands of leaked sockets (observed: 6645 on a prod sidecar).
9//
10// Fix (0.1.4): the 'error' handler destroys the socket, same as 'end'.
11
12import test from 'brittle'
13import net from '#node:net'
14import { mkdtempSync } from '#node:fs'
15import { join } from '#node:path'
16import { tmpdir } from '#node:os'
17import { UdsServer } from '../lib/transports/uds.js'
18
19function tmpSockPath () {
20 return join(mkdtempSync(join(tmpdir(), 'hyper-svc-uds-leak-')), 'svc.sock')
21}
22async function waitFor (pred, ms = 5000) {
23 const t0 = Date.now()
24 while (Date.now() - t0 < ms) { if (pred()) return true; await new Promise((r) => setTimeout(r, 20)) }
25 return false
26}
27
28test('UDS: abrupt client disconnect releases the socket (no fd leak)', async (t) => {
29 const server = new UdsServer({ path: tmpSockPath(), dispatch: async () => ({ ok: true }) })
30 await server.listen()
31 t.teardown(async () => { try { await server.close() } catch {} })
32
33 const client = net.createConnection(server.path)
34 t.teardown(() => { try { client.destroy() } catch {} })
35
36 t.ok(await waitFor(() => server._connections.size === 1), 'server registered the connection')
37
38 // ABRUPT disconnect: destroy() without end() → the peer vanishes (RST/error),
39 // the exact path the old 'error' handler swallowed.
40 client.destroy()
41
42 const cleaned = await waitFor(() => server._connections.size === 0, 5000)
43 t.ok(cleaned, 'abrupt disconnect released the socket — _connections drained (fix: destroy on error)')
44 t.is(server._connections.size, 0, 'no leaked socket left in the connection set')
45})
46
47test('UDS: many abrupt disconnects do not accumulate sockets', async (t) => {
48 const server = new UdsServer({ path: tmpSockPath(), dispatch: async () => ({ ok: true }) })
49 await server.listen()
50 t.teardown(async () => { try { await server.close() } catch {} })
51
52 // 25 connect-then-abruptly-kill cycles (mimics timed-out/killed pgh calls).
53 for (let i = 0; i < 25; i++) {
54 const c = net.createConnection(server.path)
55 await waitFor(() => server._connections.size >= 1, 2000)
56 c.destroy()
57 await waitFor(() => server._connections.size === 0, 2000)
58 }
59 t.is(server._connections.size, 0, '25 abrupt disconnects left zero leaked sockets')
60})