OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-overlay / test / overlay-v2-keyed-fetch.test.js

295 lines · javascript ref:
// CHARACTERIZATION — cross-peer fetch when the overlay is constructed WITH a channel `key`.
//
// Why this file exists: `overlay-v2-fetch.test.js` proves cross-peer fetch works, but every peer in it
// is built WITHOUT `opts.key`, so the channel opens on id=null. PRODUCTION never does that — the
// multi-DID atproto daemon builds one overlay per DID keyed by that DID's roomKey
// (hyper-proto-peer/lib/device-node.js: `new HyperOverlay(ovStore, { namespace: 'atproto-repo-overlay:'+did,
// key: roomKey, ... })`), which is precisely what `opts.key` was added for:
//
//   "Per-instance protocol channel id (opts.key) — lets N overlays coexist on ONE shared mux ...
//    Without it they collide on (PROTOCOL, null) and only the first channel opens."  (overlay.js)
//
// So the keyed path is the one that ships, and no test covered a keyed cross-peer FETCH. Two live
// co-owners of the same DID have been unable to move record bytes between them; this pins down whether
// the library is at fault or the daemon's wiring is.
//
// Each test is a controlled variant against the known-good baseline (test 1).

import test from 'brittle'
import Protomux from 'protomux'
import SecretStream from '@hyperswarm/secret-stream'
import { Duplex } from 'streamx'
import crypto from 'hypercore-crypto'
import { tmpStore, tmpDir, fs, path } from './helpers.js'
import { HyperOverlay } from '../lib/overlay.js'

function makeDuplex () {
  let aWrite, bWrite
  const a = new Duplex({ write (d, cb) { bWrite(d); cb() }, read () {} })
  const b = new Duplex({ write (d, cb) { aWrite(d); cb() }, read () {} })
  aWrite = (d) => a.push(d)
  bWrite = (d) => b.push(d)
  return [a, b]
}

// `key` mirrors production: a 32-byte roomKey buffer. null = the baseline the existing suite covers.
async function makePeer (label, key = null) {
  const ov = new HyperOverlay(tmpStore('ovk-' + label), {
    namespace: 'atproto-repo-overlay:did:plc:testtesttest',
    key,
    destDir: tmpDir('ovk-' + label + '-dest')
  })
  await ov.ready()
  return ov
}

// Write a real file on `seeder` and register it, returning its oid.
async function seed (ov, label, bytes, registerOpts = {}) {
  const dir = tmpDir('ovk-' + label + '-files')
  const p = path.join(dir, 'rec.dagcbor')
  fs.writeFileSync(p, bytes)
  const oid = crypto.data(bytes).toString('hex')
  const reg = await ov.registerFile('/records/app.bsky.feed.post/' + label, p, { size: bytes.length, ...registerOpts })
  return { oid, reg, diskPath: p }
}

const ROOM_KEY = crypto.hash([Buffer.from('atproto-base/v1/room-key'), Buffer.from('did:plc:testtesttest')])
// Records are small — the live blocker was 152 bytes. Size matters: the chunker/serve path behaves
// differently for sub-chunk files than for the 200KB multi-chunk case the existing test uses.
const SMALL = Buffer.from(JSON.stringify({ $type: 'app.bsky.feed.post', text: 'hello', createdAt: '2026-07-26T00:00:00.000Z' }))

test('BASELINE (control): unkeyed peers — cross-peer fetch of a SMALL record', async (t) => {
  const a = await makePeer('a'); const b = await makePeer('b')
  t.teardown(async () => { try { await a.close() } catch {}; try { await b.close() } catch {} })

  const [sa, sb] = makeDuplex()
  a.attachProtocol(Protomux.from(sa)); b.attachProtocol(Protomux.from(sb))
  await new Promise(r => setTimeout(r, 200))

  t.is(b._protocol._peers.size, 1, 'b attached one peer')
  const { oid } = await seed(a, 'a', SMALL)

  const got = await b.fetchFile(oid, { destPath: path.join(tmpDir('ovk-b-out'), 'rec.dagcbor') })
  t.ok(got, 'CONTROL: unkeyed cross-peer fetch of a small record works')
  if (got) t.is(Buffer.compare(fs.readFileSync(got.destPath), SMALL), 0, 'bytes match')
})

