OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-overlay commits / 6bc4fae

0.3.16: bound a corked serve batch — backpressure was blind while corked
protomux returns TRUE from send() while the mux is corked (it only appends to the batch), so _serveJob's `flushed === false` never fired inside a corked run: a chunkNeed job read EVERY requested chunk into memory, encoded each frame, then on uncork concatenated the whole batch, encrypted it and handed it to UDX — ~4-5x the requested bytes, all at once. Measured on stortron 2026-10-11 (sidecar as an overlay mirror serving hyper-tps): RSS 1.4 -> 4.0 GB in 5 s, and again 2.3 -> 3.7 GB in 2 s; native backtrace in bare_buffer_alloc_unsafe; JS allocation trace at the cap: 6.1 GB via TransferManager.readChunk <- _serveJob, the same again in NoiseSecretStream.alloc, in 737 ~8 MB writes. This is the "native heap" growth behind the sidecar wedges and the box running out of RAM + swap. _serveJob now flushes every SERVE_BATCH_BYTES (1 MiB), reads the mux's real `drained`, and parks on drain when the stream is backed up — so a job holds ~one batch, not the whole request. Un-corked muxes behave as before. test/chunk-backpressure.test.js: "corked serve is bounded" (red: 32/32 chunks, 8 MiB, sent before any drain). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6bc4fae590ec7c4e58b2968d79e40c4cd6b1d634
by plankatron <fleeky@gmail.com> · 2026-10-12T00:20:59.000Z

Changed files (3) +61 -2

lib/protocol.js +21 -1
……@@ -60,6 +60,10 @@ const CONTROL_STOPPED = 1
6060// Abandon a serve loop whose stream neither drains nor closes within
6161// this window (a wedged-but-TCP-alive peer); the receiver re-requests, so it self-heals.
6262const DRAIN_TIMEOUT_MS = 60000
63// Max bytes per corked chunkData batch in _serveJob: flush, check real
64// backpressure, park on drain if the stream is backed up. Bounds a serve job's
65// memory to ~one batch instead of the whole request (see _serveJob).
66const SERVE_BATCH_BYTES = 1024 * 1024
6367
6468export class OverlayProtocol {
6569 constructor (syncEngine, transferManager, opts = {}) {
……@@ -744,6 +748,12 @@ export class OverlayProtocol {
744748 const cork = () => { if (mux && mux.cork && !corked) { mux.cork(); corked = true } }
745749 const uncork = () => { if (mux && mux.uncork && corked) { mux.uncork(); corked = false } }
746750 cork()
751 // Bytes queued in the current corked batch. protomux's send() returns TRUE
752 // while corked (it only appends to the batch), so `flushed === false` never
753 // fires inside a corked run — before this cap a job read EVERY requested
754 // chunk into memory before the first flush (stortron 2026-10-11: one
755 // chunkNeed for a ~572 MB .pck took the sidecar 1.4 -> 4 GB RSS in 5 s).
756 let batchBytes = 0
747757 try {
748758 for (let i = 0; i < indices.length; i++) {
749759 const index = indices[i]
……@@ -757,8 +767,18 @@ export class OverlayProtocol {
757767 if (this._chunkServeCb) {
758768 try { this._chunkServeCb({ path, index, bytes: data.length, peer, from: peer.authorizedServe.get(path) || null }) } catch {}
759769 }
760 if (flushed === false && i < indices.length - 1) {
770 batchBytes += data.length
771 if (i === indices.length - 1) continue
772 let backedUp = flushed === false
773 if (!backedUp && corked && batchBytes >= SERVE_BATCH_BYTES) {
774 uncork() // flush the full batch; the mux's drained now reflects the real write
775 batchBytes = 0
776 backedUp = !!mux && mux.drained === false
777 if (!backedUp) cork()
778 }
779 if (backedUp) {
761780 uncork() // flush the corked batch before we park on drain
781 batchBytes = 0
762782 const alive = await this._waitForDrain(peer)
763783 if (!alive) return
764784 if (this._serveAuthorizer && !peer.authorizedServe.has(path)) return
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "hyper-overlay",
3 "version": "0.3.15",
3 "version": "0.3.16",
44 "description": "Content-addressed P2P file distribution over Protomux — FastCDC chunking, multi-source fetch, resumable transfers, zero blob duplication. Files live on disk; the overlay is a thin index and transfer protocol.",
55 "type": "module",
66 "main": "index.js",
test/chunk-backpressure.test.js +39 -0
……@@ -148,3 +148,42 @@ test('P3 cork-batching: cork/uncork stay balanced across a paced serve', async (
148148 t.is(corkDepth, 0, 'P3: cork/uncork balanced — no leaked cork after the serve')
149149 t.ok(maxDepth === 1, 'P3: exactly one cork depth (batch corked, flushed before each drain)')
150150})
151
152test('corked serve is bounded: a backed-up stream pauses the job after ~one batch', async (t) => {
153 // protomux returns TRUE from send() while corked (it only appends to the
154 // batch), so `flushed === false` never fired inside a corked run: a job read
155 // EVERY requested chunk into memory before the first flush. stortron
156 // 2026-10-11: one chunkNeed for a ~572 MB .pck went 1.4 -> 4 GB RSS in 5 s
157 // (readChunk copy + frame copy + batch concat + encrypt + udx).
158 const CH = 256 * 1024
159 const N = 32 // 8 MiB requested
160 const chunkMap = Array.from({ length: N }, (_, i) => ({ hash: 'h' + i, offset: i * CH, length: CH }))
161 const proto = new OverlayProtocol(null, {
162 _fileIndex: { getChunkMap: async () => chunkMap, getChunkMapByHash: async () => chunkMap },
163 readChunk: (p, off, len) => b4a.alloc(len)
164 }, { filePaths: new Map([['content:x', '/fake']]) })
165 const stream = makeStream()
166 const sent = []
167 let corked = 0
168 let drained = true
169 const mux = {
170 stream,
171 get drained () { return drained },
172 cork () { corked++ },
173 uncork () { if (--corked === 0) drained = false } // flushing a batch fills the write buffer
174 }
175 const peer = {
176 channel: { closed: false, get drained () { return drained } },
177 mux,
178 msgs: { chunkData: { send: (m) => { sent.push(m.index); if (corked) return true; drained = false; return false } } }
179 }
180
181 proto._onChunkNeed(peer, { path: 'content:x', indices: chunkMap.map((_, i) => i) })
182 await tick()
183 t.ok(sent.length * CH <= 2 * 1024 * 1024, 'paused after ~one batch, not the whole job (sent ' + sent.length + '/' + N + ' before any drain)')
184
185 let guard = 0
186 while (sent.length < N && guard++ < 200) { drained = true; stream.emitDrain(); await tick() }
187 t.is(sent.length, N, 'all chunks still served')
188 t.is(corked, 0, 'cork/uncork balanced')
189})