OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer / test / resync-fresh-corestore.test.js

105 lines · javascript ref:
// RESYNC (fresh-corestore re-join) — the heal for a POISONED StateBase autobase local writer.
//
// A poisoned local writer crashes autobase apply (SIGABRT in ApplyState._appendLocalNodes) at every
// boot. A fresh NAMESPACE re-derives the SAME writer (primaryKey-derived); only a fresh CORESTORE sheds
// it. This proves the mechanism the bin/resync-did directive drives: a device that re-opens its
// StateBase from a FRESH corestore (a) gets a DIFFERENT (clean) local writer key — the shedding — yet
// (b) joins the SAME base and RE-CONVERGES to the authoritative peer's view, and (c) is RE-ADMITTED
// (writable) via the attestation doorbell. Modeled on rejoin-convergence.test.js, but B rejoins from a
// fresh store (the resync side store) rather than its stale one. Plus a unit test for the index helpers.
import '../lib/bare-globals.js'
import test from 'brittle'
import crypto from 'hypercore-crypto'
import fs from 'bare-fs'
import b4a from 'b4a'
import createTestnet from '@hyperswarm/testnet'
import { StateBaseV3 } from 'atproto-room/lib/state-base.js'
import { RepoBase } from 'atproto-room/lib/repo-base.js'
import { makeAttest, makeVerifyAttest, attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey } from 'atproto-room/lib/membership.js'
import { resyncDid, readResyncIndex } from '../lib/device-node.js'