test('KEYED (production shape): both peers keyed with the SAME roomKey', async (t) => {
  const a = await makePeer('ka', ROOM_KEY); const b = await makePeer('kb', ROOM_KEY)
  t.teardown(async () => { try { await a.close() } catch {}; try { await b.close() } catch {} })

  const [sa, sb] = makeDuplex()
  a.attachProtocol(Protomux.from(sa)); b.attachProtocol(Protomux.from(sb))
  await new Promise(r => setTimeout(r, 200))

  t.is(b._protocol._peers.size, 1, 'b registered a peer (note: _peers.set happens BEFORE channel.open, so this alone does not prove the remote opened)')
  const { oid, reg } = await seed(a, 'ka', SMALL)
  t.ok(reg && reg.contentHash, 'seeder registered the file and got a contentHash')

  const got = await b.fetchFile(oid, { destPath: path.join(tmpDir('ovk-kb-out'), 'rec.dagcbor') })
  t.ok(got, 'KEYED cross-peer fetch resolves — if this fails while the control passes, opts.key breaks fetch')
  if (got) t.is(Buffer.compare(fs.readFileSync(got.destPath), SMALL), 0, 'bytes match')
})

test('KEYED + prepare:false (what reindexOverlay does) — is the lazy serve path real?', async (t) => {
  const a = await makePeer('pa', ROOM_KEY); const b = await makePeer('pb', ROOM_KEY)
  t.teardown(async () => { try { await a.close() } catch {}; try { await b.close() } catch {} })

  const [sa, sb] = makeDuplex()
  a.attachProtocol(Protomux.from(sa)); b.attachProtocol(Protomux.from(sb))
  await new Promise(r => setTimeout(r, 200))

  // registerFile's comment claims prepare:false is safe because "_onContentRequest chunks lazily on
  // first request". reindexOverlay relies on that claim for every record it repairs.
  const { oid } = await seed(a, 'pa', SMALL, { prepare: false })

  const got = await b.fetchFile(oid, { destPath: path.join(tmpDir('ovk-pb-out'), 'rec.dagcbor') })
  t.ok(got, 'a file registered with prepare:false is still servable to a peer')
  if (got) t.is(Buffer.compare(fs.readFileSync(got.destPath), SMALL), 0, 'bytes match')
})

test('MISMATCHED keys must NOT resolve (proves the id actually scopes the channel)', async (t) => {
  const a = await makePeer('ma', ROOM_KEY)
  const b = await makePeer('mb', crypto.hash([Buffer.from('different')]))
  t.teardown(async () => { try { await a.close() } catch {}; try { await b.close() } catch {} })

  const [sa, sb] = makeDuplex()
  a.attachProtocol(Protomux.from(sa)); b.attachProtocol(Protomux.from(sb))
  await new Promise(r => setTimeout(r, 200))

  const { oid } = await seed(a, 'ma', SMALL)
  const got = await b.fetchFile(oid, { destPath: path.join(tmpDir('ovk-mb-out'), 'rec.dagcbor'), peerWaitMs: 500 })
  t.is(got, null, 'different channel ids are isolated — no cross-talk')
})

test('MULTI-DID on ONE mux: two keyed overlays per side, each fetches only its own DID', async (t) => {
  // The real daemon shape: ONE connection carries N per-DID overlays (device-node.js:490 loops every
  // hosted DID's overlay over the same mux). If a second keyed channel on the same mux breaks the first,
  // a multi-DID node would silently lose byte transfer for some DIDs.
  const KEY1 = ROOM_KEY
  const KEY2 = crypto.hash([Buffer.from('atproto-base/v1/room-key'), Buffer.from('did:plc:second')])

  const a1 = await makePeer('m1a', KEY1); const a2 = await makePeer('m2a', KEY2)
  const b1 = await makePeer('m1b', KEY1); const b2 = await makePeer('m2b', KEY2)
  t.teardown(async () => { for (const o of [a1, a2, b1, b2]) { try { await o.close() } catch {} } })

  const [sa, sb] = makeDuplex()
  const muxA = Protomux.from(sa); const muxB = Protomux.from(sb)
  a1.attachProtocol(muxA); a2.attachProtocol(muxA) // both DIDs share ONE mux, as the daemon does
  b1.attachProtocol(muxB); b2.attachProtocol(muxB)
  await new Promise(r => setTimeout(r, 250))

  const one = await seed(a1, 'm1a', SMALL)
  const twoBytes = Buffer.from('second did record bytes')
  const two = await seed(a2, 'm2a', twoBytes)

  const g1 = await b1.fetchFile(one.oid, { destPath: path.join(tmpDir('ovk-m1b-out'), 'r.bin') })
  const g2 = await b2.fetchFile(two.oid, { destPath: path.join(tmpDir('ovk-m2b-out'), 'r.bin') })

  t.ok(g1, 'DID #1 resolved over the shared mux')
  t.ok(g2, 'DID #2 resolved over the SAME mux (N overlays coexist — the reason opts.key exists)')
})

