OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-overlay commits / b5fe183

0.3.17: release a legacy receive's fd on a failed chunk or peer close
stortron 2026-10-11: ~2,600 hyper-tps texture receives held one `.overlay-partial` fd each for days. The publisher served a stale chunk map (fixed in 0.3.14), every chunk failed the hash check, and _onChunkData ignored the failed writeChunk; the channel's onclose released schedulers and tree requests but never legacy receives, and nothing timed them out. Each peer now tracks the legacy receives it feeds. A chunk the receive cannot accept (hash/length mismatch, index out of range, local I/O error) or the channel closing PAUSES the receive: fd closed, partial kept, so a later offer resumes and re-verifies it. No idle timeout yet — not needed for the observed cases. test/legacy-receive-release.test.js (red: no pause on either path). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
b5fe1830c72216780356257ff6ca3e7d864ccac2
by plankatron <fleeky@gmail.com> · 2026-10-12T00:42:31.000Z

Changed files (3) +129 -2

lib/protocol.js +26 -1
……@@ -279,6 +279,10 @@ export class OverlayProtocol {
279279 // promptly instead of stranding the request (and its buffered pages)
280280 // until the idle timeout fires.
281281 if (peer) self._failPendingTrees(peer, new Error('channel closed'))
282 // Release this peer's legacy receives: their fds would otherwise stay
283 // open for ever (stortron 2026-10-11: ~2,600). Paused, not cancelled —
284 // the partial stays for a later offer to resume and re-verify.
285 if (peer) for (const diskPath of [...peer.legacyReceives]) self._releaseLegacyReceive(peer, diskPath)
282286 // tell the sender-side indicator this peer is gone for every file
283287 // it was pulling, so a disconnect mid-download clears its row promptly.
284288 if (peer && self._serveEndCb) {
……@@ -301,6 +305,9 @@ export class OverlayProtocol {
301305 const peer = {
302306 mux, channel, remoteFeedKey: null, remoteSeq: 0, msgs: {},
303307 pendingTrees: new Map(), // hash → { resolve, reject, timer, timeout, nonce, pages, pageBytes }
308 // disk paths of legacy (single-peer) receives this peer is feeding —
309 // released (paused) on a failed chunk or when the channel closes.
310 legacyReceives: new Set(),
304311 // synthetic serve-paths this peer was authorized for via a
305312 // GATED _onContentRequest, mapped to the requester profileKey (msg.from).
306313 // _onChunkNeed serves bytes only for paths in here, so a peer cannot pull
……@@ -668,10 +675,18 @@ export class OverlayProtocol {
668675 } catch {
669676 return
670677 }
678 peer.legacyReceives.add(diskPath)
671679
672680 peer.msgs.chunkNeed.send({ path: msg.path, indices: needed })
673681 }
674682
683 // Close a legacy receive's fd and stop tracking it; KEEP the partial
684 // (TransferManager.pause) so a later offer resumes and re-verifies it.
685 _releaseLegacyReceive (peer, diskPath) {
686 peer.legacyReceives.delete(diskPath)
687 Promise.resolve().then(() => this._transferManager.pause(diskPath)).catch(() => {})
688 }
689
675690 async _onChunkNeed (peer, msg) {
676691 // Serve bytes ONLY for a synthetic path THIS peer was
677692 // authorized for via a gated _onContentRequest. Blocks a peer pulling bytes
……@@ -844,7 +859,17 @@ export class OverlayProtocol {
844859
845860 const result = this._transferManager.writeChunk(diskPath, msg.index, msg.data)
846861
847 if (result.ok && this._transferManager.isComplete(diskPath)) {
862 // A chunk this receive cannot accept (hash/length mismatch, index out of
863 // range, local I/O error) means it will never complete from this peer:
864 // release it instead of holding the fd for ever. 'no active transfer' /
865 // 'transfer closed' mean there is nothing open to release.
866 if (!result.ok) {
867 if (result.error !== 'no active transfer' && result.error !== 'transfer closed') this._releaseLegacyReceive(peer, diskPath)
868 return
869 }
870
871 if (this._transferManager.isComplete(diskPath)) {
872 peer.legacyReceives.delete(diskPath)
848873 const fin = await this._transferManager.finalize(diskPath)
849874 if (fin.ok) {
850875 const fileData = fs.readFileSync(diskPath)
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "hyper-overlay",
3 "version": "0.3.16",
3 "version": "0.3.17",
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/legacy-receive-release.test.js +102 -0
……@@ -0,0 +1,102 @@
1// A legacy (single-peer) receive must release its partial fd when it cannot
2// make progress: a chunk that fails verification, or the peer going away.
3//
4// stortron 2026-10-11: ~2,600 hyper-tps texture receives held one
5// `.overlay-partial` fd each for days. The publisher served a stale chunk map,
6// every chunk failed the hash check, and _onChunkData ignored the failed
7// writeChunk; onclose released schedulers and tree requests but never legacy
8// receives. pause() closes the fd and KEEPS the partial, so a later offer
9// resumes and re-verifies it.
10
11import test from 'brittle'
12import Protomux from 'protomux'
13import { Duplex } from 'streamx'
14import b4a from 'b4a'
15import { tmpStore } from './helpers.js'
16import { FileIndex } from '../lib/file-index.js'
17import { OverlayProtocol } from '../lib/protocol.js'
18
19function makeDuplex () {
20 let aWrite, bWrite
21 const a = new Duplex({ write (data, cb) { bWrite(data); cb() }, read () {} })
22 const b = new Duplex({ write (data, cb) { aWrite(data); cb() }, read () {} })
23 aWrite = (data) => a.push(data)
24 bWrite = (data) => b.push(data)
25 return [a, b]
26}
27const tick = () => new Promise((resolve) => setTimeout(resolve, 50))
28
29function recordingTransferManager (fileIndex, writeResult) {
30 const calls = { started: [], paused: [], cancelled: [] }
31 return {
32 calls,
33 _fileIndex: fileIndex,
34 async prepareFile () { return null },
35 readChunk () { return null },
36 computeNeeded (chunks) { return chunks.map((_, i) => i) },
37 async startReceive (p) { calls.started.push(p); return {} },
38 writeChunk () { return writeResult },
39 isComplete () { return false },
40 async finalize () { return { ok: false } },
41 async pause (p) { calls.paused.push(p) },
42 cancel (p) { calls.cancelled.push(p) }
43 }
44}
45
46async function makePeer (label, tm) {
47 const fileIndex = new FileIndex(tmpStore(label))
48 await fileIndex.ready()
49 const syncEngine = {
50 _fileIndex: fileIndex,
51 feedKey: '0'.repeat(64),
52 feed: { length: 0, async get () { return null } },
53 async markSynced () {},
54 async logPut () {}
55 }
56 const protocol = new OverlayProtocol(syncEngine, tm(fileIndex), {
57 key: Buffer.alloc(32, 0xab),
58 autoSync: false,
59 filePaths: new Map([['/tex.png', '/spool/tex.png']])
60 })
61 return { fileIndex, protocol }
62}
63
64async function pair (tmA, tmB) {
65 const A = await makePeer('lr-a', tmA)
66 const B = await makePeer('lr-b', tmB)
67 const [sA, sB] = makeDuplex()
68 A.protocol.attach(Protomux.from(sA))
69 const pB = B.protocol.attach(Protomux.from(sB))
70 await tick()
71 return { A, B, pB }
72}
73
74const MAP = [{ hash: 'a'.repeat(64), length: 191 }]
75
76test('a chunk that fails verification pauses the receive (fd released, partial kept)', async (t) => {
77 let tmB
78 const { A, B, pB } = await pair(
79 (fi) => recordingTransferManager(fi, { ok: true }),
80 (fi) => (tmB = recordingTransferManager(fi, { ok: false, error: 'hash mismatch at index 0' }))
81 )
82 await B.protocol._onChunkHashes(pB, { path: '/tex.png', tier: 0, chunks: MAP, more: 0 })
83 t.alike(tmB.calls.started, ['/spool/tex.png'], 'receive started')
84 await B.protocol._onChunkData(pB, { path: '/tex.png', index: 0, data: b4a.alloc(191) })
85 t.alike(tmB.calls.paused, ['/spool/tex.png'], 'paused on the failed chunk')
86 t.alike(tmB.calls.cancelled, [], 'not cancelled: the partial is kept for resume')
87 A.protocol.destroy(); B.protocol.destroy()
88})
89
90test('the peer closing pauses every legacy receive it was feeding', async (t) => {
91 let tmB
92 const { A, B, pB } = await pair(
93 (fi) => recordingTransferManager(fi, { ok: true }),
94 (fi) => (tmB = recordingTransferManager(fi, { ok: true }))
95 )
96 await B.protocol._onChunkHashes(pB, { path: '/tex.png', tier: 0, chunks: MAP, more: 0 })
97 t.alike(tmB.calls.paused, [], 'still receiving')
98 A.protocol.destroy() // closes the channel; B's onclose fires
99 await tick()
100 t.alike(tmB.calls.paused, ['/spool/tex.png'], 'paused when the peer went away')
101 B.protocol.destroy()
102})