| … | … | @@ -96,6 +96,11 @@ export class DeployServer extends ReadyResource { |
| 96 | 96 | this._transferManager = null |
| 97 | 97 | this._discovery = null |
| 98 | 98 | this._sessions = new Set() |
| 99 | // Connections we've already armed an overlay-release handler on, so a | |
| 100 | // repeated serveOnConnection (it is documented idempotent-ish, and | |
| 101 | // _onConnection calls it too) adds exactly one 'close' listener. Weak so | |
| 102 | // holding it never keeps a dead connection alive. See serveOnConnection. | |
| 103 | this._armedConns = new WeakSet() | |
| 99 | 104 | |
| 100 | 105 | // 0.5b tree publish + 0.5c tree serving: when trees are populated |
| 101 | 106 | // (which we do in _scan), also attach hyper-overlay/v2 to each |
| … | … | @@ -678,6 +683,14 @@ export class DeployServer extends ReadyResource { |
| 678 | 683 | this._overlayProtocol._contentHashPaths = contentHashPaths |
| 679 | 684 | this._overlayProtocol._filePaths = filePaths |
| 680 | 685 | this._overlayProtocol.attach(mux) |
| 686 | // attach() pairs the mux; only detach() unpairs it. Arm the inverse | |
| 687 | // HERE rather than leaving it to the caller: this is the public way to | |
| 688 | // mux content-serving onto a connection the caller owns, and it had no | |
| 689 | // public inverse, so an external caller could not release the pairing | |
| 690 | // even knowing it should. pgh-sidecar serves its registry-swarm | |
| 691 | // connections this way and pinned every one of them for the life of the | |
| 692 | // process. 0.4.11 fixed only connections arriving via our own swarm. | |
| 693 | this._armOverlayRelease(conn) | |
| 681 | 694 | return true |
| 682 | 695 | } catch (err) { |
| 683 | 696 | this.emit('error', new Error('failed to attach overlay-v2 protocol: ' + err.message)) |
| … | … | @@ -704,6 +717,19 @@ export class DeployServer extends ReadyResource { |
| 704 | 717 | * on an already-closed connection that is a no-op we do not want to throw |
| 705 | 718 | * out of a 'close' handler. |
| 706 | 719 | */ |
| 720 | /** | |
| 721 | * Drop the overlay pairing when `conn` closes. One listener per connection | |
| 722 | * (WeakSet-guarded), and _detachOverlay is idempotent — _paired.delete is | |
| 723 | * guarded and _peers.get returns early — so overlapping with | |
| 724 | * _onConnection's own close/error handlers is harmless. | |
| 725 | */ | |
| 726 | _armOverlayRelease (conn) { | |
| 727 | if (!conn || typeof conn.once !== 'function') return | |
| 728 | if (this._armedConns.has(conn)) return | |
| 729 | this._armedConns.add(conn) | |
| 730 | conn.once('close', () => this._detachOverlay(conn)) | |
| 731 | } | |
| 732 | ||
| 707 | 733 | _detachOverlay (conn) { |
| 708 | 734 | if (!this._overlayProtocol) return |
| 709 | 735 | try { |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "hyper-deploy", |
| 3 | "version": "0.4.12", | |
| 3 | "version": "0.4.13", | |
| 4 | 4 | "description": "P2P release distribution — serve build artifacts to peers over Hyperswarm", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -91,3 +91,57 @@ test('overlay state is released when a connection closes', async (t) => { |
| 91 | 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 | 92 | t.is(server.connections, 0, 'sessions released on close') |
| 93 | 93 | }) |
| 94 | ||
| 95 | /** | |
| 96 | * The SAME leak via the PUBLIC serve path. | |
| 97 | * | |
| 98 | * `serveOnConnection` is the documented way to mux content-serving onto a | |
| 99 | * connection the caller already owns ("the keet many-protocols-one-connection | |
| 100 | * pattern"). pgh-sidecar uses exactly that on its registry swarm | |
| 101 | * (pgh-sidecar/lib/index.js:362) — it does its own `_registryStore.replicate(conn)` | |
| 102 | * first, then calls `serveOnConnection(conn)`. | |
| 103 | * | |
| 104 | * 0.4.11 fixed the leak only for connections that arrive through DeployServer's | |
| 105 | * OWN swarm, because `_detachOverlay` is called from `_onConnection`'s close/error | |
| 106 | * handlers. A caller that uses the public API gets `attach()` with no inverse: | |
| 107 | * `_paired` (and the mux, and the mux's stream) stays pinned for the life of the | |
| 108 | * process, which is the whole point of an always-on daemon. | |
| 109 | * | |
| 110 | * The public API has no public inverse, so no caller can fix this from outside. | |
| 111 | */ | |
| 112 | test('overlay state is released for a connection served via the public serveOnConnection', async (t) => { | |
| 113 | t.timeout(30000) | |
| 114 | ||
| 115 | const dir = mktemp('pub') | |
| 116 | fs.writeFileSync(path.join(dir, 'a.txt'), 'hello') | |
| 117 | ||
| 118 | const server = new DeployServer(dir) | |
| 119 | await server.ready() | |
| 120 | t.teardown(async () => { | |
| 121 | try { await server.close() } catch {} | |
| 122 | try { fs.rmSync(dir, { recursive: true, force: true }) } catch {} | |
| 123 | }) | |
| 124 | ||
| 125 | const proto = server._overlayProtocol | |
| 126 | t.ok(proto, 'overlay protocol is enabled') | |
| 127 | ||
| 128 | const N = 5 | |
| 129 | const conns = [] | |
| 130 | for (let i = 0; i < N; i++) { | |
| 131 | const p = pair() | |
| 132 | conns.push(p) | |
| 133 | // Exactly what pgh-sidecar does on its registry-swarm connections. | |
| 134 | server.serveOnConnection(p.a) | |
| 135 | } | |
| 136 | ||
| 137 | t.is(proto._paired.size, N, 'one paired mux per live connection') | |
| 138 | ||
| 139 | for (const p of conns) { | |
| 140 | p.a.destroy() | |
| 141 | p.b.destroy() | |
| 142 | } | |
| 143 | ||
| 144 | await waitFor(() => proto._paired.size === 0) | |
| 145 | ||
| 146 | 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') | |
| 147 | }) |