OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-overlay commits / e5fe16e

0.2.13: restore backpressure in chunk send path (fixes large-transfer stalls)
The multi-source (ChunkScheduler) rewrite regressed bulk transfer: _onChunkNeed fired every chunkData.send() in a tight synchronous loop, ignoring send()'s drained return. protomux's send() returns mux.stream.write()'s value — false means the secret-stream write buffer is full — but stream.write buffers unbounded when ignored. On a real link, large transfers (the 185-328MB bootstrap tarballs shipped in dep-tree closures) flood the buffer → stall / ETIMEDOUT / ECONNRESET → "all peers gone with N chunks outstanding". The pre-rewrite path honored backpressure (proven at 200MB/1.30GB, no stall); loopback benches (incl. pgh transfer-2gb-blob) never overflow so they masked it. Fix: serialize chunk sends per peer through _pumpSendQueue, awaiting the stream 'drain' whenever send() reports backpressure (_awaitDrain, with a 30s safety timeout so a dead peer can't wedge the pump). One pump per peer (peer._sending guard) so concurrent chunkNeed batches don't re-flood. Composes with the torrent-style scheduler: a drain-paused seeder simply looks like a slower peer, which the scheduler already handles (cap-bounded inflight + reassign-on-stall). Fast path unchanged when the stream is drained. Proof: test/chunk-backpressure.test.js (3/3) — paces by drain under backpressure, sends all immediately when drained (no throughput regression), serializes concurrent batches. Real multi-source fetch (overlay-v2-fetch) still 4/4. protocol-peer failures are pre-existing v1 bare-compat (identical without this change). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
e5fe16e07b4cca9afd2f1421a6cf0a8e6c73ad8d
by plankatron <fleeky@gmail.com> · 2026-07-02T03:40:56.000Z

Changed files (3) +169 -11

