// REPRO: MOVE-INDEXER to a single always-on anchor. The robust fix for the
// "creator went offline → room stuck" class, given autobase can't reliably hold
// >2 indexers (the 3rd-indexer hang, proven separately). Keeps ONE indexer:
// a designated anchor takes the slot from the genesis creator (atomic swap,
// count stays 1), so the room finalizes (quorum 1) whenever the anchor is up —
// regardless of the creator. keet's move-indexer (members/index.js:258-275).
//
// Roles: A = admin, lex-lowest → genesis creator + initial sole indexer.
// B = admin anchor (opts.claimIndexer) → MOVE-INDEXER → becomes SOLE
// indexer; A is demoted to a plain writer.
// D = peer, joins AFTER A is offline → admitted + finalized by B alone.
// Pass: after the move there is exactly 1 indexer = B (A demoted); with A CLOSED,
// D is admitted AND signedLength ADVANCES (real finality at quorum 1).
import { StateBaseV3 } from '../lib/state-base-v3.js'
import createTestnet from 'hyperdht/testnet'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
const ROOT = path.join(os.tmpdir(), 'sbv3-move-' + Bare.pid)
try { fs.rmSync(ROOT, { recursive: true, force: true }) } catch {}
const hex = (b) => b4a.toString(b, 'hex')
const roomSeed = crypto.randomBytes(32)
// Two admin keypairs; lex-lowest = genesis creator (A). B = anchor.
let k1 = crypto.keyPair(); let k2 = crypto.keyPair()
const kpA = hex(k1.publicKey) < hex(k2.publicKey) ? k1 : k2
const kpB = hex(k1.publicKey) < hex(k2.publicKey) ? k2 : k1
const kpD = crypto.keyPair()
const members = [
{ key: hex(kpA.publicKey), role: 1 }, // genesis creator
{ key: hex(kpB.publicKey), role: 1 }, // anchor (claims indexer)
{ key: hex(kpD.publicKey), role: 3 } // peer
]
let pass = 0; let fail = 0
const ok = (c, m) => { if (c) { pass++; console.log(' ok ' + m) } else { fail++; console.log(' FAIL ' + m) } }
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
const idxCount = (sb) => { try { return sb.base.linearizer.indexers.length } catch { return '?' } }
let stage = 'init'
const watchdog = setTimeout(() => { console.log(' [WATCHDOG] stuck at ' + stage); Bare.exit(3) }, 150000)
let A, B, D, E
const live = () => [A, B, D, E].filter(x => x && x.base && !x._closing)
let hb = 0
// churn so the swap manifest migration advances (low-churn is the trap)
const churnSettle = async (predicate, maxMs = 45000) => {
const deadline = Date.now() + maxMs
while (Date.now() < deadline) {
for (const x of live()) { if (x.base.writable) { try { await x.set('__hb__:' + (hb % 4), hb++) } catch {} } }
for (const x of live()) await x.base.update().catch(() => {})
await sleep(150)
if (predicate()) return true
}
return predicate()
}
async function main () {
stage = 'testnet'
const testnet = await createTestnet(3)
const bootstrap = testnet.bootstrap
const opt = (kp, extra = {}) => ({ roomSeed, encryptionKey: roomSeed, network: true, swarmOpts: { bootstrap }, selfKeyPair: kp, selfKey: kp.publicKey, getMembers: async () => members, ...extra })
stage = 'A'; A = new StateBaseV3(path.join(ROOT, 'A'), crypto.randomBytes(32), opt(kpA))
await A.ready(); ok(A.writable, '1. A (lex-lowest admin) = genesis creator + sole indexer')
await A.projectMembers(members)
ok(idxCount(A) === 1, '1b. exactly 1 indexer at genesis')
// B opens as a DESIGNATED ANCHOR — auto-claims the indexer slot via move-indexer.
stage = 'B'; B = new StateBaseV3(path.join(ROOT, 'B'), A._roomKey, opt(kpB, { claimIndexer: true })); await B.ready()
stage = 'moveB'; await churnSettle(() => B.writable && B._isLocalIndexer() === true && idxCount(B) === 1)
ok(B.writable && B._isLocalIndexer() === true, '2. B (anchor) MOVE-INDEXER → B is now an indexer')
ok(idxCount(B) === 1, '3. still exactly ONE indexer after the swap (count constant)')
// A should be demoted to a plain writer.
await churnSettle(() => A._isLocalIndexer() === false, 15000)
ok(A._isLocalIndexer() === false, '4. genesis creator A DEMOTED to plain writer (no longer indexer)')
// Creator A OFFLINE.
stage = 'closeA'; try { await A.close() } catch {}
console.log(' [stage] genesis creator A CLOSED — anchor B is the sole indexer')
await sleep(600)
const signedBefore = B.base.signedLength
stage = 'D'; D = new StateBaseV3(path.join(ROOT, 'D'), B._roomKey, opt(kpD)); await D.ready()
ok(!D.writable, '5. D (peer) opens NOT writable (creator gone)')
stage = 'Dwrite'; await D.set('world:players:' + hex(kpD.publicKey).slice(0, 8), { pos: [9, 9, 9] })
// Settle until B FINALIZES D's write (the real admission proof — D's optimistic
// block commits into B's SIGNED view only if B admitted D as a writer).
stage = 'finalize'; await churnSettle(() => B.base.signedLength > signedBefore + 1, 60000)
// D's optimistic write committed + finalized at B ⇒ D IS an admitted writer.
ok((await B.list('world:players:'))[('world:players:' + hex(kpD.publicKey).slice(0, 8))] != null,
'6. D ADMITTED + FINALIZED by anchor B with creator OFFLINE (write in B signed view)')
// WRITABLE-FLAG-LAG FIX (#99): D is an admitted PLAIN writer, but autobase never
// activates its localWriter, so D.base.writable stays false INDEFINITELY (measured
// 30s+, never flips). effectiveWritable instead reports true once D replicates the
// indexer's signed addWriter into D's system writer set
// (base.system.has(D.local) — the authoritative "admitted" signal, NOT room
// membership). The daemon polls state.info, so emulate that: settle until D pulls
// the signed admission (~1.5s here), bounded.
let dEff = false
for (let i = 0; i < 80 && !dEff; i++) { await D.base.update().catch(() => {}); dEff = await D.effectiveWritable(); if (!dEff) await sleep(150) }
console.log(' [diag] D.base.writable=%s (stays false) D.effectiveWritable=%s', D.base.writable, dEff)
ok(dEff === true, '6b. D reports effectiveWritable=true once admitted (system writer-set, not base.writable)')
console.log(' [diag] FINAL: B indexers=%s signedLength=%d (was %d) length=%d', idxCount(B), B.base.signedLength, signedBefore, B.base.length)
ok(B.base.signedLength > signedBefore, '7. FINALIZES with creator OFFLINE: signedLength ' + signedBefore + '→' + B.base.signedLength + ' (quorum 1, sole anchor)')
const bSees = await B.list('world:players:')
ok(Object.keys(bSees).some(k => k.includes(hex(kpD.publicKey).slice(0, 8))), '8. D\'s edit replicated to anchor B: ' + Object.keys(bSees).join(','))
// FALSE-GREEN REGRESSION (#99): a peer in the membership ROSTER but NEVER admitted
// by an indexer must read effectiveWritable=FALSE. The prior __members__-based
// check returned true for any roster member — the tabertron "ghost clode" that
// showed writable while its writes never finalized on the room's indexer.
stage = 'E'; const kpE = crypto.keyPair()
const rosterWithE = [...members, { key: hex(kpE.publicKey), role: 3 }]
await B.projectMembers(rosterWithE) // B (indexer) publishes E into the roster
E = new StateBaseV3(path.join(ROOT, 'E'), B._roomKey, opt(kpE, { getMembers: async () => rosterWithE })); await E.ready()
// settle until E has REPLICATED the roster (so E ∈ __members__: the OLD signal's
// input). E never writes, so no indexer ever admits it.
let eInRoster = false
for (let i = 0; i < 60 && !eInRoster; i++) {
await E.base.update().catch(() => {})
const mr = await E.list('')
const mem = mr.__members__ ? (typeof mr.__members__ === 'string' ? JSON.parse(mr.__members__) : mr.__members__) : {}
eInRoster = mem[hex(kpE.publicKey)] != null
if (!eInRoster) await sleep(150)
}
ok(eInRoster, '9a. E is in the replicated __members__ roster (the OLD signal WOULD report writable)')
const eEff = await E.effectiveWritable()
console.log(' [diag] E inRoster=%s E.base.writable=%s E.effectiveWritable=%s', eInRoster, E.base.writable, eEff)
ok(eEff === false, '9b. E (roster member, NEVER admitted) reports effectiveWritable=FALSE (#99 false-green fixed)')
clearTimeout(watchdog)
console.log('\n[probe] pass=' + pass + ' fail=' + fail)
Bare.exit(fail ? 1 : 0)
}
main().catch((e) => { console.error('[probe] THREW:', e && e.stack ? e.stack : e); Bare.exit(2) })