bot / scripts / patch-deps.mjs
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
// Idempotent dependency patches — run as stack-a's postinstall.
//
// @libp2p/webrtc 6.0.31 + node-datachannel 0.33.2: the native DataChannel
// wrapper holds its five JS callbacks as napi references (GC ROOTS) and only
// releases them when libdatachannel emits `closed` (doCleanup in
// data-channel-wrapper.cpp). The transport's stream cleanup closes the
// channel ONLY when readyState === 'open', so a stream reset while its
// channel is still 'connecting' — every failed protocol negotiation — never
// closes the native channel, `closed` never fires, and the whole dead stream
// graph (buffers, contexts, abort listeners) stays pinned until the peer
// connection dies. Measured live 2026-09-01: 2274 fully-closed streams
// retained 25 min into a boot, ~5 MB/min, heap-OOM within the hour.
//
// The patch: on stream close, ALWAYS close the channel (close() is legal
// from 'connecting'), and null the polyfill's re-dispatch fields so the
// stream graph detaches from the native anchor even when the native side
// never emits closed. The wrapper itself may linger (small) — the stream,
// its buffers and its listeners become collectable.
//
// Idempotent: applying twice is a no-op; a version bump that rewrites the
// file surfaces as "target not found" so the pin can be re-audited rather
// than silently unpatched.
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";

const ROOT = join(dirname(new URL(import.meta.url).pathname), "..");
const FILE = join(ROOT, "stack-a", "node_modules", "@libp2p", "webrtc", "dist", "src", "stream.js");

const TARGET = `        const cleanUpDatachannelOnClose = () => {
            if (this.channel.readyState === 'open') {
                this.log.trace('stream closed, closing underlying datachannel');
                this.channel.close();
            }
        };`;

const PATCHED = `        const cleanUpDatachannelOnClose = () => {
            // ardegazu patch (see scripts/patch-deps.mjs): always close the
            // channel and detach the polyfill's re-dispatch fields — the
            // node-datachannel native wrapper pins its JS callbacks until the
            // channel truly closes, which a never-opened channel never does.
            try {
                if (this.channel.readyState === 'open' || this.channel.readyState === 'connecting') {
                    this.log.trace('stream closed, closing underlying datachannel');
                    this.channel.close();
                }
            }
            catch { }
            this.channel.onopen = null;
            this.channel.onclose = null;
            this.channel.onclosing = null;
            this.channel.onerror = null;
            this.channel.onbufferedamountlow = null;
            this.channel.onmessage = null;
        };`;

// Patch 2 — node-datachannel's polyfill: the native DataChannel wrapper keeps
// mOnClosedCallback (a napi ref, GC ROOT) until libdatachannel emits closed;
// doClose() releases the other four callbacks but deliberately not this one
// (data-channel-wrapper.cpp), and a never-opened channel never emits closed.
// That one root pinned every dead channel's polyfill wrapper, its ~11
// EventTarget listener records and its buffered messages: measured 3,327
// retained RTCDataChannel wrappers 15 min into a boot with only 54 live
// streams, ~3 MB/min. Re-registering onClosed REPLACES the native callback
// (napi ref released), so close() swaps in a capture-free no-op first, then
// closes, then dispatches the JS close event itself to keep the contract
// (the native dispatch path is the callback we just replaced). Remote-closed
// channels never enter close() and keep the stock path.
const POLY = join(ROOT, "stack-a", "node_modules", "node-datachannel", "dist", "esm", "polyfill", "RTCDataChannel.mjs");

const POLY_TARGET = `  close() {
    __privateSet(this, _closeRequested, true);
    setImmediate(() => {
      __privateGet(this, _dataChannel).close();
    });
  }`;

const POLY_PATCHED = `  close() {
    // ardegazu patch (see scripts/patch-deps.mjs): a never-opened channel may
    // never get a native closed event, so dispatch the JS close ourselves
    // after requesting the native close - downstream cleanup (the muxer, the
    // per-connection Set) hangs off that event. JS-only on purpose: touching
    // the native callback registrations from here races the RTC thread.
    __privateSet(this, _closeRequested, true);
    setImmediate(() => {
      try {
        __privateGet(this, _dataChannel).close();
      } catch {
      }
      if (__privateGet(this, _readyState) !== "closed") {
        __privateSet(this, _readyState, "closed");
        this.dispatchEvent(new Event("close"));
      }
    });
  }`;

