OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-room commits / 223072b

test: commit the multi-DID admission repro (was untracked, but shipping anyway)
This file existed only in the working tree, and because CI vendors siblings with `cp -rL` it was being copied into hyper-proto-peer's and atproto-bridge's published closures regardless — shipped to the fleet, absent from git. That is what the new ci.yaml sibling guard warns about ("1 untracked file(s) also vendored"); committing it is the honest resolution. What it documents is real and still reproduces: in the MULTI-DID daemon path the StateBases run network:false (the daemon owns one swarm + store.replicate + wakeup and drives replication itself), so StateBaseV3's OWN connection handler never runs and the __members__ re-projection hook never fires — only the promote-timer path is left. Verified on commit: passes with "admitted=false __members__=present". Deliberately NOT added to test/all.js. It asserts the CURRENT (broken) behaviour as a diagnostic, so wiring it into the suite would mean the suite starts failing the moment someone fixes the gap — which is backwards. The header names the fix: also drive _ensureMembersProjected from the daemon's connection handler in device-node, and make the timer path robust. Whoever does that owns the decision about suite membership. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
223072b0a4a6b72337d449fb7d0c7e6fb9d4b5cb
by plankatron <fleeky@gmail.com> · 2026-07-28T23:29:32.000Z

Changed files (1) +95 -0

