OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-room / test / silent-loss.repro.mjs

82 lines · javascript ref:
// SMOKING GUN: our set() fuses content into a pre-admission optimistic block and does NOT block
// until writable. If no indexer is online to admit+ack, set() returns SUCCESS but autobase rolls
// the block back every drain → the write is SILENTLY LOST. KEET never does this (optimistic =
// content-free admission doorbell; content only via a confirmed-writer append).
//
// Three scenarios:
//   HAPPY   : A(indexer) online, B writes k1 once (no retry loop) → does a SINGLE set commit?
//   OFFLINE : A never online, B writes k1 → set() reports success; is k1 durable? (expect NO)
//   RECOVER : A offline when B writes k1, THEN A comes online → does k1 recover, or is it lost
//             because B never re-appended?
import '../lib/bare-globals.js'
import crypto from 'hypercore-crypto'
import fs from 'bare-fs'
import b4a from 'b4a'
import createTestnet from '@hyperswarm/testnet'
import { StateBaseV3 } from '../lib/state-base.js'
import { makeAttest, makeVerifyAttest, attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey } from '../lib/membership.js'

const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
const DEVA = 'legal winner thank year wave sausage worth useful legal winner thank yellow'
const DEVB = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const tmp = () => { const d = '/tmp/hpp-loss-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
const log = (...a) => console.log('[LOSS]', ...a)

async function main () {
  const MODE = process.env.MODE || 'HAPPY'
  const testnet = await createTestnet(3)
  const rootPub = await rootIdentityPublicKey(ROOT)
  let da = await deviceKeetKeyPair(DEVA); let pa = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: da.publicKey })
  let db = await deviceKeetKeyPair(DEVB); let pb = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: db.publicKey })
  let A = { dev: da, proof: pa }, B = { dev: db, proof: pb }
  if (b4a.compare(A.dev.publicKey, B.dev.publicKey) > 0) { const t = A; A = B; B = t }
  const members = [
    { key: b4a.toString(A.dev.publicKey, 'hex'), role: 1 },
    { key: b4a.toString(B.dev.publicKey, 'hex'), role: 1 }
  ]
  const roomSeed = crypto.randomBytes(32); const roomKey = crypto.randomBytes(32); const encryptionKey = crypto.randomBytes(32)
  const mk = (dev, proof, dir) => new StateBaseV3(dir, roomKey, {
    roomSeed, encryptionKey, swarmOpts: { bootstrap: testnet.bootstrap },
    selfKey: dev.publicKey, attest: makeAttest(dev, proof), verifyAttest: makeVerifyAttest(rootPub),
    getMembers: async () => members
  })
  const dirA = tmp()
  log('MODE', MODE)

  let sbA = null
  if (MODE === 'HAPPY') { sbA = mk(A.dev, A.proof, dirA); await sbA.ready(); await sbA.projectMembers(members); log('A (indexer) ONLINE') }
  else { log('A (indexer) OFFLINE at write time') }

  const sbB = mk(B.dev, B.proof, tmp()); await sbB.ready()

  // THE REAL API USAGE: one set(), no ping/retry loop.
  log('B: set(k1) ONCE (no retry loop)...')
  let setThrew = null
  try { await sbB.set('k1', { v: 'important-post' }) } catch (e) { setThrew = e.message }
  log('B set() returned:', setThrew ? ('THREW: ' + setThrew) : 'SUCCESS (no throw)')
  log('B.local.length', sbB.base.local.length, 'B.writable', sbB.base.writable)

  // settle
  for (let i = 0; i < 12; i++) { if (sbA) await sbA.base.update().catch(() => {}); await sbB.base.update().catch(() => {}); await sleep(500) }

  const bK1 = await sbB.get('k1')
  log('after settle — B.get(k1):', JSON.stringify(bK1), bK1 ? 'durable on B' : 'GONE on B (rolled back / never committed)')
  log('B.writable now:', sbB.base.writable, 'B.local.length', sbB.base.local.length)
  if (sbA) { const aK1 = await sbA.get('k1'); log('A.get(k1):', JSON.stringify(aK1), aK1 ? 'committed on A' : 'NOT on A') }

  if (MODE === 'RECOVER') {
    log('--- now bringing A (indexer) ONLINE (B already stopped writing) ---')
    sbA = mk(A.dev, A.proof, dirA); await sbA.ready(); await sbA.projectMembers(members)
    for (let i = 0; i < 30; i++) { await sbA.base.update().catch(() => {}); await sbB.base.update().catch(() => {}); await sleep(1000) }
    const bK1r = await sbB.get('k1'); const aK1r = await sbA.get('k1')
    log('after A online — B.get(k1):', JSON.stringify(bK1r), 'A.get(k1):', JSON.stringify(aK1r))
    log(bK1r ? 'RECOVERED (block re-applied on admission)' : 'STILL LOST — the single optimistic write never recovered')
  }

  if (sbA) await sbA.close(); await sbB.close(); await testnet.destroy()
  log('DONE')
  process.exit(0)
}
main().catch(e => { console.error('LOSS REPRO FAILED', e); process.exit(1) })