// 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')
})