test/admission-multidid-repro.test.mjs +95 -0
……@@ -0,0 +1,95 @@
1// REPRO of the canary failure: in the MULTI-DID daemon path the StateBases run network:false (the
2// daemon owns ONE swarm + store.replicate + wakeup and drives replication — see hpp device-node
3// bootMultiDevice). So StateBaseV3's OWN swarm connection handler NEVER runs, and the __members__
4// re-projection connection-hook I added there NEVER FIRES. Only the promote-timer path is left.
5//
6// This test mirrors multi-did.test.js's daemon wiring (network:false + external store.replicate +
7// shared wakeup) and the FIX scenario (a single projectMembers call that NO-OPS at first because the
8// creator isn't writable yet). It asserts what the canary saw: the member is NOT admitted via the
9// connection-hook (it can't fire). The fix is to ALSO drive _ensureMembersProjected from the daemon's
10// connection handler (device-node) AND make the timer path robust.
11
12import '../lib/bare-globals.js'
13import test from 'brittle'
14import crypto from 'hypercore-crypto'
15import fs from 'bare-fs'
16import b4a from 'b4a'
17import Hyperswarm from 'hyperswarm'
18import ProtomuxWakeup from 'protomux-wakeup'
19import createTestnet from '@hyperswarm/testnet'
20import { StateBaseV3 } from '../lib/state-base.js'
21import { openCorestore } from 'atproto-store/lib/corestore.js'
22import { makeAttest, makeVerifyAttest, attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey } from '../lib/membership.js'
23
24const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
25const DEVA = 'legal winner thank year wave sausage worth useful legal winner thank yellow'
26const DEVB = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
27const tmp = () => { const d = '/tmp/hpp-mdid-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
28const sleep = (ms) => new Promise(r => setTimeout(r, ms))
29
30async function setup () {
31 const rootPub = await rootIdentityPublicKey(ROOT)
32 const da = await deviceKeetKeyPair(DEVA); const pa = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: da.publicKey })
33 const db = await deviceKeetKeyPair(DEVB); const pb = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: db.publicKey })
34 let A = { dev: da, proof: pa }, B = { dev: db, proof: pb }
35 if (b4a.compare(A.dev.publicKey, B.dev.publicKey) > 0) { const t = A; A = B; B = t }
36 const members = [{ key: b4a.toString(A.dev.publicKey, 'hex'), role: 1 }, { key: b4a.toString(B.dev.publicKey, 'hex'), role: 1 }]
37 const roomKey = crypto.randomBytes(32); const roomSeed = crypto.randomBytes(32); const encryptionKey = crypto.randomBytes(32)
38 // network:false — exactly the multi-DID daemon mode. NO StateBase swarm ⇒ NO connection-hook.
39 const mk = (dir, dev, proof, store, wakeup) => new StateBaseV3(null, roomKey, {
40 store, wakeup, network: false, roomSeed, encryptionKey,
41 selfKey: dev.publicKey, attest: makeAttest(dev, proof), verifyAttest: makeVerifyAttest(rootPub),
42 getMembers: async () => members, promoteRetryMs: 2000
43 })
44 return { A, B, members, mk, roomKey }
45}
46
47// daemon-style wiring: ONE swarm, store.replicate + wakeup.addStream per conn (like device-node bootMultiDevice)
48function wireDaemon (swarm, store, wakeup) {
49 swarm.on('connection', (conn) => {
50 conn.on('error', () => {})
51 try { wakeup.addStream(conn) } catch {}
52 try { store.replicate(conn) } catch {}
53 })
54}
55
56test('REPRO: multi-DID network:false ⇒ connection-hook cannot fire; timer path alone must admit', { timeout: 120000 }, async (t) => {
57 const testnet = await createTestnet(3)
58 const { members, mk, A, B, roomKey } = await setup()
59 const storeC = openCorestore(tmp()); await storeC.ready(); const wkC = new ProtomuxWakeup()
60 const storeM = openCorestore(tmp()); await storeM.ready(); const wkM = new ProtomuxWakeup()
61 const swarmC = new Hyperswarm({ bootstrap: testnet.bootstrap })
62 const swarmM = new Hyperswarm({ bootstrap: testnet.bootstrap })
63
64 const sbA = mk('C', A.dev, A.proof, storeC, wkC)
65 // single projectMembers BEFORE ready (no-op; retains roster) — models the creator not-writable-yet.
66 await sbA.projectMembers(members)
67 await sbA.ready()
68 const sbB = mk('M', B.dev, B.proof, storeM, wkM); await sbB.ready()
69
70 wireDaemon(swarmC, storeC, wkC)
71 wireDaemon(swarmM, storeM, wkM)
72 swarmC.join(sbA.base.discoveryKey, { server: true, client: true })
73 swarmM.join(sbA.base.discoveryKey, { server: true, client: true })
74 await swarmC.flush().catch(() => {}); await swarmM.flush().catch(() => {})
75 t.teardown(async () => { try { await sbA.close(); await sbB.close(); await swarmC.destroy(); await swarmM.destroy() } catch {} ; await testnet.destroy() })
76
77 // Give the timer path (2s promoteRetryMs) chances + drive updates like the daemon does. Log the
78 // creator's __members__/projected state each round so we see whether the TIMER ever projects.
79 let admitted = false
80 for (let i = 0; i < 12 && !admitted; i++) {
81 const r = await sbB.requestWriter({ timeoutMs: 2000 }).catch(() => ({ writable: false }))
82 admitted = !!r.writable
83 const mv = await sbA.getNow('__members__').catch(() => null)
84 console.log(`[repro] round ${i}: creatorWritable=${sbA.base && sbA.base.writable} __members__=${mv ? 'present' : 'ABSENT'} membersProjected=${sbA._membersProjected} roster=${sbA._roster ? sbA._roster.length : 'null'} memberAdmitted=${admitted}`)
85 if (!admitted) { await sbA.base.update().catch(() => {}); await sbB.base.update().catch(() => {}); await sleep(500) }
86 }
87 const membersInView = await sbA.get('__members__').catch(() => null)
88 console.log('[repro] __members__ in creator view:', membersInView ? Object.keys(membersInView || {}).length : 'ABSENT', '| member admitted:', admitted)
89 t.comment('membersInView=' + JSON.stringify(membersInView) + ' membersProjected=' + sbA._membersProjected + ' writable=' + (sbA.base && sbA.base.writable))
90
91 // This is the DIAGNOSIS assertion — record the actual behavior. If admitted=true the timer path
92 // suffices (bug is elsewhere); if false, the connection-hook was load-bearing and multi-DID needs
93 // the daemon to drive _ensureMembersProjected.
94 t.ok(true, 'diagnostic recorded: admitted=' + admitted + ' __members__=' + (membersInView ? 'present' : 'ABSENT'))
95})