// ─────────────────────────────────────────────────────────────────────────────
// LIVE ORDERING — the variable every passing test above omits.
//
// The daemon does NOT attach to a bare duplex. hyper-proto-peer/lib/device-node.js:470-490 does:
//     swarm.on('connection', conn => {
//       store.replicate(conn)                 // corestore claims a Protomux on the stream FIRST
//       const mux = Protomux.from(conn)       // REUSES that protomux
//       n.overlay.attachProtocol(mux)         // overlay channel on the reused mux  (in a silent try/catch)
//     })
//
// test/overlay-v2-corestore-mux.test.js captured this exact hypothesis for issue #66 — "stortron's
// overlayV2 ends up with ZERO peers ... the overlay channel never OPENS (no sync-state exchange)" — and
// recorded that it FAILED. That file imports lib/overlay-v2.js + lib/protocol-v2.js, which no longer
// exist after the 0.3.0 rename, so it has been dead at module-load ever since and the regression was
// never guarded. These re-run it against the CURRENT modules, in the production keyed shape.
// ─────────────────────────────────────────────────────────────────────────────

async function makeKeyedPeer (label, key) {
  const store = tmpStore('ovk-' + label)
  const ov = new HyperOverlay(store, {
    namespace: 'atproto-repo-overlay:did:plc:testtesttest',
    key,
    destDir: tmpDir('ovk-' + label + '-dest')
  })
  await ov.ready()
  return { ov, store }
}

async function secretPair () {
  const [da, db] = makeDuplex()
  const sa = new SecretStream(true, da)
  const sb = new SecretStream(false, db)
  await Promise.all([
    new Promise(r => sa.on('connect', r)),
    new Promise(r => sb.on('connect', r))
  ])
  return [sa, sb]
}

test('LIVE ORDERING: corestore.replicate(conn) BEFORE a KEYED attachProtocol — does the channel open?', async (t) => {
  const A = await makeKeyedPeer('la', ROOM_KEY)
  const B = await makeKeyedPeer('lb', ROOM_KEY)
  t.teardown(async () => { try { await A.ov.close() } catch {}; try { await B.ov.close() } catch {} })

  const [sa, sb] = await secretPair()
  A.store.replicate(sa)                     // ← corestore claims the mux first, exactly as the daemon does
  B.store.replicate(sb)
  A.ov.attachProtocol(Protomux.from(sa))    // ← overlay channel on the REUSED mux
  B.ov.attachProtocol(Protomux.from(sb))
  await new Promise(r => setTimeout(r, 600))

  const { oid } = await seed(A.ov, 'la', SMALL)
  const got = await B.ov.fetchFile(oid, { destPath: path.join(tmpDir('ovk-lb-out'), 'rec.dagcbor'), peerWaitMs: 2000 })

  t.ok(got, 'DECISIVE: keyed overlay fetch under corestore-replicate-first ordering (the live daemon shape)')
  if (got) t.is(Buffer.compare(fs.readFileSync(got.destPath), SMALL), 0, 'bytes match')
})

test('ORDERING CONTROL: attachProtocol BEFORE corestore.replicate (same parts, order swapped)', async (t) => {
  const A = await makeKeyedPeer('oa', ROOM_KEY)
  const B = await makeKeyedPeer('ob', ROOM_KEY)
  t.teardown(async () => { try { await A.ov.close() } catch {}; try { await B.ov.close() } catch {} })

  const [sa, sb] = await secretPair()
  A.ov.attachProtocol(Protomux.from(sa))    // ← overlay first this time
  B.ov.attachProtocol(Protomux.from(sb))
  A.store.replicate(sa)
  B.store.replicate(sb)
  await new Promise(r => setTimeout(r, 600))

  const { oid } = await seed(A.ov, 'oa', SMALL)
  const got = await B.ov.fetchFile(oid, { destPath: path.join(tmpDir('ovk-ob-out'), 'rec.dagcbor'), peerWaitMs: 2000 })

  // If this PASSES while the test above FAILS, the fault is ordering and the daemon fix is a one-liner:
  // attach the overlay before handing the connection to corestore.
  t.ok(got, 'overlay fetch works when the overlay attaches BEFORE corestore replicates')
  if (got) t.is(Buffer.compare(fs.readFileSync(got.destPath), SMALL), 0, 'bytes match')
})

