| … | … | @@ -0,0 +1,93 @@ |
| 1 | /** |
| 2 | * DeployServer must release per-connection overlay state when a peer goes away. |
| 3 | * |
| 4 | * OverlayProtocol keeps two per-mux collections (hyper-overlay/lib/protocol.js): |
| 5 | * |
| 6 | * _peers Map<mux, peer> -- pruned by the channel's own onclose handler |
| 7 | * _paired Set<mux> -- pruned ONLY by detach(mux) |
| 8 | * |
| 9 | * attach() deliberately leaves the mux.pair() registration in place after a |
| 10 | * channel closes, so a peer that restarts can re-pair for the connection's |
| 11 | * whole life. That is correct for a LIVE connection and a leak for a dead one: |
| 12 | * the entry (and the mux it pins, and the stream the mux pins) is only released |
| 13 | * by detach(), and DeployServer._onConnection never called it -- it removed its |
| 14 | * own _sessions entry on close and left the overlay attached. |
| 15 | * |
| 16 | * One DeployServer holds ONE shared _overlayProtocol and attaches it to every |
| 17 | * inbound mux, so under connection churn _paired grows without bound. |
| 18 | * |
| 19 | * Production, tabertron 2026-09-02 10:04:22: the pgh-sidecar's bare process |
| 20 | * reached anon-rss 22512044kB (~22 GB, total-vm 43 GB) after 3817s and was |
| 21 | * OOM-killed -- by a wide margin the largest process on the box (next was |
| 22 | * python at ~1.3 GB). Its log shows the churn that drove it: a continuous |
| 23 | * stream of deploy-server ETIMEDOUT / ECONNRESET / "Duplicate connection". |
| 24 | */ |
| 25 | |
| 26 | import test from 'brittle' |
| 27 | import fs from 'bare-fs' |
| 28 | import path from 'bare-path' |
| 29 | import os from 'bare-os' |
| 30 | import crypto from 'hypercore-crypto' |
| 31 | import SecretStream from '@hyperswarm/secret-stream' |
| 32 | |
| 33 | import { DeployServer } from '../index.js' |
| 34 | |
| 35 | function mktemp (prefix) { |
| 36 | const dir = path.join(os.tmpdir(), 'hyper-deploy-connrel-' + prefix + '-' + crypto.randomBytes(6).toString('hex')) |
| 37 | fs.mkdirSync(dir, { recursive: true }) |
| 38 | return dir |
| 39 | } |
| 40 | |
| 41 | function pair () { |
| 42 | const a = new SecretStream(true) |
| 43 | const b = new SecretStream(false) |
| 44 | a.rawStream.pipe(b.rawStream).pipe(a.rawStream) |
| 45 | return { a, b } |
| 46 | } |
| 47 | |
| 48 | async function waitFor (fn, timeoutMs = 5000) { |
| 49 | const t0 = Date.now() |
| 50 | while (Date.now() - t0 < timeoutMs) { |
| 51 | if (fn()) return true |
| 52 | await new Promise((r) => setTimeout(r, 25)) |
| 53 | } |
| 54 | return false |
| 55 | } |
| 56 | |
| 57 | test('overlay state is released when a connection closes', async (t) => { |
| 58 | t.timeout(30000) |
| 59 | |
| 60 | const dir = mktemp('rel') |
| 61 | fs.writeFileSync(path.join(dir, 'a.txt'), 'hello') |
| 62 | |
| 63 | const server = new DeployServer(dir) |
| 64 | await server.ready() |
| 65 | t.teardown(async () => { |
| 66 | try { await server.close() } catch {} |
| 67 | try { fs.rmSync(dir, { recursive: true, force: true }) } catch {} |
| 68 | }) |
| 69 | |
| 70 | const proto = server._overlayProtocol |
| 71 | t.ok(proto, 'overlay protocol is enabled') |
| 72 | |
| 73 | const N = 5 |
| 74 | const conns = [] |
| 75 | for (let i = 0; i < N; i++) { |
| 76 | const p = pair() |
| 77 | conns.push(p) |
| 78 | server._onConnection(p.a) |
| 79 | } |
| 80 | |
| 81 | t.is(proto._paired.size, N, 'one paired mux per live connection') |
| 82 | |
| 83 | for (const p of conns) { |
| 84 | p.a.destroy() |
| 85 | p.b.destroy() |
| 86 | } |
| 87 | |
| 88 | await waitFor(() => proto._paired.size === 0 && proto._peers.size === 0) |
| 89 | |
| 90 | t.is(proto._peers.size, 0, 'peers released on close') |
| 91 | t.is(proto._paired.size, 0, 'paired muxes released on close — otherwise every dead connection is pinned for the life of the process') |
| 92 | t.is(server.connections, 0, 'sessions released on close') |
| 93 | }) |