OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-deploy / test / connection-release.test.js

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