OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-overlay commits / e4abe73

0.3.10: overlay attach was ONE-SHOT — a missed race killed byte transfer permanently
ROOT CAUSE of two live co-owners agreeing on 135 records while holding divergent byte sets, with overlayPeers:0 forever on a connection that was otherwise fine. If the remote has no matching (protocol, id) channel at the moment we attach — it has not booted that DID's overlay yet, or is mid-restart — protomux tears our channel down, onclose drops the peer, and NOTHING ever re-created it. The remote attaching a second later did not help: createChannel then returns null ("the remote has already closed this one") and there was no path back. The failure is invisible in the worst way. The connection stays healthy for everything else, so corestore keeps replicating and the autobase pointers keep converging — the repo looks correct, both nodes agree on the record set — while the BYTES never move again. Each node's byte set drifts toward "what I authored", which is exactly what was measured (tabertron 125 posts / stortron 124, each holding one the other lacked). FIX: register mux.pair({protocol, id}) once per mux — protomux's mechanism for this exact race. It fires whenever the remote REQUESTS the channel, so a late or restarted peer gets one created on demand. Left registered across an incidental close so re-pairing works for the connection's whole life; dropped in detach(), which is explicit teardown. Also guard createChannel returning null instead of null-dereffing addMessage into the consumer's catch. Reproduced offline first (test/overlay-v2-keyed-fetch.test.js, ASYMMETRY): before: "A has a peer once B attaches" -> actual 0, FAILED after: passes, and a late attach yields a working fetch Note this ALSO explains why restarting a node never helped: whichever side attached first still lost its channel, and the surviving connection was never re-paired. Suite 213/213, 733/733 (excluding test/overlay-v2-corestore-mux.test.js, which has been dead at module-load since the 0.3.0 rename removed lib/overlay-v2.js). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
e4abe73ffb93b1c3032fa3ca8f2b935a8cf02b16
by plankatron <fleeky@gmail.com> · 2026-07-26T18:29:48.000Z

Changed files (3) +81 -1