test('SECRETSTREAM ONLY: keyed attach over a SecretStream, no corestore (isolates the transport)', async (t) => {
  const A = await makeKeyedPeer('sa', ROOM_KEY)
  const B = await makeKeyedPeer('sb2', ROOM_KEY)
  t.teardown(async () => { try { await A.ov.close() } catch {}; try { await B.ov.close() } catch {} })

  const [sa, sb] = await secretPair()
  A.ov.attachProtocol(Protomux.from(sa))
  B.ov.attachProtocol(Protomux.from(sb))
  await new Promise(r => setTimeout(r, 600))

  const { oid } = await seed(A.ov, 'sa', SMALL)
  const got = await B.ov.fetchFile(oid, { destPath: path.join(tmpDir('ovk-sb2-out'), 'rec.dagcbor'), peerWaitMs: 2000 })

  // Separates "SecretStream vs plain Duplex" from "corestore shares the mux".
  t.ok(got, 'keyed fetch works over SecretStream when corestore is NOT on the mux')
  if (got) t.is(Buffer.compare(fs.readFileSync(got.destPath), SMALL), 0, 'bytes match')
})

// ─────────────────────────────────────────────────────────────────────────────
// ASYMMETRY — what does peerCount look like when only ONE side attaches?
//
// Live finding: tabertron reports overlayPeers:0 for every DID, persistently, while attachProtocol
// never throws. protocol.js attach() does `_peers.set(mux, peer)` UNCONDITIONALLY before channel.open(),
// so a successful local attach should leave peerCount >= 1 no matter what the remote does — UNLESS
// protomux closes the channel when the remote has no matching (protocol, id), in which case onclose
// deletes the peer and the count falls back to 0.
//
// Those two readings demand different fixes, so pin which one the library actually does.
// ─────────────────────────────────────────────────────────────────────────────

test('ASYMMETRY: only one side attaches — does the attacher keep its peer?', async (t) => {
  const A = await makeKeyedPeer('asy-a', ROOM_KEY)
  const B = await makeKeyedPeer('asy-b', ROOM_KEY)
  t.teardown(async () => { try { await A.ov.close() } catch {}; try { await B.ov.close() } catch {} })

  const [sa, sb] = await secretPair()
  // Both sides replicate corestore (so the connection is live and busy, as in prod)...
  A.store.replicate(sa)
  B.store.replicate(sb)
  // ...but ONLY A attaches its overlay. B never does — the asymmetric case.
  A.ov.attachProtocol(Protomux.from(sa))
  await new Promise(r => setTimeout(r, 800))

  const n = A.ov.peerCount
  t.comment('A.peerCount with an unattached remote = ' + n)

  // Whichever way this lands, it decodes the live 0:
  //   n === 1 -> a local attach SURVIVES an unattached remote, so the live 0 means the local attach
  //              never ran at all (look at the daemon's call path / `nodes` timing).
  //   n === 0 -> protomux tears the channel down when the remote has no match, so the live 0 is
  //              equally consistent with "the OTHER node isn't attaching" (look at stortron).
  t.ok(n === 0 || n === 1, 'peerCount is a definite 0 or 1 (recorded above)')

  // And the symmetric completion: once B attaches, a peer must appear on both sides.
  B.ov.attachProtocol(Protomux.from(sb))
  await new Promise(r => setTimeout(r, 800))
  t.is(A.ov.peerCount, 1, 'A has a peer once B attaches')
  t.is(B.ov.peerCount, 1, 'B has a peer once it attaches')

  const { oid } = await seed(A.ov, 'asy-a', SMALL)
  const got = await B.ov.fetchFile(oid, { destPath: path.join(tmpDir('ovk-asy-out'), 'r.bin'), peerWaitMs: 2000 })
  t.ok(got, 'and a late attach still yields a working fetch (no permanent damage from the asymmetric phase)')
})