// MINIMAL REPRO (deep-debugging): isolate whether an EXISTING plain writer (one that
// already appended data) can be promoted to indexer IN PLACE via start-indexing —
// controlling for whether the promotee keeps WRITING during the promotion.
//
// Two nodes only (fast): A = genesis admin (sole indexer, quorum 1); C = admin. C opens,
// writes data (→ admitted plain writer, indexer:false), then start-indexes to become the
// 2nd indexer. This is the 1→2 in-place case — the smallest form of the mid-run failure.
//
// Toggle CHURN_C=1 to have C keep writing during the promotion window; default (unset) =
// C QUIESCES before promoting. Prediction under the "concurrent-writes-block-the-fork"
// hypothesis: quiescent → PASS, churning → FAIL. If quiescent ALSO fails, in-place
// promotion of a data-bearing writer is fundamentally unsupported (fix must avoid the
// plain-writer-first state).
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'
import process from 'bare-process'
const CHURN_C = !!process.env.CHURN_C
const ROOT = path.join(os.tmpdir(), 'sbv3-inplace-min-' + Bare.pid)
try { fs.rmSync(ROOT, { recursive: true, force: true }) } catch {}
const hex = (b) => b4a.toString(b, 'hex')
const kps = [crypto.keyPair(), crypto.keyPair()].sort((x, y) => hex(x.publicKey) < hex(y.publicKey) ? -1 : 1)
const [kpA, kpC] = kps
const members = [
{ key: hex(kpA.publicKey), role: 1 }, // genesis admin
{ key: hex(kpC.publicKey), role: 1 } // admin (promoted in place after writing)
]
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 A, C
const live = () => [A, C].filter(x => x && x.base && !x._closing)
let hb = 0
// churnWho: which nodes append heartbeats. A always churns (drives consensus); C only if CHURN_C.
const settle = async (predicate, maxMs, churnC) => {
const deadline = Date.now() + maxMs
while (Date.now() < deadline) {
if (A.base.writable) { try { await A.set('__hbA__', hb++) } catch {} }
if (churnC && C.base.writable) { try { await C.set('__hbC__', 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 () {
console.log(' MODE: C ' + (CHURN_C ? 'KEEPS WRITING' : 'QUIESCES') + ' during promotion')
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: 999999 })
stage = 'A.ready'; A = new StateBaseV3(path.join(ROOT, 'A'), crypto.randomBytes(32), opt(kpA))
await A.ready(); await A.projectMembers(members)
ok(A.writable && idxCount(A) === 1, '1. A = genesis, sole indexer')
stage = 'C.ready'; C = new StateBaseV3(path.join(ROOT, 'C'), A._roomKey, opt(kpC)); await C.ready()
await settle(async () => (C.base.length || 0) > 0, 45000, true)
// C writes real data → admitted as a PLAIN writer (indexer:false). This is the state
// that differs from a fresh indexer add.
stage = 'C.write'; await C.set('c:data', { hello: 'world' })
const cAdmitted = await settle(async () => (await C.effectiveWritable()) === true, 40000, true)
ok(cAdmitted, '2. C admitted as a plain writer after writing data')
ok(C._isLocalIndexer() !== true, '2b. C is not yet an indexer')
// Optional quiesce: give consensus a moment to drain C's writes before promoting.
if (!CHURN_C) { stage = 'quiesce'; await settle(async () => false, 4000, false) }
// THE PROMOTION: existing plain writer C → indexer, in place.
stage = 'promote'; await C.requestWriter({ timeoutMs: 60000, startIndexing: true })
const cIndexed = await settle(async () => C._isLocalIndexer() === true, 60000, CHURN_C)
console.log(' [diag] indexer counts A=%s C=%s ; C.writable=%s', idxCount(A), idxCount(C), C.base.writable)
ok(cIndexed, '3. existing plain writer C PROMOTED to indexer in place (set 1→2)')
ok(idxCount(A) === 2, '3b. A also sees the 2-indexer set')
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) })