| … | … | @@ -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 | |
| 12 | import test from 'brittle' |
| 13 | import net from '#node:net' |
| 14 | import { mkdtempSync } from '#node:fs' |
| 15 | import { join } from '#node:path' |
| 16 | import { tmpdir } from '#node:os' |
| 17 | import { UdsServer } from '../lib/transports/uds.js' |
| 18 | |
| 19 | function tmpSockPath () { |
| 20 | return join(mkdtempSync(join(tmpdir(), 'hyper-svc-uds-leak-')), 'svc.sock') |
| 21 | } |
| 22 | async 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 | |
| 28 | test('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 | |
| 47 | test('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 | }) |