OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-svc commits / 3c8f9b6

uds: reap idle connections (server-side leak defense)
The existing end/error->destroy handlers clean up when a client CLOSES or EXITS. But a caller that opens a UDS connection and then neither closes it nor exits (a long-lived process that forgot Client.close()) leaves the accepted socket ESTAB forever — no end/close/error ever fires, so the server can't react. This grew a prod pgh-sidecar to ~348k ESTAB unix sockets / ~22GB RSS over 3 days (2026-07-05). Add an idle reaper: sweep _connections and destroy any idle past idleTimeout (default 120s) with no request in flight. Activity resets the timer on every frame; _inflight guards against reaping mid-dispatch, so only genuinely dead connections are reclaimed. idleTimeout:0 disables. Verified: leaked idle conns reaped; in-flight request survives a sweep; clean-close + abrupt-disconnect paths unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3c8f9b65106afb6e4148b6b9ae14d8ba94c6a5c3
by plankatron <fleeky@gmail.com> · 2026-07-05T23:21:48.000Z

Changed files (3) +84 -1

lib/transports/uds.js +41 -0
……@@ -33,6 +33,18 @@ export class UdsServer {
3333 this._server = null
3434 this._connections = new Set()
3535 this._listening = false
36
37 // Idle-connection reaper. `allowHalfOpen:false` + the end/error handlers
38 // below clean up when a client CLOSES or EXITS. But a caller that opens a
39 // connection and then neither closes it nor exits (a long-lived process
40 // that forgets Client.close()) leaves the accepted socket ESTAB forever —
41 // the OS never delivers an end/close the server can react to. Unbounded,
42 // this grew pgh-sidecar to ~348k ESTAB unix sockets / ~22GB (2026-07-05).
43 // Sweep and destroy connections idle past _idleTimeout with no request in
44 // flight. Request/response callers reset the timer on every frame, so only
45 // genuinely dead connections are reaped. Set idleTimeout:0 to disable.
46 this._idleTimeout = opts.idleTimeout === undefined ? 120000 : opts.idleTimeout
47 this._sweepTimer = null
3648 }
3749
3850 get path () { return this._path }
……@@ -75,6 +87,25 @@ export class UdsServer {
7587 })
7688
7789 this._listening = true
90
91 if (this._idleTimeout > 0) {
92 const sweepEvery = Math.max(1000, Math.min(30000, Math.floor(this._idleTimeout / 2)))
93 this._sweepTimer = setInterval(() => this._reapIdle(), sweepEvery)
94 // Don't let the reaper keep the process alive on its own.
95 if (this._sweepTimer && typeof this._sweepTimer.unref === 'function') this._sweepTimer.unref()
96 }
97 }
98
99 // Destroy connections idle past _idleTimeout with no request in flight.
100 // destroy() fires 'close', which removes the socket from _connections.
101 _reapIdle () {
102 const now = Date.now()
103 for (const sock of this._connections) {
104 if ((sock._inflight || 0) > 0) continue
105 if (now - (sock._lastActive || 0) > this._idleTimeout) {
106 try { sock.destroy() } catch {}
107 }
108 }
78109 }
79110
80111 _probe () {
……@@ -93,6 +124,8 @@ export class UdsServer {
93124
94125 _onConnection (sock) {
95126 this._connections.add(sock)
127 sock._lastActive = Date.now()
128 sock._inflight = 0
96129 sock.on('close', () => this._connections.delete(sock))
97130
98131 // Peer closed their write side (client finished + disconnected). bare-net
……@@ -104,6 +137,7 @@ export class UdsServer {
104137 const decoder = new FrameDecoder((req) => this._handleRequest(sock, req))
105138
106139 sock.on('data', (chunk) => {
140 sock._lastActive = Date.now()
107141 try {
108142 decoder.push(chunk)
109143 } catch (err) {
……@@ -138,6 +172,9 @@ export class UdsServer {
138172 identity: { type: 'local', userId: process.env.USER || null, trust: 'local' }
139173 }
140174
175 // Mark in-flight so the idle reaper never destroys a socket mid-dispatch,
176 // even if the handler runs longer than _idleTimeout.
177 sock._inflight = (sock._inflight || 0) + 1
141178 try {
142179 const result = await this._dispatch(req.method, req.params, ctx)
143180 try {
……@@ -147,11 +184,15 @@ export class UdsServer {
147184 try {
148185 sock.write(encodeFrame({ id, error: err.message || String(err) }))
149186 } catch {}
187 } finally {
188 sock._inflight = Math.max(0, (sock._inflight || 1) - 1)
189 sock._lastActive = Date.now()
150190 }
151191 }
152192
153193 async close () {
154194 if (!this._server) return
195 if (this._sweepTimer) { clearInterval(this._sweepTimer); this._sweepTimer = null }
155196 for (const sock of this._connections) {
156197 try { sock.destroy() } catch {}
157198 }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "hyper-svc",
3 "version": "0.1.4",
3 "version": "0.1.5",
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 +42 -0
……@@ -15,6 +15,7 @@ import { mkdtempSync } from '#node:fs'
1515import { join } from '#node:path'
1616import { tmpdir } from '#node:os'
1717import { UdsServer } from '../lib/transports/uds.js'
18import { Client } from '../lib/client.js'
1819
1920function tmpSockPath () {
2021 return join(mkdtempSync(join(tmpdir(), 'hyper-svc-uds-leak-')), 'svc.sock')
……@@ -58,3 +59,44 @@ test('UDS: many abrupt disconnects do not accumulate sockets', async (t) => {
5859 }
5960 t.is(server._connections.size, 0, '25 abrupt disconnects left zero leaked sockets')
6061})
62
63// Idle-reaper regression: a client that connects and then neither closes nor
64// exits (a long-lived caller that forgot Client.close()) leaves the accepted
65// socket ESTAB forever — no end/close/error ever fires. This is distinct from
66// the abrupt-disconnect cases above (which DO fire events) and is what grew a
67// prod sidecar to ~348k ESTAB unix sockets / ~22GB (2026-07-05). The server
68// now sweeps and destroys connections idle past idleTimeout.
69test('UDS: idle connection with no in-flight request is reaped (leak defense)', async (t) => {
70 const server = new UdsServer({ path: tmpSockPath(), dispatch: async () => ({ ok: true }), idleTimeout: 400 })
71 await server.listen()
72 t.teardown(async () => { try { await server.close() } catch {} })
73
74 // Connect and never close/exit; keep the ref so it isn't GC'd. The OS delivers
75 // no end/close to the server — only the reaper can reclaim it.
76 const client = net.createConnection(server.path)
77 t.teardown(() => { try { client.destroy() } catch {} })
78 t.ok(await waitFor(() => server._connections.size === 1), 'connection registered')
79
80 const reaped = await waitFor(() => server._connections.size === 0, 5000)
81 t.ok(reaped, 'idle connection reaped past idleTimeout — server-side leak defense')
82})
83
84test('UDS: reaper never kills a connection mid-request', async (t) => {
85 let release
86 const gate = new Promise((r) => { release = r })
87 const server = new UdsServer({
88 path: tmpSockPath(),
89 idleTimeout: 300,
90 dispatch: async () => { await gate; return { ok: true } } // outlives idleTimeout
91 })
92 await server.listen()
93 t.teardown(async () => { try { await server.close() } catch {} })
94
95 const client = await Client.connectUds(server.path)
96 t.teardown(async () => { try { await client.close() } catch {} })
97 const callP = client.call('slow', {})
98 await new Promise((r) => setTimeout(r, 800)) // let the reaper sweep while in-flight
99 t.is(server._connections.size, 1, 'in-flight connection survived the reaper')
100 release()
101 t.alike(await callP, { ok: true }, 'in-flight request completed after reaper sweeps')
102})