const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
const DEVA = 'legal winner thank year wave sausage worth useful legal winner thank yellow'
const DEVB = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const DID = 'did:plc:resync222222222222222222'
const POST = 'app.bsky.feed.post'
const tmp = () => { const d = '/tmp/hpp-resync-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))

test('resync index helpers: resyncDid bumps a per-DID gen, readResyncIndex reads it back', async (t) => {
  const storage = tmp()
  t.alike(readResyncIndex(storage), {}, 'no index yet → {}')
  const r1 = resyncDid(storage, DID)
  t.is(r1.gen, 1, 'first resync → gen 1')
  t.ok(r1.sidePath.includes('statebase-resync') && r1.sidePath.endsWith('-r1'), 'side path is <storage>/statebase-resync/<did16>-r1')
  t.is(readResyncIndex(storage)[DID], 1, 'index persisted gen 1 for the DID')
  const r2 = resyncDid(storage, DID)
  t.is(r2.gen, 2, 'second resync bumps to gen 2 (a NEW fresh corestore)')
  t.not(r1.sidePath, r2.sidePath, 'each gen is a distinct corestore path')
  const other = resyncDid(storage, 'did:plc:other22222222222222222222')
  t.is(other.gen, 1, 'a different DID is tracked independently (generic, per-DID)')
  t.is(readResyncIndex(storage)[DID], 2, 'the first DID is unaffected by the second')
})

test('fresh-corestore re-join SHEDS the local writer + RE-CONVERGES + RE-ADMITS', { timeout: 240000 }, async (t) => {
  const testnet = await createTestnet(3)
  const rootPub = await rootIdentityPublicKey(ROOT)
  const d1 = await deviceKeetKeyPair(DEVA); const p1 = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: d1.publicKey })
  const d2 = await deviceKeetKeyPair(DEVB); const p2 = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: d2.publicKey })
  // Lowest key = creator = sole indexer (quorum 1, advances alone); higher key = non-indexer rejoiner.
  let a = { dev: d1, proof: p1 }, bb = { dev: d2, proof: p2 }
  if (b4a.compare(a.dev.publicKey, bb.dev.publicKey) > 0) { const t2 = a; a = bb; bb = t2 }
  const devA = a.dev, proofA = a.proof, devB = bb.dev, proofB = bb.proof
  const members = [
    { key: b4a.toString(devA.publicKey, 'hex'), role: 1 },
    { key: b4a.toString(devB.publicKey, 'hex'), role: 1 }
  ]
  const roomSeed = crypto.randomBytes(32); const roomKey = crypto.randomBytes(32); const encryptionKey = crypto.randomBytes(32)
  const mk = (dev, proof, dir) => new StateBaseV3(dir, roomKey, {
    roomSeed, encryptionKey, swarmOpts: { bootstrap: testnet.bootstrap },
    selfKey: dev.publicKey, attest: makeAttest(dev, proof), verifyAttest: makeVerifyAttest(rootPub),
    getMembers: async () => members
  })

  const A = mk(devA, proofA, tmp()); await A.ready(); await A.projectMembers(members)
  let B = mk(devB, proofB, tmp()); await B.ready()
  let closedB = false
  t.teardown(async () => { try { await A.close() } catch {} ; if (!closedB) { try { await B.close() } catch {} } ; await testnet.destroy() })

  // ── Phase 1: B is admitted + writable; A sees B's record (converged) ──
  let rbB = new RepoBase(B, { did: DID })
  let admitted = false
  for (let i = 0; i < 40 && !admitted; i++) { try { await rbB.putRecord(POST, '3fromB', { $type: POST, text: 'from B', createdAt: '2026-07-04T00:00:00.000Z' }) } catch {} ; await B.set('__ping__', { i }).catch(() => {}); await sleep(1500); admitted = await B.effectiveWritable().catch(() => false) }
  t.ok(admitted, 'phase1: B admitted + writable on its ORIGINAL corestore')
  const origLocal = b4a.toString(B.base.local.key, 'hex')
  const baseKey = b4a.toString(B.base.key, 'hex')
  const rbA = new RepoBase(A, { did: DID })
  let seen = null
  for (let i = 0; i < 30 && !seen; i++) { await A.base.update().catch(() => {}); seen = await rbA.getRecord(POST, '3fromB'); if (!seen) await sleep(1000) }
  t.ok(seen, 'phase1: A converged on B\'s record')

  // ── Phase 2: B "poisoned" — take it offline, A advances (records B's original writer never signed) ──
  await B.close(); closedB = true
  for (let k = 0; k < 5; k++) { await rbA.putRecord(POST, '3A' + k, { $type: POST, text: 'A advanced ' + k, createdAt: '2026-07-05T00:00:00.000Z' }) }
  await A.base.update().catch(() => {})
  t.ok(await rbA.getRecord(POST, '3A4'), 'phase2: A advanced while B was down')

  // ── Phase 3: RESYNC — B re-joins from a FRESH corestore (the side store), NOT its old one ──
  B = mk(devB, proofB, tmp()); await B.ready(); closedB = false
  const freshLocal = b4a.toString(B.base.local.key, 'hex')
  t.not(freshLocal, origLocal, 'RESYNC: fresh corestore → a DIFFERENT (clean) local writer key — the poisoned writer is SHED')
  t.is(b4a.toString(B.base.key, 'hex'), baseKey, 'RESYNC: still the SAME base (roomKey-derived) — a fresh replica, not a new room')

  // ── Phase 4: the fresh replica RE-CONVERGES to A's authoritative view ──
  const rbB2 = new RepoBase(B, { did: DID })
  let converged = null
  for (let i = 0; i < 45 && !converged; i++) { await B.base.update().catch(() => {}); await B.set('__resync_ping__', { i }).catch(() => {}); converged = await rbB2.getRecord(POST, '3A4'); if (!converged) await sleep(1000) }
  t.ok(converged && converged.text === 'A advanced 4', 'phase4: the fresh-corestore replica RE-CONVERGED to A\'s advanced records')
  t.ok(await rbB2.getRecord(POST, '3fromB'), 'phase4: the pre-resync history is present too (full view, nothing lost)')

  // ── Phase 5: the fresh writer is RE-ADMITTED (writable) — "fully re-admitted, old tasks restored" ──
  let readmitted = false
  for (let i = 0; i < 40 && !readmitted; i++) { await B.set('__readmit_ping__', { i }).catch(() => {}); await B.base.update().catch(() => {}); readmitted = await B.effectiveWritable().catch(() => false); if (!readmitted) await sleep(1500) }
  t.ok(readmitted, 'phase5: the fresh local writer was RE-ADMITTED (writable) via the attestation doorbell')
})