OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-overlay commits / a01ff56

test: characterize KEYED cross-peer fetch — the shape production actually runs
Two live co-owners of the same DID cannot move record bytes between them. This pins down whether hyper-overlay or the consuming daemon is at fault. The gap: 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, which is exactly what opts.key was added for. No test covered a keyed cross-peer FETCH. 8 variants, all PASS: - baseline unkeyed (control, matches existing suite) - keyed with the same roomKey (production shape) - keyed + prepare:false (what reindexOverlay relies on) - mismatched keys stay isolated (the id really scopes the channel) - two keyed overlays on ONE mux (the multi-DID daemon shape) - corestore.replicate(conn) BEFORE attach (the live daemon ordering) - attach BEFORE corestore.replicate (ordering control) - keyed over SecretStream, no corestore (isolates the transport) => hyper-overlay is EXONERATED. Every shape resolves offline, so the fault is in the daemon's wiring or its live environment, not this library. Notably this REFUTES the standing #66 hypothesis. test/overlay-v2-corestore-mux .test.js recorded "corestore claims the mux first => the overlay channel never OPENS, stortron's overlayV2 has ZERO peers" and that it FAILED. That file imports lib/overlay-v2.js + lib/protocol-v2.js, which stopped existing at the 0.3.0 rename, so it has been dead at module-load ever since — the hypothesis was never re-tested and the regression never guarded. Re-run here against the current modules, that ordering passes. Side effect worth flagging: because that stale file still matches test/*.test.js, `npm test` for this repo currently fails at module load. Left in place rather than deleted — it is the historical record of the #66 investigation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a01ff56a717de1f41c9326ecfdc8ce89f4caf961
by plankatron <fleeky@gmail.com> · 2026-07-26T17:32:31.000Z

Changed files (1) +248 -0