// Patch 3 — THE root of the wrapper leak (found by shortest-GC-root-path over
// a live snapshot): RTCPeerConnection keeps a per-connection Set of its data
// channels; createDataChannel() wires close → Set.delete, but the INBOUND
// onDataChannel path only add()s — no close listener, never deleted. Every
// inbound channel (2,300 of the 2,341 retained streams were inbound) is
// therefore pinned for the peer connection's whole life. Mirror the outbound
// wiring. Depends on Patch 2: for a locally-closed never-opened channel the
// native closed event never fires, so without the manual dispatch this
// listener would never run either.
const PC = join(ROOT, "stack-a", "node_modules", "node-datachannel", "dist", "esm", "polyfill", "RTCPeerConnection.mjs");

const PC_TARGET = `    __privateGet(this, _peerConnection).onDataChannel((channel) => {
      const dc = new RTCDataChannel(channel);
      __privateGet(this, _dataChannels).add(dc);
      this.dispatchEvent(new RTCDataChannelEvent("datachannel", { channel: dc }));
    });`;

const PC_PATCHED = `    __privateGet(this, _peerConnection).onDataChannel((channel) => {
      const dc = new RTCDataChannel(channel);
      __privateGet(this, _dataChannels).add(dc);
      // ardegazu patch (see scripts/patch-deps.mjs): mirror createDataChannel's
      // close wiring - without it an inbound channel is never removed from the
      // per-connection Set and outlives its stream by the connection's life.
      dc.addEventListener("close", () => {
        __privateGet(this, _dataChannels).delete(dc);
        __privateWrapper(this, _dataChannelsClosed)._++;
      });
      this.dispatchEvent(new RTCDataChannelEvent("datachannel", { channel: dc }));
    });`;

// Patch 4 — the connection-level twin of Patch 2, and the anchor of the
// remaining ~800 dead connection graphs per half hour: the native
// PeerConnectionWrapper's doClose() releases every napi callback EXCEPT
// mOnStateChangeCallback (peer-connection-wrapper.cpp keeps it for a
// doCleanup() that only runs if the final state change still fires), and the
// polyfill's constructor registered it with a closure capturing the polyfill
// pc — so every locally-closed-but-never-cleaned-up RTCPeerConnection pins
// its RTCPeerConnectionMultiaddrConnection, DataChannelMuxer and channels
// forever. close() now closes its channels first (running Patches 1–3's
// chain), swaps the state-change callback for a capture-free no-op (the
// re-registration releases the old napi root), then closes natively and
// dispatches the state-change event itself.
const PC_CLOSE_TARGET = `  close() {
    __privateGet(this, _peerConnection).close();
  }`;

const PC_CLOSE_PATCHED = `  close() {
    // ardegazu patch (see scripts/patch-deps.mjs): close the channels first
    // so their own cleanup chain runs, then dispatch connectionstatechange
    // ourselves - a locally-closed connection may never get the native state
    // change, and the transport's shutdown-listener removal hangs off that
    // event. Re-entry guard + deferred dispatch: the connection's state
    // handler reacts to "closed" by calling close() again. JS-only on
    // purpose: swapping native callback registrations races the RTC thread.
    // The guard is an OWN flag, never the native state: libdatachannel
    // reaches state "closed" on its own for failed dials, and close() must
    // still run then - it is what releases the native callback registrations.
    if (this.__ardzCloseRan) {
      return;
    }
    this.__ardzCloseRan = true;
    for (const dc of [...__privateGet(this, _dataChannels)]) {
      try {
        dc.close();
      } catch {
      }
    }
    // one tick later than the channels' own deferred native closes (FIFO):
    // closing the native pc first destroys the channels before their doClose
    // can release the per-channel napi callbacks, re-pinning the graph.
    setImmediate(() => {
      try {
        __privateGet(this, _peerConnection).close();
      } catch {
      }
      this.dispatchEvent(new Event("connectionstatechange"));
    });
  }`;

// Patch 5 — the MASTER anchor, an upstream @libp2p/webrtc bug: the transport
// parks a connection-capturing listener on its PROCESS-LIFETIME shutdown
// signal and removes it on the peer connection's 'close' event — an event
// that does not exist on RTCPeerConnection (spec or polyfill; the real one is
// 'connectionstatechange'). The removal never runs, so the shutdown signal
// pins every connection ever made (maconn → pc → muxer → channels), which is
// why Patches 1–4 shrank the leak but could not zero it. Remove on the event
// that actually fires; Patch 4 guarantees it fires for local closes too.
const TRANSPORT = join(ROOT, "stack-a", "node_modules", "@libp2p", "webrtc", "dist", "src", "private-to-private", "transport.js");

const T_TARGET = `        this.shutdownController.signal.addEventListener('abort', shutDownListener);
        pc.addEventListener('close', () => {
            this.shutdownController.signal.removeEventListener('abort', shutDownListener);
        });`;

