OverviewFilesCommitsIssuesDocsReleasesPeers

keet-daemon / test / probe-indexer-promotion.mjs

140 lines · javascript ref:
// 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) })