OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-deploy commits / 7da5200

0.4.11: DeployServer pinned every connection it ever served
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 alive across a channel close so a peer that restarts can re-pair for the connection's whole life. That is correct while the connection LIVES and a leak once it dies -- the entry, the mux it pins, and that mux's stream are released only by detach(). `grep -rn "\.detach("` across hyper-deploy, pgh-sidecar and hyper-overlay returned nothing: detach() was dead code. One DeployServer holds ONE shared _overlayProtocol and attaches it to every inbound mux, so _paired grew by one permanently-pinned entry per connection, for the life of the process. Measured on tabertron 2026-09-02. The pgh-sidecar's bare process was OOM-killed at 10:04:22 with anon-rss 22512044kB (~22 GB, total-vm 43 GB) after 3817s -- by a wide margin the largest process on the box, the next being python at 1.3 GB. Its log shows the churn that drove it: a continuous stream of deploy-server ETIMEDOUT / ECONNRESET / "Duplicate connection". After a restart the same process went 304 MB at 4m29s -> 4719 MB at 18m38s, ~315 MB/min, which reaches 22 GB in ~64 min and reproduces the original timeline. DeployClient never attaches the overlay, so it was never affected. The test is red-first and isolates the collection at fault: after five connections are destroyed, _peers -> 0 and _sessions -> 0 both already passed; _paired stayed at 5. Pre-existing and unrelated, both confirmed on a clean tree with this change stashed: test/symlink-serve.test.js imports node fs/path/os instead of bare-*, which aborts the whole bare run before any test executes (same class as pgv3 0.1.235); test/e2e-release-sync.test.js aborts with "finalize failed: undefined". 22/22 with those two excluded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7da52004f754e694cf5ac25bc5a8199a34ec3e8e
by plankatron <fleeky@gmail.com> · 2026-09-02T14:25:47.000Z

Changed files (3) +122 -1

index.js +28 -0
……@@ -646,6 +646,32 @@ export class DeployServer extends ReadyResource {
646646 }
647647 }
648648
649 /**
650 * Release the overlay protocol's per-mux state for a dead connection.
651 *
652 * OverlayProtocol keeps `_peers` (pruned by the channel's own onclose) and
653 * `_paired` (pruned ONLY by detach). attach() deliberately leaves the
654 * mux.pair() registration alive across a channel close so a restarting peer
655 * can re-pair for the connection's whole life — correct while the connection
656 * lives, a leak once it dies. One DeployServer attaches ONE shared
657 * _overlayProtocol to every inbound mux, so without this every connection
658 * ever made stays pinned, along with its mux and that mux's stream.
659 *
660 * Left unpruned this reached anon-rss ~22 GB in 3817s on tabertron
661 * (2026-09-02) under deploy-server connection churn, and was OOM-killed.
662 *
663 * Protomux.from is idempotent, so this resolves the same mux
664 * serveOnConnection attached. Best-effort: detach() closes the channel, and
665 * on an already-closed connection that is a no-op we do not want to throw
666 * out of a 'close' handler.
667 */
668 _detachOverlay (conn) {
669 if (!this._overlayProtocol) return
670 try {
671 this._overlayProtocol.detach(Protomux.from(conn))
672 } catch {}
673 }
674
649675 _onConnection (conn) {
650676 const peerKey = conn.remotePublicKey
651677 ? conn.remotePublicKey.toString('hex').slice(0, 12)
……@@ -670,11 +696,13 @@ export class DeployServer extends ReadyResource {
670696
671697 conn.on('close', () => {
672698 this._sessions.delete(session)
699 this._detachOverlay(conn)
673700 this.emit('peer', { key: peerKey, state: 'disconnected' })
674701 })
675702
676703 conn.on('error', (err) => {
677704 this._sessions.delete(session)
705 this._detachOverlay(conn)
678706 this.emit('error', err)
679707 })
680708
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "hyper-deploy",
3 "version": "0.4.10",
3 "version": "0.4.11",
44 "description": "P2P release distribution — serve build artifacts to peers over Hyperswarm",
55 "type": "module",
66 "main": "index.js",
test/connection-release.test.js +93 -0
……@@ -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
26import test from 'brittle'
27import fs from 'bare-fs'
28import path from 'bare-path'
29import os from 'bare-os'
30import crypto from 'hypercore-crypto'
31import SecretStream from '@hyperswarm/secret-stream'
32
33import { DeployServer } from '../index.js'
34
35function 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
41function 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
48async 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
57test('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})