test/overlay-v2-keyed-fetch.test.js +248 -0
……@@ -0,0 +1,248 @@
1// CHARACTERIZATION — cross-peer fetch when the overlay is constructed WITH a channel `key`.
2//
3// Why this file exists: `overlay-v2-fetch.test.js` proves cross-peer fetch works, but every peer in it
4// is built WITHOUT `opts.key`, so the channel opens on id=null. PRODUCTION never does that — the
5// multi-DID atproto daemon builds one overlay per DID keyed by that DID's roomKey
6// (hyper-proto-peer/lib/device-node.js: `new HyperOverlay(ovStore, { namespace: 'atproto-repo-overlay:'+did,
7// key: roomKey, ... })`), which is precisely what `opts.key` was added for:
8//
9// "Per-instance protocol channel id (opts.key) — lets N overlays coexist on ONE shared mux ...
10// Without it they collide on (PROTOCOL, null) and only the first channel opens." (overlay.js)
11//
12// So the keyed path is the one that ships, and no test covered a keyed cross-peer FETCH. Two live
13// co-owners of the same DID have been unable to move record bytes between them; this pins down whether
14// the library is at fault or the daemon's wiring is.
15//
16// Each test is a controlled variant against the known-good baseline (test 1).
17
18import test from 'brittle'
19import Protomux from 'protomux'
20import SecretStream from '@hyperswarm/secret-stream'
21import { Duplex } from 'streamx'
22import crypto from 'hypercore-crypto'
23import { tmpStore, tmpDir, fs, path } from './helpers.js'
24import { HyperOverlay } from '../lib/overlay.js'
25
26function makeDuplex () {
27 let aWrite, bWrite
28 const a = new Duplex({ write (d, cb) { bWrite(d); cb() }, read () {} })
29 const b = new Duplex({ write (d, cb) { aWrite(d); cb() }, read () {} })
30 aWrite = (d) => a.push(d)
31 bWrite = (d) => b.push(d)
32 return [a, b]
33}
34
35// `key` mirrors production: a 32-byte roomKey buffer. null = the baseline the existing suite covers.
36async function makePeer (label, key = null) {
37 const ov = new HyperOverlay(tmpStore('ovk-' + label), {
38 namespace: 'atproto-repo-overlay:did:plc:testtesttest',
39 key,
40 destDir: tmpDir('ovk-' + label + '-dest')
41 })
42 await ov.ready()
43 return ov
44}
45
46// Write a real file on `seeder` and register it, returning its oid.
47async function seed (ov, label, bytes, registerOpts = {}) {
48 const dir = tmpDir('ovk-' + label + '-files')
49 const p = path.join(dir, 'rec.dagcbor')
50 fs.writeFileSync(p, bytes)
51 const oid = crypto.data(bytes).toString('hex')
52 const reg = await ov.registerFile('/records/app.bsky.feed.post/' + label, p, { size: bytes.length, ...registerOpts })
53 return { oid, reg, diskPath: p }
54}
55
56const ROOM_KEY = crypto.hash([Buffer.from('atproto-base/v1/room-key'), Buffer.from('did:plc:testtesttest')])
57// Records are small — the live blocker was 152 bytes. Size matters: the chunker/serve path behaves
58// differently for sub-chunk files than for the 200KB multi-chunk case the existing test uses.
59const SMALL = Buffer.from(JSON.stringify({ $type: 'app.bsky.feed.post', text: 'hello', createdAt: '2026-07-26T00:00:00.000Z' }))
60
61test('BASELINE (control): unkeyed peers — cross-peer fetch of a SMALL record', async (t) => {
62 const a = await makePeer('a'); const b = await makePeer('b')
63 t.teardown(async () => { try { await a.close() } catch {}; try { await b.close() } catch {} })
64
65 const [sa, sb] = makeDuplex()
66 a.attachProtocol(Protomux.from(sa)); b.attachProtocol(Protomux.from(sb))
67 await new Promise(r => setTimeout(r, 200))
68
69 t.is(b._protocol._peers.size, 1, 'b attached one peer')
70 const { oid } = await seed(a, 'a', SMALL)
71
72 const got = await b.fetchFile(oid, { destPath: path.join(tmpDir('ovk-b-out'), 'rec.dagcbor') })
73 t.ok(got, 'CONTROL: unkeyed cross-peer fetch of a small record works')
74 if (got) t.is(Buffer.compare(fs.readFileSync(got.destPath), SMALL), 0, 'bytes match')
75})
76
77test('KEYED (production shape): both peers keyed with the SAME roomKey', async (t) => {
78 const a = await makePeer('ka', ROOM_KEY); const b = await makePeer('kb', ROOM_KEY)
79 t.teardown(async () => { try { await a.close() } catch {}; try { await b.close() } catch {} })
80
81 const [sa, sb] = makeDuplex()
82 a.attachProtocol(Protomux.from(sa)); b.attachProtocol(Protomux.from(sb))
83 await new Promise(r => setTimeout(r, 200))
84
85 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)')
86 const { oid, reg } = await seed(a, 'ka', SMALL)
87 t.ok(reg && reg.contentHash, 'seeder registered the file and got a contentHash')
88
89 const got = await b.fetchFile(oid, { destPath: path.join(tmpDir('ovk-kb-out'), 'rec.dagcbor') })
90 t.ok(got, 'KEYED cross-peer fetch resolves — if this fails while the control passes, opts.key breaks fetch')
91 if (got) t.is(Buffer.compare(fs.readFileSync(got.destPath), SMALL), 0, 'bytes match')
92})
93
94test('KEYED + prepare:false (what reindexOverlay does) — is the lazy serve path real?', async (t) => {
95 const a = await makePeer('pa', ROOM_KEY); const b = await makePeer('pb', ROOM_KEY)
96 t.teardown(async () => { try { await a.close() } catch {}; try { await b.close() } catch {} })
97
98 const [sa, sb] = makeDuplex()
99 a.attachProtocol(Protomux.from(sa)); b.attachProtocol(Protomux.from(sb))
100 await new Promise(r => setTimeout(r, 200))
101
102 // registerFile's comment claims prepare:false is safe because "_onContentRequest chunks lazily on
103 // first request". reindexOverlay relies on that claim for every record it repairs.
104 const { oid } = await seed(a, 'pa', SMALL, { prepare: false })
105
106 const got = await b.fetchFile(oid, { destPath: path.join(tmpDir('ovk-pb-out'), 'rec.dagcbor') })
107 t.ok(got, 'a file registered with prepare:false is still servable to a peer')
108 if (got) t.is(Buffer.compare(fs.readFileSync(got.destPath), SMALL), 0, 'bytes match')
109})
110
111test('MISMATCHED keys must NOT resolve (proves the id actually scopes the channel)', async (t) => {
112 const a = await makePeer('ma', ROOM_KEY)
113 const b = await makePeer('mb', crypto.hash([Buffer.from('different')]))
114 t.teardown(async () => { try { await a.close() } catch {}; try { await b.close() } catch {} })
115
116 const [sa, sb] = makeDuplex()
117 a.attachProtocol(Protomux.from(sa)); b.attachProtocol(Protomux.from(sb))
118 await new Promise(r => setTimeout(r, 200))
119
120 const { oid } = await seed(a, 'ma', SMALL)
121 const got = await b.fetchFile(oid, { destPath: path.join(tmpDir('ovk-mb-out'), 'rec.dagcbor'), peerWaitMs: 500 })
122 t.is(got, null, 'different channel ids are isolated — no cross-talk')
123})
124
125test('MULTI-DID on ONE mux: two keyed overlays per side, each fetches only its own DID', async (t) => {
126 // The real daemon shape: ONE connection carries N per-DID overlays (device-node.js:490 loops every
127 // hosted DID's overlay over the same mux). If a second keyed channel on the same mux breaks the first,
128 // a multi-DID node would silently lose byte transfer for some DIDs.
129 const KEY1 = ROOM_KEY
130 const KEY2 = crypto.hash([Buffer.from('atproto-base/v1/room-key'), Buffer.from('did:plc:second')])
131
132 const a1 = await makePeer('m1a', KEY1); const a2 = await makePeer('m2a', KEY2)
133 const b1 = await makePeer('m1b', KEY1); const b2 = await makePeer('m2b', KEY2)
134 t.teardown(async () => { for (const o of [a1, a2, b1, b2]) { try { await o.close() } catch {} } })
135
136 const [sa, sb] = makeDuplex()
137 const muxA = Protomux.from(sa); const muxB = Protomux.from(sb)
138 a1.attachProtocol(muxA); a2.attachProtocol(muxA) // both DIDs share ONE mux, as the daemon does
139 b1.attachProtocol(muxB); b2.attachProtocol(muxB)
140 await new Promise(r => setTimeout(r, 250))
141
142 const one = await seed(a1, 'm1a', SMALL)
143 const twoBytes = Buffer.from('second did record bytes')
144 const two = await seed(a2, 'm2a', twoBytes)
145
146 const g1 = await b1.fetchFile(one.oid, { destPath: path.join(tmpDir('ovk-m1b-out'), 'r.bin') })
147 const g2 = await b2.fetchFile(two.oid, { destPath: path.join(tmpDir('ovk-m2b-out'), 'r.bin') })
148
149 t.ok(g1, 'DID #1 resolved over the shared mux')
150 t.ok(g2, 'DID #2 resolved over the SAME mux (N overlays coexist — the reason opts.key exists)')
151})
152
153// ─────────────────────────────────────────────────────────────────────────────
154// LIVE ORDERING — the variable every passing test above omits.
155//
156// The daemon does NOT attach to a bare duplex. hyper-proto-peer/lib/device-node.js:470-490 does:
157// swarm.on('connection', conn => {
158// store.replicate(conn) // corestore claims a Protomux on the stream FIRST
159// const mux = Protomux.from(conn) // REUSES that protomux
160// n.overlay.attachProtocol(mux) // overlay channel on the reused mux (in a silent try/catch)
161// })
162//
163// test/overlay-v2-corestore-mux.test.js captured this exact hypothesis for issue #66 — "stortron's
164// overlayV2 ends up with ZERO peers ... the overlay channel never OPENS (no sync-state exchange)" — and
165// recorded that it FAILED. That file imports lib/overlay-v2.js + lib/protocol-v2.js, which no longer
166// exist after the 0.3.0 rename, so it has been dead at module-load ever since and the regression was
167// never guarded. These re-run it against the CURRENT modules, in the production keyed shape.
168// ─────────────────────────────────────────────────────────────────────────────
169
170async function makeKeyedPeer (label, key) {
171 const store = tmpStore('ovk-' + label)
172 const ov = new HyperOverlay(store, {
173 namespace: 'atproto-repo-overlay:did:plc:testtesttest',
174 key,
175 destDir: tmpDir('ovk-' + label + '-dest')
176 })
177 await ov.ready()
178 return { ov, store }
179}
180
181async function secretPair () {
182 const [da, db] = makeDuplex()
183 const sa = new SecretStream(true, da)
184 const sb = new SecretStream(false, db)
185 await Promise.all([
186 new Promise(r => sa.on('connect', r)),
187 new Promise(r => sb.on('connect', r))
188 ])
189 return [sa, sb]
190}
191
192test('LIVE ORDERING: corestore.replicate(conn) BEFORE a KEYED attachProtocol — does the channel open?', async (t) => {
193 const A = await makeKeyedPeer('la', ROOM_KEY)
194 const B = await makeKeyedPeer('lb', ROOM_KEY)
195 t.teardown(async () => { try { await A.ov.close() } catch {}; try { await B.ov.close() } catch {} })
196
197 const [sa, sb] = await secretPair()
198 A.store.replicate(sa) // ← corestore claims the mux first, exactly as the daemon does
199 B.store.replicate(sb)
200 A.ov.attachProtocol(Protomux.from(sa)) // ← overlay channel on the REUSED mux
201 B.ov.attachProtocol(Protomux.from(sb))
202 await new Promise(r => setTimeout(r, 600))
203
204 const { oid } = await seed(A.ov, 'la', SMALL)
205 const got = await B.ov.fetchFile(oid, { destPath: path.join(tmpDir('ovk-lb-out'), 'rec.dagcbor'), peerWaitMs: 2000 })
206
207 t.ok(got, 'DECISIVE: keyed overlay fetch under corestore-replicate-first ordering (the live daemon shape)')
208 if (got) t.is(Buffer.compare(fs.readFileSync(got.destPath), SMALL), 0, 'bytes match')
209})
210
211test('ORDERING CONTROL: attachProtocol BEFORE corestore.replicate (same parts, order swapped)', async (t) => {
212 const A = await makeKeyedPeer('oa', ROOM_KEY)
213 const B = await makeKeyedPeer('ob', ROOM_KEY)
214 t.teardown(async () => { try { await A.ov.close() } catch {}; try { await B.ov.close() } catch {} })
215
216 const [sa, sb] = await secretPair()
217 A.ov.attachProtocol(Protomux.from(sa)) // ← overlay first this time
218 B.ov.attachProtocol(Protomux.from(sb))
219 A.store.replicate(sa)
220 B.store.replicate(sb)
221 await new Promise(r => setTimeout(r, 600))
222
223 const { oid } = await seed(A.ov, 'oa', SMALL)
224 const got = await B.ov.fetchFile(oid, { destPath: path.join(tmpDir('ovk-ob-out'), 'rec.dagcbor'), peerWaitMs: 2000 })
225
226 // If this PASSES while the test above FAILS, the fault is ordering and the daemon fix is a one-liner:
227 // attach the overlay before handing the connection to corestore.
228 t.ok(got, 'overlay fetch works when the overlay attaches BEFORE corestore replicates')
229 if (got) t.is(Buffer.compare(fs.readFileSync(got.destPath), SMALL), 0, 'bytes match')
230})
231
232test('SECRETSTREAM ONLY: keyed attach over a SecretStream, no corestore (isolates the transport)', async (t) => {
233 const A = await makeKeyedPeer('sa', ROOM_KEY)
234 const B = await makeKeyedPeer('sb2', ROOM_KEY)
235 t.teardown(async () => { try { await A.ov.close() } catch {}; try { await B.ov.close() } catch {} })
236
237 const [sa, sb] = await secretPair()
238 A.ov.attachProtocol(Protomux.from(sa))
239 B.ov.attachProtocol(Protomux.from(sb))
240 await new Promise(r => setTimeout(r, 600))
241
242 const { oid } = await seed(A.ov, 'sa', SMALL)
243 const got = await B.ov.fetchFile(oid, { destPath: path.join(tmpDir('ovk-sb2-out'), 'rec.dagcbor'), peerWaitMs: 2000 })
244
245 // Separates "SecretStream vs plain Duplex" from "corestore shares the mux".
246 t.ok(got, 'keyed fetch works over SecretStream when corestore is NOT on the mux')
247 if (got) t.is(Buffer.compare(fs.readFileSync(got.destPath), SMALL), 0, 'bytes match')
248})