| … | … | @@ -0,0 +1,67 @@ |
| 1 | // REPRO: the in-place view-reindex crash. A MEMBER converges a base, then reopens the SAME storage |
| 2 | // forcing the genesis/creator role (assume the indexer in place, continuing the existing log). Autobase |
| 3 | // reindexes the view on the role change and something requests a stale checkout on the reset view core → |
| 4 | // hypercore "Invalid checkout N for batch, length is 0". Instrumented hypercore prints the culprit stack. |
| 5 | import '../lib/bare-globals.js' |
| 6 | import test from 'brittle' |
| 7 | import fs from 'bare-fs' |
| 8 | import b4a from 'b4a' |
| 9 | import createTestnet from '@hyperswarm/testnet' |
| 10 | import { bootMultiDevice, provisionDevice } from '../lib/device-node.js' |
| 11 | import { deviceKeetKeyPair } from 'atproto-room/lib/membership.js' |
| 12 | |
| 13 | const M = { |
| 14 | communityRoot: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art', |
| 15 | device1: 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote', |
| 16 | device2: 'legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal will' |
| 17 | } |
| 18 | const tmp = () => { const d = '/tmp/hpp-vrx-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d } |
| 19 | const sleep = (ms) => new Promise(r => setTimeout(r, ms)) |
| 20 | const post = (rk, text) => ({ action: 'create', collection: 'app.bsky.feed.post', rkey: rk, record: { $type: 'app.bsky.feed.post', text, createdAt: new Date().toISOString() } }) |
| 21 | const has = (st, rk) => st.records.some(r => r.rkey === rk) |
| 22 | async function bundle (rootMnem, devMnem, handle) { |
| 23 | const dk = await deviceKeetKeyPair(devMnem) |
| 24 | const b = await provisionDevice({ rootMnemonic: rootMnem, devicePublicKey: dk.publicKey, handle, pdsUrl: 'https://pds.local' }) |
| 25 | return { entry: { deviceMnemonic: devMnem, did: b.did, signingKey: b.signingKey, deviceProof: b.deviceProof, rootIdentityKey: b.rootIdentityKey, handle }, keetPub: b4a.toString(dk.publicKey, 'hex'), did: b.did } |
| 26 | } |
| 27 | |
| 28 | test('REPRO: member reopens SAME store as forceCreator (assume indexer in place) → view-reindex checkout crash', { timeout: 240000 }, async (t) => { |
| 29 | try { process.on('unhandledRejection', (e) => { console.error('UNHANDLED ' + (e && (e.stack || e.message || e))) }) } catch {} |
| 30 | const testnet = await createTestnet(3) |
| 31 | const swarmOpts = { bootstrap: testnet.bootstrap } |
| 32 | const sCre = tmp(); const sMem = tmp() |
| 33 | const d1 = await bundle(M.communityRoot, M.device1, 'club.local') |
| 34 | const d2 = await bundle(M.communityRoot, M.device2, 'club.local') |
| 35 | const CRE = d1.keetPub < d2.keetPub ? d1 : d2 |
| 36 | const MEM = d1.keetPub < d2.keetPub ? d2 : d1 |
| 37 | const DID = CRE.did |
| 38 | const members = [{ key: d1.keetPub, role: 1 }, { key: d2.keetPub, role: 1 }] |
| 39 | CRE.entry.members = members; MEM.entry.members = members |
| 40 | |
| 41 | const nCre = await bootMultiDevice({ storage: sCre, dids: [CRE.entry], swarmOpts, signIntervalMs: 1000 }) |
| 42 | let nMem = await bootMultiDevice({ storage: sMem, dids: [MEM.entry], swarmOpts, signIntervalMs: 1000 }) |
| 43 | t.teardown(async () => { try { await nCre.close() } catch {} ; try { await nMem.close() } catch {} ; await testnet.destroy() }) |
| 44 | const cre = () => nCre.byDid.get(DID) |
| 45 | let mem = nMem.byDid.get(DID) |
| 46 | |
| 47 | await cre().device.applyWrite(post('r-cre', 'creator')) |
| 48 | let wrote = false |
| 49 | for (let i = 0; i < 60 && !wrote; i++) { await cre().sb.base.update().catch(() => {}); await mem.sb.base.update().catch(() => {}); try { await mem.device.applyWrite(post('r-mem', 'member')); wrote = true } catch { await mem.sb.set('__ping__', { i }).catch(() => {}); await sleep(1000) } } |
| 50 | t.ok(wrote, 'member admitted + wrote') |
| 51 | for (let i = 0; i < 40; i++) { await mem.sb.base.update().catch(() => {}); await sleep(300) } |
| 52 | t.ok(has(await mem.device.state(), 'r-cre'), 'member converged (has a working log)') |
| 53 | |
| 54 | // ── IN-PLACE role change: reopen the member's SAME storage forcing the genesis/creator role ── |
| 55 | await nMem.close() |
| 56 | nMem = await bootMultiDevice({ storage: sMem, dids: [{ ...MEM.entry, forceCreator: true }], swarmOpts, signIntervalMs: 1000 }) |
| 57 | mem = nMem.byDid.get(DID) |
| 58 | // a write drives the tick/sign/membrane path that trips the reindex checkout |
| 59 | let crashed = false |
| 60 | try { |
| 61 | for (let i = 0; i < 20; i++) { await mem.sb.base.update().catch(() => {}); await sleep(500) } |
| 62 | await mem.device.applyWrite(post('r-post-switch', 'after in-place role change')) |
| 63 | for (let i = 0; i < 20; i++) { await mem.sb.base.update().catch(() => {}); await sleep(500) } |
| 64 | } catch (e) { crashed = true; console.error('CAUGHT ' + (e && e.stack)) } |
| 65 | console.log('DIAG done crashed=' + crashed + ' memWritable=' + mem.sb.base.writable + ' len=' + mem.sb.base.length) |
| 66 | t.pass('repro ran (see DIAG-CHECKOUT stack for the culprit)') |
| 67 | }) |