const T_PATCHED = `        this.shutdownController.signal.addEventListener('abort', shutDownListener);
        // ardegazu patch (see scripts/patch-deps.mjs): RTCPeerConnection has
        // no 'close' event, so this removal never ran and the shutdown signal
        // retained every connection ever made. 'connectionstatechange' is the
        // event that actually fires.
        pc.addEventListener('connectionstatechange', () => {
            let state;
            try {
                state = pc.connectionState;
            }
            catch {
                state = 'closed';
            }
            if (state === 'closed' || state === 'failed') {
                this.shutdownController.signal.removeEventListener('abort', shutDownListener);
            }
        });`;

// Patches 6+7 — the residual ~1.6 MB/min tail: doClose() releases every
// native napi callback EXCEPT the channel's onClosed and the pc's
// onStateChange (kept for a doCleanup that never comes when the native never
// fires the final event), and both constructor closures strongly capture
// their wrapper — pinning a ~200KB shell (SDP promises, listener records,
// buffers) per dead connection forever. Swapping registrations from JS races
// the RTC thread (measured SEGV), so instead make exactly these two closures
// capture a WeakRef: the native side may keep its callback forever, it just
// no longer pins the graph. While the object is genuinely in use its owner
// holds it strongly, so behavior is unchanged.
const DC_ONCLOSED_TARGET = `    __privateGet(this, _dataChannel).onClosed(() => {
      if (!__privateGet(this, _closeRequested)) {
        __privateSet(this, _readyState, "closing");
        this.dispatchEvent(new Event("closing"));
      }
      setImmediate(() => {
        __privateSet(this, _readyState, "closed");
        this.dispatchEvent(new Event("close"));
      });
    });`;

const DC_ONCLOSED_PATCHED = `    const __ardzWeakDc = new WeakRef(this);
    __privateGet(this, _dataChannel).onClosed(() => {
      const _t = __ardzWeakDc.deref();
      if (_t === undefined) {
        return;
      }
      if (!__privateGet(_t, _closeRequested)) {
        __privateSet(_t, _readyState, "closing");
        _t.dispatchEvent(new Event("closing"));
      }
      setImmediate(() => {
        const _t2 = __ardzWeakDc.deref();
        if (_t2 === undefined) {
          return;
        }
        __privateSet(_t2, _readyState, "closed");
        _t2.dispatchEvent(new Event("close"));
      });
    });`;

const PC_ONSTATE_TARGET = `    __privateGet(this, _peerConnection).onStateChange(() => {
      this.dispatchEvent(new Event("connectionstatechange"));
    });`;

const PC_ONSTATE_PATCHED = `    const __ardzWeakPc = new WeakRef(this);
    __privateGet(this, _peerConnection).onStateChange(() => {
      __ardzWeakPc.deref()?.dispatchEvent(new Event("connectionstatechange"));
    });`;

let failed = false;
for (const [file, target, patched, label] of [
  [FILE, TARGET, PATCHED, "@libp2p/webrtc stream.js (dead-stream native retention)"],
  [POLY, POLY_TARGET, POLY_PATCHED, "node-datachannel RTCDataChannel.mjs (onClosed napi-root release)"],
  [PC, PC_TARGET, PC_PATCHED, "node-datachannel RTCPeerConnection.mjs (inbound channel Set leak)"],
  [PC, PC_CLOSE_TARGET, PC_CLOSE_PATCHED, "node-datachannel RTCPeerConnection.mjs (onStateChange napi-root release)"],
  [TRANSPORT, T_TARGET, T_PATCHED, "@libp2p/webrtc transport.js (shutdown-signal listener leak)"],
  [POLY, DC_ONCLOSED_TARGET, DC_ONCLOSED_PATCHED, "node-datachannel RTCDataChannel.mjs (weak onClosed capture)"],
  [PC, PC_ONSTATE_TARGET, PC_ONSTATE_PATCHED, "node-datachannel RTCPeerConnection.mjs (weak onStateChange capture)"],
]) {
  let src;
  try {
    src = readFileSync(file, "utf8");
  } catch {
    console.log(`patch-deps: ${label} — package not installed yet, nothing to do`);
    continue;
  }
  if (src.includes(target)) {
    writeFileSync(file, src.replace(target, patched));
    console.log(`patch-deps: ${label} — patched`);
  } else if (src.includes(patched)) {
    console.log(`patch-deps: ${label} — already patched`);
  } else {
    console.error(`patch-deps: TARGET NOT FOUND for ${label} — version changed? Re-audit the leak before bumping.`);
    failed = true;
  }
}
if (failed) process.exit(1);

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/bot.git