// Two-seeder GC routing — the claim left undemonstrated by test/gc-bitfield.mjs.
//
// A (virtual) publishes. B (stock) mirrors it fully. Then A's bytes are GC'd from
// the content store. A client connected to BOTH asks for the file.
// case "lie": A advertises blocks it cannot produce -> does the client recover from B?
// case "paired": A also clears the range, so it stops advertising -> B serves cleanly.
//
// node test/two-seeder-gc.mjs <workDir>
import { createRequire } from 'module'
const require = createRequire(import.meta.url)
const fs = require('fs')
const crypto = require('hypercore-crypto')
const Corestore = require('corestore')
const Hyperdrive = require('hyperdrive')
const { ContentStore, build } = require('../lib/storage.cjs')
const { chunk } = await import('../../hyper-overlay/lib/chunker.js')
const ROOT = process.argv[2]
const NAME = '/app.bin'
const srcDir = ROOT + '/src'
fs.mkdirSync(srcDir, { recursive: true })
const payload = crypto.randomBytes(8 * 1024 * 1024)
const file = srcDir + '/payload.bin'
fs.writeFileSync(file, payload)
const chunks = chunk(payload)
function write (ws, buf) {
return new Promise((resolve) => {
if (ws.write(buf)) return resolve()
ws.once('drain', resolve)
})
}
function pipe (s1, s2) {
s1.pipe(s2).pipe(s1)
return () => {
s1.destroy()
s2.destroy()
}
}
async function scenario (tag, { clearAfterGc }) {
const base = `${ROOT}/${tag}`
const content = new ContentStore()
for (const c of chunks) content.add(c.hash, file, c.offset, c.length)
const Storage = build(content)
// A — virtual seeder
const aStore = new Corestore(new Storage(base + '/a'), { primaryKey: Buffer.alloc(32, 7), unsafe: true })
const aDrive = new Hyperdrive(aStore)
await aDrive.ready()
const ws = aDrive.createWriteStream(NAME, { dedup: true })
for (const c of chunks) await write(ws, c.data)
await new Promise((resolve, reject) => {
ws.once('error', reject)
ws.once('close', resolve)
ws.end()
})
await aDrive.getBlobs()
const driveKey = aDrive.key
// B — stock mirror, pulls everything from A
const bStore = new Corestore(base + '/b', { primaryKey: Buffer.alloc(32, 8), unsafe: true })
const bDrive = new Hyperdrive(bStore, driveKey)
await bDrive.ready()
const unpipeAB = pipe(aStore.replicate(true), bStore.replicate(false))
await bDrive.core.update({ wait: true })
const mirrored = await bDrive.get(NAME)
const bHasAll = mirrored !== null && mirrored.equals(payload)
await bDrive.getBlobs()
unpipeAB()
// count who actually serves
let servedByA = 0
let servedByB = 0
aDrive.blobs.core.on('upload', () => servedByA++)
bDrive.blobs.core.on('upload', () => servedByB++)
// the overlay GCs A's bytes
const entry = await aDrive.entry(NAME)
const map = await aDrive.blobs.getBlockMap(entry.value.blob)
const blocks = map.blocks.map((b) => b.index)
for (const c of chunks) content.locations.delete(c.hash)
if (clearAfterGc) await aDrive.blobs.core.clear(blocks[0], blocks[blocks.length - 1] + 1)
const aAdvertises = await aDrive.blobs.core.has(blocks[0])
// C — fresh client, connected to BOTH
const cStore = new Corestore(base + '/c', { primaryKey: Buffer.alloc(32, 9), unsafe: true })
const cDrive = new Hyperdrive(cStore, driveKey)
await cDrive.ready()
const unpipeCA = pipe(aStore.replicate(true), cStore.replicate(false))
const unpipeCB = pipe(bStore.replicate(true), cStore.replicate(false))
await cDrive.core.update({ wait: true })
const t0 = Date.now()
let outcome
try {
const got = await cDrive.get(NAME, { timeout: 15000 })
outcome = got === null ? 'null' : got.equals(payload) ? 'ok' : 'CORRUPT'
} catch (err) {
outcome = 'throw:' + (err.code || err.message)
}
const elapsedMs = Date.now() - t0
unpipeCA()
unpipeCB()
for (const d of [cDrive, bDrive, aDrive]) await d.close().catch(() => {})
for (const s of [cStore, bStore, aStore]) await s.close().catch(() => {})
content.close()
return { tag, bHasAll, aAdvertisesAfterGc: aAdvertises, outcome, elapsedMs, servedByA, servedByB }
}
const lie = await scenario('lie', { clearAfterGc: false })
console.log(JSON.stringify(lie))
const paired = await scenario('paired', { clearAfterGc: true })
console.log(JSON.stringify(paired))
// NB: do NOT read the elapsed difference as signal — across repeated runs the two
// cases overlap (lie 117-135ms, paired 104-133ms). What matters is that BOTH
// recover, and that the second seeder serves essentially all of it.
console.log(
JSON.stringify(
{
bothRecovered: lie.outcome === 'ok' && paired.outcome === 'ok',
servedBySecondSeeder: {
lie: lie.servedByB + '/' + (lie.servedByA + lie.servedByB),
paired: paired.servedByB + '/' + (paired.servedByA + paired.servedByB)
},
ms: { lie: lie.elapsedMs, paired: paired.elapsedMs },
verdict:
lie.outcome === 'ok' && paired.outcome === 'ok'
? 'the bitfield lie is survivable when another source exists'
: 'FAIL'
},
null,
2
)
)