OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer commits / 642d357

keep the view-reindex crash repro and the e2e latency bench; ignore blob test output
Sweeps in work from 2026-07-21 that was never committed. - test/viewreindex-repro.test.js: a member reopening the SAME store as forceCreator makes autobase reindex the view, and hypercore throws 'Invalid checkout N for batch, length is 0'. STILL REPRODUCES (2026-10-12). Deliberately NOT in test/all.js; run it by hand. It matters more now that the signer's StateBase stays on autobase (hyper-social #77). - scripts/bench-e2e.mjs: post a marker through apply-write and time it to bsky. - .gitignore: data/blob-test-*/ (three 104 KB directories of test output). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
642d357c2624a39909b49068f4e1efbbc5ebd98e
by plankatron <fleeky@gmail.com> · 2026-10-13T00:29:51.000Z

Changed files (3) +157 -0

.gitignore +3 -0
……@@ -1 +1,4 @@
11node_modules/
2
3# blob-store test output
4data/blob-test-*/
scripts/bench-e2e.mjs +87 -0
……@@ -0,0 +1,87 @@
1// bench-e2e — profile a hosted DID: e2e post LATENCY to bsky + STORAGE state + head advance.
2// bare scripts/bench-e2e.mjs <controlKeyHex> <did> [handle] [writerSeed]
3//
4// Posts one timestamped marker via the device's apply-write (control swarm), then polls the DID's PDS
5// (com.atproto.repo.getRecord) until the record resolves — measuring the peer→bridge→relay→AppView
6// latency. Reads get-state before/after for record count + head. Prints a JSON profile line.
7import '../lib/bare-globals.js'
8import Hyperswarm from 'hyperswarm'
9import crypto from 'hypercore-crypto'
10import b4a from 'b4a'
11import c from 'compact-encoding'
12import ProtomuxRPC from 'protomux-rpc'
13import { tidNow } from 'atproto-store/lib/tid.js'
14
15const ck = globalThis.Bare.argv[2]
16const did = globalThis.Bare.argv[3]
17const handle = globalThis.Bare.argv[4] || did
18const writerSeed = globalThis.Bare.argv[5] || 'wj62-swarm-test-writer-2026-07-05'
19if (!ck || !did) { console.log('usage: bare scripts/bench-e2e.mjs <controlKeyHex> <did> [handle] [writerSeed]'); globalThis.Bare.exit(1) }
20
21const now = () => Date.now()
22const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
23async function pdsFor (did) {
24 try {
25 const doc = await (await fetch('https://plc.directory/' + did)).json()
26 const svc = (doc.service || []).find((s) => (s.id || '').endsWith('atproto_pds'))
27 return svc ? svc.serviceEndpoint : null
28 } catch { return null }
29}
30// Connect AS the authorized writer — apply-write is writer-gated on the connection's remotePublicKey,
31// so the client swarm keypair MUST be the writer seed's keypair (get-state is public, works either way).
32async function rpc (ck, method, params, keyPair) {
33 const swarm = new Hyperswarm({ keyPair })
34 const done = new Promise((res) => {
35 const to = setTimeout(() => res({ error: 'timeout' }), 30000)
36 swarm.on('connection', async (conn) => {
37 if (b4a.toString(conn.remotePublicKey, 'hex') !== ck.toLowerCase()) { conn.destroy(); return }
38 const r = new ProtomuxRPC(conn, { protocol: 'hyper-proto-peer/device/v1', valueEncoding: c.json })
39 try { const out = await r.request(method, params); clearTimeout(to); res({ out }) } catch (e) { clearTimeout(to); res({ error: e.message }) }
40 })
41 })
42 swarm.join(b4a.from(ck, 'hex'), { client: true, server: false })
43 await swarm.flush()
44 const r = await done
45 await swarm.destroy()
46 return r
47}
48
49async function main () {
50 const writerKp = crypto.keyPair(crypto.hash(b4a.from(writerSeed))) // the allowlisted writer identity
51 const pds = await pdsFor(did)
52 const before = (await rpc(ck, 'get-state', {}, writerKp)).out || {}
53 const rkey = tidNow()
54 const text = `bench ${new Date().toISOString()} — e2e latency probe`
55 const record = { $type: 'app.bsky.feed.post', text, createdAt: new Date().toISOString(), langs: ['en'] }
56
57 const t0 = now()
58 const posted = await rpc(ck, 'apply-write', { action: 'create', collection: 'app.bsky.feed.post', rkey, record }, writerKp)
59 const tPosted = now()
60 if (posted.error) { console.log(JSON.stringify({ did, error: 'apply-write: ' + posted.error })); globalThis.Bare.exit(1) }
61
62 // poll the PDS until the record resolves (peer→bridge→relay→AppView propagation)
63 let latencyMs = null
64 if (pds) {
65 const url = `${pds}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=app.bsky.feed.post&rkey=${rkey}`
66 for (let i = 0; i < 60; i++) { // up to ~90s
67 try { const r = await fetch(url); if (r.ok) { latencyMs = now() - t0; break } } catch {}
68 await sleep(1500)
69 }
70 }
71 const after = (await rpc(ck, 'get-state', {}, writerKp)).out || {}
72
73 console.log(JSON.stringify({
74 did, handle, rkey,
75 applyWriteMs: tPosted - t0,
76 bskyLatencyMs: latencyMs, // null = did not appear within ~90s
77 recordCountBefore: before.recordCount ?? null,
78 recordCountAfter: after.recordCount ?? null,
79 headBefore: before.head ? before.head.rev : null,
80 headAfter: after.head ? after.head.rev : null,
81 writable: after.writable ?? null,
82 indexers: after.indexers ?? null,
83 pds
84 }, null, 2))
85 globalThis.Bare.exit(0)
86}
87main()
test/viewreindex-repro.test.js +67 -0
……@@ -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.
5import '../lib/bare-globals.js'
6import test from 'brittle'
7import fs from 'bare-fs'
8import b4a from 'b4a'
9import createTestnet from '@hyperswarm/testnet'
10import { bootMultiDevice, provisionDevice } from '../lib/device-node.js'
11import { deviceKeetKeyPair } from 'atproto-room/lib/membership.js'
12
13const 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}
18const tmp = () => { const d = '/tmp/hpp-vrx-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
19const sleep = (ms) => new Promise(r => setTimeout(r, ms))
20const post = (rk, text) => ({ action: 'create', collection: 'app.bsky.feed.post', rkey: rk, record: { $type: 'app.bsky.feed.post', text, createdAt: new Date().toISOString() } })
21const has = (st, rk) => st.records.some(r => r.rkey === rk)
22async 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
28test('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})