OverviewFilesCommitsIssuesDocsReleasesPeers

keet-daemon / test / probe-nonindexer-read.mjs

107 lines · javascript ref:
// ISOLATION: can a NON-INDEXER peer read committed gamestate via sb.get()? This is the
// exact path the game uses (router.js state.get → sb.get). v2 showed the writer C reading
// its own room's c:score as null; this pins down WHY by testing two non-indexer readers
// side by side:
//   R = PURE READER — never writes (the real "non-indexer player just observing state").
//   W = WRITER-READER — writes its OWN key first (like v2's C: an optimistic writer), then
//       tries to read A's committed key + its own.
//
// A = genesis admin = sole indexer (quorum 1) writes c:score. If R sees it but W doesn't,
// C's null was an OPTIMISTIC-WRITER view artifact, not a general non-indexer read gap →
// pure players read fine and rotation stands. If R ALSO reads null, there's a real
// non-indexer read-visibility gap (bigger than the promotion work).
//
// PASS =
//   1  A (sole indexer) wrote + reads c:score
//   2  R (pure reader, never wrote) reads c:score  ← the decisive assertion
//   3  W becomes an admitted writer and reads its OWN write
//   4  W reads A's committed c:score               ← reproduces/【dis】proves the C=null case
//   5  A reads W's write (round-trip)
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-read-' + Bare.pid)
try { fs.rmSync(ROOT, { recursive: true, force: true }) } catch {}
const hex = (b) => b4a.toString(b, 'hex')

const kpA = crypto.keyPair(); const kpR = crypto.keyPair(); const kpW = crypto.keyPair()
const members = [
  { key: hex(kpA.publicKey), role: 1 }, // genesis admin = sole indexer
  { key: hex(kpR.publicKey), role: 3 }, // pure reader (member)
  { key: hex(kpW.publicKey), role: 3 }  // writer-reader (member)
]

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 scoreOn = async (sb) => { try { const v = await sb.get('c:score'); return v && v.pts } catch { return 'ERR' } }

let A, R, W
const live = () => [A, R, W].filter(x => x && x.base && !x._closing)
let hb = 0
// ONLY A writes (drives finalization as sole indexer). R NEVER writes. W writes only when
// explicitly told (writeW flag). Everyone calls update() so views advance.
const settle = async (predicate, maxMs, writeW = false) => {
  const deadline = Date.now() + maxMs
  while (Date.now() < deadline) {
    if (A.base.writable) { try { await A.set('__hbA__', hb++) } catch {} }
    if (writeW && W.base.writable) { try { await W.set('__hbW__', hb++) } catch {} }
    for (const x of live()) await x.base.update().catch(() => {})
    await sleep(150)
    if (await predicate()) return true
  }
  return await predicate()
}

let stage = 'init'
const watchdog = setTimeout(() => { console.log('  [WATCHDOG] stuck at stage=' + stage + ' — exiting to flush'); Bare.exit(3) }, 150000)

async function main () {
  stage = 'testnet'
  const testnet = await createTestnet(3)
  const bootstrap = testnet.bootstrap
  const roomSeed = crypto.randomBytes(32)
  const opt = (kp) => ({ roomSeed, encryptionKey: roomSeed, network: true, swarmOpts: { bootstrap }, selfKeyPair: kp, selfKey: kp.publicKey, getMembers: async () => members, promoteRetryMs: 9999999 })

  stage = 'A'; A = new StateBaseV3(path.join(ROOT, 'A'), crypto.randomBytes(32), opt(kpA))
  await A.ready(); await A.projectMembers(members)
  stage = 'R'; R = new StateBaseV3(path.join(ROOT, 'R'), A._roomKey, opt(kpR)); await R.ready()
  stage = 'W'; W = new StateBaseV3(path.join(ROOT, 'W'), A._roomKey, opt(kpW)); await W.ready()
  await settle(async () => (R.base.length || 0) > 0 && (W.base.length || 0) > 0, 45000)

  // A (sole indexer) writes committed gamestate.
  stage = 'A.write'; await A.set('c:score', { pts: 100 })
  ok(await settle(async () => (await scoreOn(A)) === 100, 30000), '1. A (sole indexer) wrote + reads c:score')

  // DECISIVE: R never wrote anything — can it read the committed value?
  stage = 'R.read'; const rSees = await settle(async () => (await scoreOn(R)) === 100, 45000)
  console.log('  [diag] c:score seen by  A=%s R=%s W=%s', await scoreOn(A), await scoreOn(R), await scoreOn(W))
  ok(rSees, '2. PURE READER R (never wrote) reads committed c:score=100')

  // W writes its own key → becomes an optimistic/admitted writer (like v2's C).
  stage = 'W.write'; await W.set('w:hello', { hi: true })
  ok(await settle(async () => (await W.effectiveWritable()) === true, 40000, true), '3a. W admitted as a writer')
  const wOwn = await (async () => { try { const v = await W.get('w:hello'); return v && v.hi === true } catch { return false } })()
  ok(wOwn, '3b. W reads its OWN write')

  // The C=null reproduction: can an optimistic WRITER read A's committed key?
  stage = 'W.read'; const wSees = await settle(async () => (await scoreOn(W)) === 100, 45000, true)
  console.log('  [diag] after W wrote: c:score  A=%s R=%s W=%s', await scoreOn(A), await scoreOn(R), await scoreOn(W))
  ok(wSees, '4. WRITER-READER W reads A\'s committed c:score=100 (if FAIL → optimistic-writer view artifact = the C=null cause)')

  // Round-trip: A reads W's write.
  stage = 'A.readW'; const aSeesW = await settle(async () => { try { const v = await A.get('w:hello'); return v && v.hi === true } catch { return false } }, 30000, true)
  ok(aSeesW, '5. A reads W\'s write (round-trip)')

  clearTimeout(watchdog)
  console.log('\n  RESULT: ' + pass + ' passed, ' + fail + ' failed')
  Bare.exit(fail === 0 ? 0 : 1)
}

main().catch((e) => { console.log('  FATAL ' + (e && e.stack || e)); Bare.exit(2) })