OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-svc / test / uds-leak.test.js

103 lines · javascript ref:
// 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')
})