// UDS fd-leak regression: abrupt client disconnect must release the socket.
//
// bare-net's net.Socket does NOT emit 'close' on its own — only on an explicit
// .destroy(). The 0.1.3 fix destroyed on 'end' (clean FIN), but the 'error'
// handler swallowed abrupt disconnects (ECONNRESET / client killed / timed-out)
// WITHOUT destroying — so those sockets (fd + 64KB read buffer + _connections
// entry) leaked forever. Hammering the UDS with timed-out/killed calls
// accumulated thousands of leaked sockets (observed: 6645 on a prod sidecar).
//
// Fix (0.1.4): the 'error' handler destroys the socket, same as 'end'.
import test from 'brittle'
import net from '#node:net'
import { mkdtempSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import { UdsServer } from '../lib/transports/uds.js'
import { Client } from '../lib/client.js'
function tmpSockPath () {
return join(mkdtempSync(join(tmpdir(), 'hyper-svc-uds-leak-')), 'svc.sock')
}
async function waitFor (pred, ms = 5000) {
const t0 = Date.now()
while (Date.now() - t0 < ms) { if (pred()) return true; await new Promise((r) => setTimeout(r, 20)) }
return false
}
test('UDS: abrupt client disconnect releases the socket (no fd leak)', async (t) => {
const server = new UdsServer({ path: tmpSockPath(), dispatch: async () => ({ ok: true }) })
await server.listen()
t.teardown(async () => { try { await server.close() } catch {} })
const client = net.createConnection(server.path)
t.teardown(() => { try { client.destroy() } catch {} })
t.ok(await waitFor(() => server._connections.size === 1), 'server registered the connection')
// ABRUPT disconnect: destroy() without end() → the peer vanishes (RST/error),
// the exact path the old 'error' handler swallowed.
client.destroy()
const cleaned = await waitFor(() => server._connections.size === 0, 5000)
t.ok(cleaned, 'abrupt disconnect released the socket — _connections drained (fix: destroy on error)')
t.is(server._connections.size, 0, 'no leaked socket left in the connection set')
})
test('UDS: many abrupt disconnects do not accumulate sockets', async (t) => {
const server = new UdsServer({ path: tmpSockPath(), dispatch: async () => ({ ok: true }) })
await server.listen()
t.teardown(async () => { try { await server.close() } catch {} })
// 25 connect-then-abruptly-kill cycles (mimics timed-out/killed pgh calls).
for (let i = 0; i < 25; i++) {
const c = net.createConnection(server.path)
await waitFor(() => server._connections.size >= 1, 2000)
c.destroy()
await waitFor(() => server._connections.size === 0, 2000)
}
t.is(server._connections.size, 0, '25 abrupt disconnects left zero leaked sockets')
})
// Idle-reaper regression: a client that connects and then neither closes nor
// exits (a long-lived caller that forgot Client.close()) leaves the accepted
// socket ESTAB forever — no end/close/error ever fires. This is distinct from
// the abrupt-disconnect cases above (which DO fire events) and is what grew a
// prod sidecar to ~348k ESTAB unix sockets / ~22GB (2026-07-05). The server
// now sweeps and destroys connections idle past idleTimeout.
test('UDS: idle connection with no in-flight request is reaped (leak defense)', async (t) => {
const server = new UdsServer({ path: tmpSockPath(), dispatch: async () => ({ ok: true }), idleTimeout: 400 })
await server.listen()
t.teardown(async () => { try { await server.close() } catch {} })
// Connect and never close/exit; keep the ref so it isn't GC'd. The OS delivers
// no end/close to the server — only the reaper can reclaim it.
const client = net.createConnection(server.path)
t.teardown(() => { try { client.destroy() } catch {} })
t.ok(await waitFor(() => server._connections.size === 1), 'connection registered')
const reaped = await waitFor(() => server._connections.size === 0, 5000)
t.ok(reaped, 'idle connection reaped past idleTimeout — server-side leak defense')
})
test('UDS: reaper never kills a connection mid-request', async (t) => {
let release
const gate = new Promise((r) => { release = r })
const server = new UdsServer({
path: tmpSockPath(),
idleTimeout: 300,
dispatch: async () => { await gate; return { ok: true } } // outlives idleTimeout
})
await server.listen()
t.teardown(async () => { try { await server.close() } catch {} })
const client = await Client.connectUds(server.path)
t.teardown(async () => { try { await client.close() } catch {} })
const callP = client.call('slow', {})
await new Promise((r) => setTimeout(r, 800)) // let the reaper sweep while in-flight
t.is(server._connections.size, 1, 'in-flight connection survived the reaper')
release()
t.alike(await callP, { ok: true }, 'in-flight request completed after reaper sweeps')
})