lib/protocol.js +34 -0
……@@ -68,6 +68,8 @@ export class OverlayProtocol {
6868 this._corestore = opts.corestore || null
6969 this._overlayKey = opts.key || null
7070 this._peers = new Map()
71 // Muxes we've registered a pair() callback on, so we register exactly one per mux. See attach().
72 this._paired = new Set()
7173 this._filePaths = opts.filePaths || new Map()
7274 this._syncBaseDirs = opts.syncBaseDirs || []
7375 this._defaultStrategy = opts.defaultStrategy || 'prompt'
……@@ -236,6 +238,26 @@ export class OverlayProtocol {
236238
237239 const self = this
238240
241 // Attach used to be ONE-SHOT and order-sensitive, which silently killed byte transfer for the life
242 // of a connection. If the remote has no matching (protocol, id) channel at the moment we attach —
243 // it hasn't booted this DID's overlay yet, or is mid-restart — protomux tears our channel down,
244 // onclose drops the peer, and NOTHING ever re-created it. The remote attaching a second later did
245 // not help: `createChannel` then returns null ("the remote has already closed this one") and there
246 // was no path back. Meanwhile the connection stays perfectly healthy for everything else, so
247 // corestore keeps replicating and the pointers keep converging while the BYTES never move again —
248 // which is precisely the fault seen in production (overlayPeers:0, forever, on a live connection).
249 //
250 // mux.pair() is protomux's mechanism for exactly this race: it fires whenever the remote REQUESTS
251 // this (protocol, id), so a late or restarted peer gets a channel created on demand. Registered
252 // once per mux, and left registered after a close so re-pairing keeps working for the connection's
253 // whole life.
254 if (!this._paired.has(mux)) {
255 this._paired.add(mux)
256 try {
257 mux.pair({ protocol: PROTOCOL, id: this._overlayKey || null }, () => { try { self.attach(mux) } catch {} })
258 } catch { this._paired.delete(mux) }
259 }
260
239261 const channel = mux.createChannel({
240262 protocol: PROTOCOL,
241263 id: this._overlayKey || null,
……@@ -265,6 +287,13 @@ export class OverlayProtocol {
265287
266288 if (!channel) return null
267289
290 // protomux returns null for a duplicate channel, or one the remote has already closed. Before the
291 // pair() registration above this could not be recovered from; now the pair callback will re-drive
292 // attach() when the remote asks again, so returning cleanly here is the correct behaviour. It also
293 // stops a null-deref on the addMessage calls below, which the consumer's try/catch would have
294 // swallowed into a silent "this DID has no byte transfer".
295 if (!channel) return null
296
268297 const peer = {
269298 mux, channel, remoteFeedKey: null, remoteSeq: 0, msgs: {},
270299 pendingTrees: new Map(), // hash → { resolve, reject, timer, timeout, nonce, pages, pageBytes }
……@@ -301,7 +330,12 @@ export class OverlayProtocol {
301330 return peer
302331 }
303332
333 // Explicit teardown — unlike an incidental channel close (where the pair callback must SURVIVE so a
334 // returning peer can re-pair), detach means we are done with this mux, so drop the pairing too.
304335 detach (mux) {
336 if (this._paired.delete(mux)) {
337 try { mux.unpair({ protocol: PROTOCOL, id: this._overlayKey || null }) } catch {}
338 }
305339 const peer = this._peers.get(mux)
306340 if (!peer) return
307341 peer.channel.close()
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "hyper-overlay",
3 "version": "0.3.9",
3 "version": "0.3.10",
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/overlay-v2-keyed-fetch.test.js +46 -0
……@@ -246,3 +246,49 @@ test('SECRETSTREAM ONLY: keyed attach over a SecretStream, no corestore (isolate
246246 t.ok(got, 'keyed fetch works over SecretStream when corestore is NOT on the mux')
247247 if (got) t.is(Buffer.compare(fs.readFileSync(got.destPath), SMALL), 0, 'bytes match')
248248})
249
250// ─────────────────────────────────────────────────────────────────────────────
251// ASYMMETRY — what does peerCount look like when only ONE side attaches?
252//
253// Live finding: tabertron reports overlayPeers:0 for every DID, persistently, while attachProtocol
254// never throws. protocol.js attach() does `_peers.set(mux, peer)` UNCONDITIONALLY before channel.open(),
255// so a successful local attach should leave peerCount >= 1 no matter what the remote does — UNLESS
256// protomux closes the channel when the remote has no matching (protocol, id), in which case onclose
257// deletes the peer and the count falls back to 0.
258//
259// Those two readings demand different fixes, so pin which one the library actually does.
260// ─────────────────────────────────────────────────────────────────────────────
261
262test('ASYMMETRY: only one side attaches — does the attacher keep its peer?', async (t) => {
263 const A = await makeKeyedPeer('asy-a', ROOM_KEY)
264 const B = await makeKeyedPeer('asy-b', ROOM_KEY)
265 t.teardown(async () => { try { await A.ov.close() } catch {}; try { await B.ov.close() } catch {} })
266
267 const [sa, sb] = await secretPair()
268 // Both sides replicate corestore (so the connection is live and busy, as in prod)...
269 A.store.replicate(sa)
270 B.store.replicate(sb)
271 // ...but ONLY A attaches its overlay. B never does — the asymmetric case.
272 A.ov.attachProtocol(Protomux.from(sa))
273 await new Promise(r => setTimeout(r, 800))
274
275 const n = A.ov.peerCount
276 t.comment('A.peerCount with an unattached remote = ' + n)
277
278 // Whichever way this lands, it decodes the live 0:
279 // n === 1 -> a local attach SURVIVES an unattached remote, so the live 0 means the local attach
280 // never ran at all (look at the daemon's call path / `nodes` timing).
281 // n === 0 -> protomux tears the channel down when the remote has no match, so the live 0 is
282 // equally consistent with "the OTHER node isn't attaching" (look at stortron).
283 t.ok(n === 0 || n === 1, 'peerCount is a definite 0 or 1 (recorded above)')
284
285 // And the symmetric completion: once B attaches, a peer must appear on both sides.
286 B.ov.attachProtocol(Protomux.from(sb))
287 await new Promise(r => setTimeout(r, 800))
288 t.is(A.ov.peerCount, 1, 'A has a peer once B attaches')
289 t.is(B.ov.peerCount, 1, 'B has a peer once it attaches')
290
291 const { oid } = await seed(A.ov, 'asy-a', SMALL)
292 const got = await B.ov.fetchFile(oid, { destPath: path.join(tmpDir('ovk-asy-out'), 'r.bin'), peerWaitMs: 2000 })
293 t.ok(got, 'and a late attach still yields a working fetch (no permanent damage from the asymmetric phase)')
294})