OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer / test / rejoin-convergence.test.js

92 lines · javascript ref:
// REPRO for the live wj62 split-brain (2026-07-12): fresh 2-device convergence works
// (multi-device.test.js passes), but a device that goes OFFLINE, lets the other advance, then
// REJOINS with stale local state — does it re-converge? This models tabertron (offline ~7 days,
// stale StateBase) rejoining the running stortron. Same StateBaseV3 replication, local testnet.
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'

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:swarm2222222222222222222'
const POST = 'app.bsky.feed.post'
const tmp = () => { const d = '/tmp/hpp-rejoin-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
const sleep = (ms) => new Promise(r => setTimeout(r, ms))

test('offline device rejoins STALE and must re-converge to the peer that advanced', { 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 })
  // Faithful to live (stortron key 71555d… < tabertron 81e1cb…): the ONLINE/advancing device (A) must
  // be the LOWEST key = creator = sole indexer; the OFFLINE/rejoining device (B) is the higher-key
  // NON-indexer. StateBaseV3 admission adds writers indexer:false, so A stays the only indexer → quorum
  // 1 → A can advance alone. (My first run took the indexer offline → immediate deadlock.)
  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 dirB = tmp() // B's PERSISTENT storage — reused across the offline/rejoin
  const A = mk(devA, proofA, tmp()); await A.ready(); await A.projectMembers(members)
  let B = mk(devB, proofB, dirB); await B.ready()
  t.teardown(async () => { try { await A.close() } catch {} ; try { await B.close() } catch {} ; await testnet.destroy() })

  // ── Phase 1: initial convergence (identical to multi-device.test.js) ──
  let rbB = new RepoBase(B, { did: DID })
  const rec = { $type: POST, text: 'from B before offline', createdAt: '2026-07-04T00:00:00.000Z' }
  let admitted = false
  for (let i = 0; i < 40 && !admitted; i++) { try { await rbB.putRecord(POST, '3fromB', rec) } catch {} ; await B.set('__ping__', { i }).catch(() => {}); await sleep(1500); admitted = await B.effectiveWritable().catch(() => false) }
  t.ok(admitted, 'phase1: device B admitted + writable')
  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 saw B\'s record (converged)')

  // B must pull back A's FINALIZED checkpoint (indexed view) so it locally MATERIALIZES its own record
  // before going offline. Subtlety: B wrote '3fromB' optimistically + A saw it via A's optimistic view,
  // but a non-indexer B only gains a durable/readable view once it replicates the INDEXER's checkpoint.
  // Without this wait, B goes offline read-blind to its own optimistic write (models a not-yet-persisted
  // device, not a genuinely-stale one). This loop = "B durably persisted state, THEN went stale".
  let bHasOwn = null
  for (let i = 0; i < 30 && !bHasOwn; i++) { await B.base.update().catch(() => {}); bHasOwn = await rbB.getRecord(POST, '3fromB'); if (!bHasOwn) await sleep(1000) }
  t.ok(bHasOwn, 'phase1: B locally MATERIALIZED its own record (replicated A\'s finalized checkpoint)')

  // ── Phase 2: B goes OFFLINE ──
  await B.close()

  // ── Phase 3: A advances the shared base while B is offline ──
  for (let k = 0; k < 5; k++) { await rbA.putRecord(POST, '3A' + k, { $type: POST, text: 'A while B offline ' + k, createdAt: '2026-07-05T00:00:00.000Z' }) }
  await A.base.update().catch(() => {})
  t.ok(await rbA.getRecord(POST, '3A4'), 'phase3: A advanced (wrote 3A0..3A4 while B offline)')

  // ── Phase 4: B REJOINS from the SAME stale storage ──
  B = mk(devB, proofB, dirB); await B.ready()
  const rbB2 = new RepoBase(B, { did: DID })
  const staleRec = await rbB2.getRecord(POST, '3fromB')
  t.ok(staleRec, 'phase4: B reopened with its STALE local state (still has pre-offline record)')
  const seesNewImmediately = await rbB2.getRecord(POST, '3A4')
  t.absent(seesNewImmediately, 'phase4: B does NOT yet have A\'s offline-era records (genuinely stale)')

  // ── Phase 5: THE ASSERTION — does the stale rejoiner re-converge? ──
  let converged = null
  for (let i = 0; i < 45 && !converged; i++) { await B.base.update().catch(() => {}); await B.set('__rejoin_ping__', { i }).catch(() => {}); converged = await rbB2.getRecord(POST, '3A4'); if (!converged) await sleep(1000) }
  t.ok(converged && converged.text === 'A while B offline 4', 'phase5: stale rejoiner B RE-CONVERGES to A\'s newer records')
})