/**
* DeployServer must release per-connection overlay state when a peer goes away.
*
* OverlayProtocol keeps two per-mux collections (hyper-overlay/lib/protocol.js):
*
* _peers Map<mux, peer> -- pruned by the channel's own onclose handler
* _paired Set<mux> -- pruned ONLY by detach(mux)
*
* attach() deliberately leaves the mux.pair() registration in place after a
* channel closes, so a peer that restarts can re-pair for the connection's
* whole life. That is correct for a LIVE connection and a leak for a dead one:
* the entry (and the mux it pins, and the stream the mux pins) is only released
* by detach(), and DeployServer._onConnection never called it -- it removed its
* own _sessions entry on close and left the overlay attached.
*
* One DeployServer holds ONE shared _overlayProtocol and attaches it to every
* inbound mux, so under connection churn _paired grows without bound.
*
* Production, tabertron 2026-09-02 10:04:22: the pgh-sidecar's bare process
* reached anon-rss 22512044kB (~22 GB, total-vm 43 GB) after 3817s and was
* OOM-killed -- by a wide margin the largest process on the box (next was
* python at ~1.3 GB). Its log shows the churn that drove it: a continuous
* stream of deploy-server ETIMEDOUT / ECONNRESET / "Duplicate connection".
*/
import test from 'brittle'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import crypto from 'hypercore-crypto'
import SecretStream from '@hyperswarm/secret-stream'
import { DeployServer } from '../index.js'
function mktemp (prefix) {
const dir = path.join(os.tmpdir(), 'hyper-deploy-connrel-' + prefix + '-' + crypto.randomBytes(6).toString('hex'))
fs.mkdirSync(dir, { recursive: true })
return dir
}
function pair () {
const a = new SecretStream(true)
const b = new SecretStream(false)
a.rawStream.pipe(b.rawStream).pipe(a.rawStream)
return { a, b }
}
async function waitFor (fn, timeoutMs = 5000) {
const t0 = Date.now()
while (Date.now() - t0 < timeoutMs) {
if (fn()) return true
await new Promise((r) => setTimeout(r, 25))
}
return false
}
test('overlay state is released when a connection closes', async (t) => {
t.timeout(30000)
const dir = mktemp('rel')
fs.writeFileSync(path.join(dir, 'a.txt'), 'hello')
const server = new DeployServer(dir)
await server.ready()
t.teardown(async () => {
try { await server.close() } catch {}
try { fs.rmSync(dir, { recursive: true, force: true }) } catch {}
})
const proto = server._overlayProtocol
t.ok(proto, 'overlay protocol is enabled')
const N = 5
const conns = []
for (let i = 0; i < N; i++) {
const p = pair()
conns.push(p)
server._onConnection(p.a)
}
t.is(proto._paired.size, N, 'one paired mux per live connection')
for (const p of conns) {
p.a.destroy()
p.b.destroy()
}
await waitFor(() => proto._paired.size === 0 && proto._peers.size === 0)
t.is(proto._peers.size, 0, 'peers released on close')
t.is(proto._paired.size, 0, 'paired muxes released on close — otherwise every dead connection is pinned for the life of the process')
t.is(server.connections, 0, 'sessions released on close')
})
/**
* The SAME leak via the PUBLIC serve path.
*
* `serveOnConnection` is the documented way to mux content-serving onto a
* connection the caller already owns ("the keet many-protocols-one-connection
* pattern"). pgh-sidecar uses exactly that on its registry swarm
* (pgh-sidecar/lib/index.js:362) — it does its own `_registryStore.replicate(conn)`
* first, then calls `serveOnConnection(conn)`.
*
* 0.4.11 fixed the leak only for connections that arrive through DeployServer's
* OWN swarm, because `_detachOverlay` is called from `_onConnection`'s close/error
* handlers. A caller that uses the public API gets `attach()` with no inverse:
* `_paired` (and the mux, and the mux's stream) stays pinned for the life of the
* process, which is the whole point of an always-on daemon.
*
* The public API has no public inverse, so no caller can fix this from outside.
*/
test('overlay state is released for a connection served via the public serveOnConnection', async (t) => {
t.timeout(30000)
const dir = mktemp('pub')
fs.writeFileSync(path.join(dir, 'a.txt'), 'hello')
const server = new DeployServer(dir)
await server.ready()
t.teardown(async () => {
try { await server.close() } catch {}
try { fs.rmSync(dir, { recursive: true, force: true }) } catch {}
})
const proto = server._overlayProtocol
t.ok(proto, 'overlay protocol is enabled')
const N = 5
const conns = []
for (let i = 0; i < N; i++) {
const p = pair()
conns.push(p)
// Exactly what pgh-sidecar does on its registry-swarm connections.
server.serveOnConnection(p.a)
}
t.is(proto._paired.size, N, 'one paired mux per live connection')
for (const p of conns) {
p.a.destroy()
p.b.destroy()
}
await waitFor(() => proto._paired.size === 0)
t.is(proto._paired.size, 0, 'paired muxes released when a publicly-served connection dies — otherwise an always-on caller pins every connection it ever served')
})