lib/protocol-v2.js +55 -10
……@@ -350,20 +350,65 @@ export class OverlayProtocolV2 {
350350 const chunkMap = await this._transferManager._fileIndex.getChunkMap(msg.path)
351351 if (!chunkMap) return
352352
353 for (const index of msg.indices) {
354 if (index >= chunkMap.length) continue
355 const c = chunkMap[index]
356 const data = this._transferManager.readChunk(diskPath, c.offset, c.length)
357 if (!data) continue
358 // [diag-2026-05-04] flag oversized chunks before they hit secret-stream
359 if (data.byteLength > 4194304) {
360 // eslint-disable-next-line no-console
361 console.log('[v2-diag] chunkData OVERSIZED path=' + msg.path + ' index=' + index + ' bytes=' + data.byteLength)
353 // Enqueue the requested chunks and pump them out with BACKPRESSURE.
354 // Pre-rewrite the bulk transfer honored stream backpressure (proven at
355 // 200MB/1.30GB with no stall); the multi-source rewrite regressed it by
356 // firing every chunkData.send() in a tight synchronous loop, ignoring
357 // send()'s drained return. On a real link that floods secret-stream's
358 // write buffer (which grows unbounded — stream.write just returns false)
359 // → stall / ETIMEDOUT / ECONNRESET on large transfers. We serialize sends
360 // per peer through _pumpSendQueue, which awaits 'drain' whenever send()
361 // reports backpressure. This composes correctly with the torrent-style
362 // ChunkScheduler: a drain-paused seeder simply looks like a slower peer,
363 // which the scheduler already handles (cap-bounded inflight + reassign).
364 if (!peer._sendQueue) peer._sendQueue = []
365 peer._sendQueue.push({ path: msg.path, diskPath, chunkMap, indices: msg.indices })
366 this._pumpSendQueue(peer)
367 }
368
369 // Serialized, backpressure-respecting chunk sender. Exactly one pump runs
370 // per peer at a time (guarded by peer._sending), so concurrent _onChunkNeed
371 // invocations for the same peer don't interleave and re-flood the socket.
372 async _pumpSendQueue (peer) {
373 if (peer._sending) return
374 peer._sending = true
375 try {
376 while (peer._sendQueue && peer._sendQueue.length) {
377 const job = peer._sendQueue.shift()
378 for (const index of job.indices) {
379 if (peer.channel && peer.channel.closed) return
380 if (index >= job.chunkMap.length) continue
381 const c = job.chunkMap[index]
382 const data = this._transferManager.readChunk(job.diskPath, c.offset, c.length)
383 if (!data) continue
384 const drained = peer.msgs.chunkData.send({ path: job.path, index, data })
385 // send() returns mux.drained (stream.write's return). false ⇒ the
386 // write buffer is full — wait for the socket to drain before the
387 // next chunk instead of piling more into an unbounded backlog.
388 if (drained === false) await this._awaitDrain(peer)
389 }
362390 }
363 peer.msgs.chunkData.send({ path: msg.path, index, data })
391 } finally {
392 peer._sending = false
364393 }
365394 }
366395
396 // Resolve when the peer's underlying stream drains. Safety-timeout so a peer
397 // that never drains (dead connection) can't wedge the pump forever — on
398 // timeout we resolve and let the next send()/channel-close surface the error.
399 _awaitDrain (peer) {
400 const mux = peer.mux
401 if (!mux || mux.drained !== false) return Promise.resolve()
402 const stream = mux.stream
403 if (!stream || typeof stream.once !== 'function') return Promise.resolve()
404 return new Promise((resolve) => {
405 const cleanup = () => { try { stream.removeListener('drain', onDrain) } catch {} clearTimeout(timer) }
406 const onDrain = () => { cleanup(); resolve() }
407 const timer = setTimeout(() => { cleanup(); resolve() }, 30000)
408 stream.once('drain', onDrain)
409 })
410 }
411
367412 async _onChunkData (peer, msg) {
368413 // Silent-peer watchdog hook: fire BEFORE any processing so a slow disk
369414 // doesn't get blamed for a peer-side stall.
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "hyper-overlay",
3 "version": "0.2.12",
3 "version": "0.2.13",
44 "description": "Content-addressed union filesystem — P2P file distribution with FastCDC chunking and zero duplication",
55 "type": "module",
66 "main": "index.js",
test/chunk-backpressure.test.js +113 -0
……@@ -0,0 +1,113 @@
1// Backpressure regression test for the chunk SEND path (_onChunkNeed).
2//
3// The multi-source (ChunkScheduler) rewrite regressed bulk transfer: the
4// seeder fired every chunkData.send() in a tight synchronous loop, ignoring
5// send()'s drained return. On a real link that floods secret-stream's write
6// buffer (unbounded backlog) → stall / ETIMEDOUT / ECONNRESET on large
7// transfers (the 185MB+ bootstrap-tarball fetch that failed in prod). The
8// pre-rewrite path honored backpressure (proven at 200MB/1.30GB, no stall).
9//
10// The fix serializes sends per peer through _pumpSendQueue, awaiting 'drain'
11// whenever send() reports backpressure. These tests assert:
12// 1. under backpressure, the seeder paces sends by drain (does NOT flood);
13// 2. when NOT backpressured, all chunks still send immediately (no
14// throughput regression);
15// 3. it composes with concurrent chunkNeed batches (one pump per peer).
16
17import test from 'brittle'
18import b4a from 'b4a'
19import { OverlayProtocolV2 } from '../lib/protocol-v2.js'
20
21// Minimal drain-controllable stream stub (only what _awaitDrain uses).
22function makeStream () {
23 const listeners = []
24 return {
25 once (ev, cb) { if (ev === 'drain') listeners.push(cb) },
26 removeListener (ev, cb) { const i = listeners.indexOf(cb); if (i >= 0) listeners.splice(i, 1) },
27 emitDrain () { const fire = listeners.splice(0); for (const cb of fire) cb() }
28 }
29}
30const tick = () => new Promise((r) => setTimeout(r, 0))
31
32function makeProto (n) {
33 const chunkMap = Array.from({ length: n }, (_, i) => ({ hash: 'h' + i, offset: i * 10, length: 10 }))
34 const transferManager = {
35 _fileIndex: { getChunkMap: async () => chunkMap },
36 readChunk: (p, off, len) => b4a.alloc(len)
37 }
38 return new OverlayProtocolV2(null, transferManager, { filePaths: new Map([['content:x', '/fake']]) })
39}
40
41test('backpressure: seeder paces chunkData by drain instead of flooding', async (t) => {
42 const N = 6
43 const proto = makeProto(N)
44 const stream = makeStream()
45 const sent = []
46 let bufferFull = false
47 const peer = {
48 channel: { closed: false },
49 mux: { get drained () { return !bufferFull }, stream },
50 // every send fills the buffer and reports backpressure (returns false)
51 msgs: { chunkData: { send: (m) => { sent.push(m.index); bufferFull = true; return false } } }
52 }
53
54 await proto._onChunkNeed(peer, { path: 'content:x', indices: [0, 1, 2, 3, 4, 5] })
55 await tick()
56
57 t.is(sent.length, 1, 'only the first chunk is sent, then the pump waits for drain (no flood)')
58
59 for (let expect = 2; expect <= N; expect++) {
60 bufferFull = false
61 stream.emitDrain()
62 await tick()
63 t.is(sent.length, expect, `drain released exactly one more chunk (${expect} total)`)
64 }
65 t.alike(sent, [0, 1, 2, 3, 4, 5], 'all chunks delivered in order, paced by backpressure')
66})
67
68test('fast path: when the stream is drained, all chunks send without waiting', async (t) => {
69 const N = 5
70 const proto = makeProto(N)
71 const stream = makeStream()
72 const sent = []
73 const peer = {
74 channel: { closed: false },
75 mux: { drained: true, stream },
76 msgs: { chunkData: { send: (m) => { sent.push(m.index); return true } } } // never backpressured
77 }
78
79 await proto._onChunkNeed(peer, { path: 'content:x', indices: [0, 1, 2, 3, 4] })
80 await tick()
81
82 t.is(sent.length, N, 'all chunks sent immediately when not backpressured (no throughput regression)')
83})
84
85test('concurrent chunkNeed batches: one pump per peer, all delivered exactly once', async (t) => {
86 const N = 8
87 const proto = makeProto(N)
88 const stream = makeStream()
89 const sent = []
90 let bufferFull = false
91 const peer = {
92 channel: { closed: false },
93 mux: { get drained () { return !bufferFull }, stream },
94 msgs: { chunkData: { send: (m) => { sent.push(m.index); bufferFull = true; return false } } }
95 }
96
97 // Two overlapping batches (as the scheduler pipelines chunkNeed).
98 await proto._onChunkNeed(peer, { path: 'content:x', indices: [0, 1, 2, 3] })
99 await proto._onChunkNeed(peer, { path: 'content:x', indices: [4, 5, 6, 7] })
100 await tick()
101
102 // SERIALIZATION PROOF: a single paced pump has sent exactly ONE chunk and is
103 // awaiting drain. If the second chunkNeed had started a second concurrent
104 // pump (the re-flood bug), TWO chunks would already be out.
105 t.is(sent.length, 1, 'concurrent chunkNeed did NOT start a second pump (only 1 chunk out, paced)')
106
107 // Drain until everything is out.
108 let guard = 0
109 while (sent.length < N && guard++ < 50) { bufferFull = false; stream.emitDrain(); await tick() }
110
111 t.is(sent.length, N, 'all 8 chunks delivered across the two batches')
112 t.alike([...sent].sort((a, b) => a - b), [0, 1, 2, 3, 4, 5, 6, 7], 'each chunk delivered exactly once')
113})