| … | … | @@ -68,6 +68,8 @@ export class OverlayProtocol { |
| 68 | 68 | this._corestore = opts.corestore || null |
| 69 | 69 | this._overlayKey = opts.key || null |
| 70 | 70 | 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() | |
| 71 | 73 | this._filePaths = opts.filePaths || new Map() |
| 72 | 74 | this._syncBaseDirs = opts.syncBaseDirs || [] |
| 73 | 75 | this._defaultStrategy = opts.defaultStrategy || 'prompt' |
| … | … | @@ -236,6 +238,26 @@ export class OverlayProtocol { |
| 236 | 238 | |
| 237 | 239 | const self = this |
| 238 | 240 | |
| 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 | ||
| 239 | 261 | const channel = mux.createChannel({ |
| 240 | 262 | protocol: PROTOCOL, |
| 241 | 263 | id: this._overlayKey || null, |
| … | … | @@ -265,6 +287,13 @@ export class OverlayProtocol { |
| 265 | 287 | |
| 266 | 288 | if (!channel) return null |
| 267 | 289 | |
| 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 | ||
| 268 | 297 | const peer = { |
| 269 | 298 | mux, channel, remoteFeedKey: null, remoteSeq: 0, msgs: {}, |
| 270 | 299 | pendingTrees: new Map(), // hash → { resolve, reject, timer, timeout, nonce, pages, pageBytes } |
| … | … | @@ -301,7 +330,12 @@ export class OverlayProtocol { |
| 301 | 330 | return peer |
| 302 | 331 | } |
| 303 | 332 | |
| 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. | |
| 304 | 335 | detach (mux) { |
| 336 | if (this._paired.delete(mux)) { | |
| 337 | try { mux.unpair({ protocol: PROTOCOL, id: this._overlayKey || null }) } catch {} | |
| 338 | } | |
| 305 | 339 | const peer = this._peers.get(mux) |
| 306 | 340 | if (!peer) return |
| 307 | 341 | peer.channel.close() |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "hyper-overlay", |
| 3 | "version": "0.3.9", | |
| 3 | "version": "0.3.10", | |
| 4 | 4 | "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.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -246,3 +246,49 @@ test('SECRETSTREAM ONLY: keyed attach over a SecretStream, no corestore (isolate |
| 246 | 246 | t.ok(got, 'keyed fetch works over SecretStream when corestore is NOT on the mux') |
| 247 | 247 | if (got) t.is(Buffer.compare(fs.readFileSync(got.destPath), SMALL), 0, 'bytes match') |
| 248 | 248 | }) |
| 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 | ||
| 262 | test('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 | }) |