| … | … | @@ -58,7 +58,13 @@ export class UdsServer { |
| 58 | 58 | if (err.message && err.message.indexOf('already in use') !== -1) throw err |
| 59 | 59 | } |
| 60 | 60 | |
| 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)) | |
| 62 | 68 | |
| 63 | 69 | await new Promise((resolve, reject) => { |
| 64 | 70 | this._server.once('error', reject) |
| … | … | @@ -89,6 +95,12 @@ export class UdsServer { |
| 89 | 95 | this._connections.add(sock) |
| 90 | 96 | sock.on('close', () => this._connections.delete(sock)) |
| 91 | 97 | |
| 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 | ||
| 92 | 104 | const decoder = new FrameDecoder((req) => this._handleRequest(sock, req)) |
| 93 | 105 | |
| 94 | 106 | sock.on('data', (chunk) => { |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "hyper-svc", |
| 3 | "version": "0.1.2", | |
| 3 | "version": "0.1.3", | |
| 4 | 4 | "description": "Reusable per-app daemon library — UDS + HTTP for local RPC, Hyperswarm for peer mirroring", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -230,6 +230,30 @@ test('UDS pipelined requests', async (t) => { |
| 230 | 230 | t.alike(results, [2, 4, 6, 8, 10]) |
| 231 | 231 | }) |
| 232 | 232 | |
| 233 | test('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 | ||
| 233 | 257 | // ── HTTP transport ─────────────────────────────────────────── |
| 234 | 258 | |
| 235 | 259 | test('HTTP round-trip', async (t) => { |