OverviewFilesCommitsIssuesDocsReleasesPeers

keet-daemon / test / probe-leave-rejoin.mjs

112 lines · javascript ref:
// TRUE LEAVE/REJOIN: a data-bearing peer becomes an indexer by LEAVING (close) and REJOINING
// FRESH (new storage dir → fresh writer that is the local writer from the first open — NO
// setLocal swap → SINGLE migration), then start-indexing via the clean pending path. This is
// keet's actual model (join with an invite = fresh writer) and should match the RELIABLE
// fresh-join (probe-indexer-quorum 8/8), unlike setLocal in-place rotation (inherently flaky:
// its extra reboot races the migration — autobase audit).
//
// C's OLD data-bearing writer (dir C1) is abandoned; its blocks stay in the shared log. C
// rejoins with the SAME keet identity (kpC) into a fresh corestore (dir C2) → re-replicates →
// fresh length-0 writer → admitted as indexer.
//
// PASS (per run) =
//   1  {A,B}=2 indexers; C a data-bearing member
//   2  after LEAVE+REJOIN(fresh)+start-indexing: C is an indexer, set=3
//   3  STAGE-B all-online: signedLength advances (single migration didn't stall)
//   4  genesis OFFLINE: B+C finalize (rotated-in C really co-signs) — the durability payoff
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-leaverejoin-' + Bare.pid)
try { fs.rmSync(ROOT, { recursive: true, force: true }) } catch {}
const hex = (b) => b4a.toString(b, 'hex')

let kpA = crypto.keyPair(); let kpB = crypto.keyPair()
if (hex(kpB.publicKey) < hex(kpA.publicKey)) { const t = kpA; kpA = kpB; kpB = t }
const kpC = crypto.keyPair()
const members = [
  { key: hex(kpA.publicKey), role: 1 },
  { key: hex(kpB.publicKey), role: 1 },
  { key: hex(kpC.publicKey), role: 3 }
]
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 idx = (sb) => { try { return sb.base.linearizer.indexers.length } catch { return '?' } }
const slen = (sb) => { try { return sb.base.signedLength } catch { return -1 } }

let A, B, C
let bootstrap = null; let roomSeed = null
const opt = (kp) => ({ roomSeed, encryptionKey: roomSeed, network: true, swarmOpts: { bootstrap }, selfKeyPair: kp, selfKey: kp.publicKey, getMembers: async () => members, promoteRetryMs: 9999999 })
const live = () => [A, B, C].filter(x => x && x.base && !x._closing)
let hb = 0
const churn = async (predicate, maxMs, writeC = true) => {
  const deadline = Date.now() + maxMs
  while (Date.now() < deadline) {
    for (const x of live()) { if (x.base.writable && (x !== C || writeC)) { try { await x.set('__hb__:' + hex(x._selfKey).slice(0, 6), hb++) } catch {} } }
    for (const x of live()) await x.base.update().catch(() => {})
    await sleep(150)
    if (predicate && await predicate()) return true
  }
  return predicate ? await predicate() : true
}
const measure = async (label, ms) => {
  const before = slen(B); await churn(null, ms); const after = slen(B)
  console.log('  [measure] %s: B.signedLength %d → %d (Δ%d) | idx A=%s B=%s C=%s', label, before, after, after - before, idx(A), idx(B), idx(C))
  return after - before
}

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

async function main () {
  stage = 'testnet'
  const testnet = await createTestnet(3); bootstrap = testnet.bootstrap; roomSeed = crypto.randomBytes(32)

  stage = 'A'; A = new StateBaseV3(path.join(ROOT, 'A'), crypto.randomBytes(32), opt(kpA)); await A.ready(); await A.projectMembers(members)
  const roomKey = A._roomKey
  stage = 'B'; B = new StateBaseV3(path.join(ROOT, 'B'), roomKey, opt(kpB)); await B.ready()
  stage = 'C1'; C = new StateBaseV3(path.join(ROOT, 'C1'), roomKey, opt(kpC)); await C.ready()
  await churn(async () => (B.base.length || 0) > 0 && (C.base.length || 0) > 0, 45000)

  stage = 'growB'; await B.requestWriter({ timeoutMs: 60000, startIndexing: true })
  ok(await churn(async () => idx(A) === 2 && idx(B) === 2, 90000), '1. {A,B}=2 indexers')

  await measure('STAGE-A pre-leave {A,B} online', 12000)

  // C plays as a data-bearing MEMBER, then is granted admin.
  stage = 'play'; await C.set('c:score', { pts: 100 }); await churn(async () => (await C.effectiveWritable()) === true, 40000)
  members[2].role = 1; await A.projectMembers(members); await churn(async () => (await C.getProjectedRole(hex(kpC.publicKey))) === 1, 30000)

  // ── LEAVE: close C entirely (abandon its data-bearing writer in dir C1) ──
  stage = 'leave'; try { C._closing = true; await C.close() } catch {}
  await sleep(1000)

  // ── REJOIN FRESH: new storage dir C2, SAME keet identity (kpC). Fresh writer is the local
  // writer from the first open — NO setLocal. Then promote via the clean single-migration path.
  stage = 'rejoin'; C = new StateBaseV3(path.join(ROOT, 'C2'), roomKey, opt(kpC)); await C.ready()
  await churn(async () => (C.base.length || 0) > 0 && (C.base.local.length || 0) === 0, 60000)
  console.log('  [diag] rejoined fresh: local.len=%d writable=%s', (C.base.local ? C.base.local.length : -1), C.base.writable)
  stage = 'promote'; await C.requestWriter({ timeoutMs: 60000, startIndexing: true })
  ok(await churn(async () => C._isLocalIndexer() === true && idx(A) === 3 && idx(B) === 3 && idx(C) === 3, 60000), '2. after LEAVE+REJOIN(fresh)+start-indexing: C is indexer; set=3')

  stage = 'measB'; const dB = await measure('STAGE-B post-rejoin, ALL online', 15000)
  ok(dB > 0, '3. post-rejoin signedLength advances (all online)')

  stage = 'closeA'; try { A._closing = true; await A.close() } catch {}
  const dC = await measure('STAGE-C genesis OFFLINE (needs B+C)', 20000)
  console.log('\n  VERDICT: rejoined C is %s', dC > 0 ? 'a REAL signer' : 'a PHANTOM')
  ok(dC > 0, '4. genesis OFFLINE: rejoined C co-signs (durable)')

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