| … | … | @@ -0,0 +1,138 @@ |
| 1 | // projectMembers() BEFORE ready() must admit exactly like projectMembers() AFTER ready(). |
| 2 | // |
| 3 | // THE BUG. projectMembers called before the base exists can only stash the roster — it early-returns on |
| 4 | // `!this._ready || !this.base || !this.base.writable`. The actual __members__ append then waited for |
| 5 | // whichever came first: a swarm connection, or a promote-timer tick up to promoteRetryMs later. |
| 6 | // |
| 7 | // Under network:false (the multi-DID daemon mode, where the daemon owns one swarm/store/wakeup and drives |
| 8 | // replication) the connection hook that would drive it sits inside `if (this.swarm)` and NEVER RUNS. So |
| 9 | // __members__ landed late, a member whose optimistic doorbell arrived first was refused by |
| 10 | // _verifyAdmissionInApply, and nothing re-rang it. Admission silently never happened. |
| 11 | // |
| 12 | // WHY IT RESISTED DIAGNOSIS: afterwards everything reads healthy — __members__ present, membersProjected |
| 13 | // true, roster loaded, creator writable — from the very first poll. The failure is invisible in the |
| 14 | // steady state, so it looked like a replication or daemon-wiring fault, and the original repro's own |
| 15 | // header blamed device-node. It is neither: same wiring, same DID count, the ONLY variable was ordering. |
| 16 | // |
| 17 | // The two-DID before-ready case is the sharpest evidence and is asserted below: two DIDs sharing ONE |
| 18 | // store, ONE swarm, ONE wakeup and ONE connection handler, and only the FIRST failed. Nothing shared can |
| 19 | // explain that, which is what localises the bug to per-StateBase open-time state. |
| 20 | // |
| 21 | // Structure copied from multi-did.test.js so the baseline is genuinely known-good. |
| 22 | // Run: bare test/members-projection-order.test.mjs |
| 23 | import '../lib/bare-globals.js' |
| 24 | import test from 'brittle' |
| 25 | import crypto from 'hypercore-crypto' |
| 26 | import fs from 'bare-fs' |
| 27 | import b4a from 'b4a' |
| 28 | import Corestore from 'corestore' |
| 29 | import Hyperswarm from 'hyperswarm' |
| 30 | import ProtomuxWakeup from 'protomux-wakeup' |
| 31 | import createTestnet from '@hyperswarm/testnet' |
| 32 | import { StateBaseV3 } from '../lib/state-base.js' |
| 33 | import { makeAttest, makeVerifyAttest, attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey } from '../lib/membership.js' |
| 34 | |
| 35 | const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' |
| 36 | const MNEMS = [ |
| 37 | 'legal winner thank year wave sausage worth useful legal winner thank yellow', |
| 38 | 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong', |
| 39 | 'letter advice cage absurd amount doctor acoustic avoid letter advice cage above', |
| 40 | 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote' |
| 41 | ] |
| 42 | const tmp = () => { const d = '/tmp/hpp-projorder-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d } |
| 43 | const sleep = (ms) => new Promise(r => setTimeout(r, ms)) |
| 44 | |
| 45 | async function mkDid (rootPub, m0, m1) { |
| 46 | const cd = await deviceKeetKeyPair(m0); const cp = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: cd.publicKey }) |
| 47 | const md = await deviceKeetKeyPair(m1); const mp = await attestDeviceChain({ rootMnemonic: ROOT, devicePublicKey: md.publicKey }) |
| 48 | let creator = { dev: cd, proof: cp }; let member = { dev: md, proof: mp } |
| 49 | if (b4a.compare(creator.dev.publicKey, member.dev.publicKey) > 0) { const t = creator; creator = member; member = t } // creator = lowest key |
| 50 | const members = [ |
| 51 | { key: b4a.toString(creator.dev.publicKey, 'hex'), role: 1 }, |
| 52 | { key: b4a.toString(member.dev.publicKey, 'hex'), role: 1 } |
| 53 | ] |
| 54 | return { rootPub, creator, member, members, roomSeed: crypto.randomBytes(32), roomKey: crypto.randomBytes(32), encryptionKey: crypto.randomBytes(32) } |
| 55 | } |
| 56 | |
| 57 | // network:false — the daemon owns replication, so StateBaseV3's own connection hook never fires. |
| 58 | function mkSb (store, wakeup, did, who) { |
| 59 | const dev = did[who] |
| 60 | return new StateBaseV3(null, did.roomKey, { |
| 61 | store, wakeup, network: false, |
| 62 | roomSeed: did.roomSeed, encryptionKey: did.encryptionKey, |
| 63 | selfKey: dev.dev.publicKey, attest: makeAttest(dev.dev, dev.proof), verifyAttest: makeVerifyAttest(did.rootPub), |
| 64 | getMembers: async () => did.members |
| 65 | }) |
| 66 | } |
| 67 | |
| 68 | function wireDaemon (swarm, store, wakeup, sbs) { |
| 69 | swarm.on('connection', (conn) => { |
| 70 | conn.on('error', () => {}) |
| 71 | try { wakeup.addStream(conn) } catch {} |
| 72 | try { store.replicate(conn) } catch {} |
| 73 | }) |
| 74 | for (const sb of sbs) swarm.join(sb.base.discoveryKey, { server: true, client: true }) |
| 75 | } |
| 76 | |
| 77 | // Run the whole two-machine scenario, projecting the roster either side of ready(). Returns per-DID |
| 78 | // admission. `order` is the ONLY thing that varies. |
| 79 | async function run (t, { nDids, order }) { |
| 80 | const testnet = await createTestnet(3) |
| 81 | const rootPub = await rootIdentityPublicKey(ROOT) |
| 82 | const dids = [await mkDid(rootPub, MNEMS[0], MNEMS[1])] |
| 83 | if (nDids > 1) dids.push(await mkDid(rootPub, MNEMS[2], MNEMS[3])) |
| 84 | |
| 85 | const store1 = new Corestore(tmp()); await store1.ready() |
| 86 | const swarm1 = new Hyperswarm({ bootstrap: testnet.bootstrap }) |
| 87 | const wakeup1 = new ProtomuxWakeup() |
| 88 | const creators = [] |
| 89 | for (const d of dids) { |
| 90 | const sb = mkSb(store1, wakeup1, d, 'creator') |
| 91 | if (order === 'before') await sb.projectMembers(d.members) |
| 92 | await sb.ready() |
| 93 | if (order === 'after') await sb.projectMembers(d.members) |
| 94 | creators.push(sb) |
| 95 | } |
| 96 | wireDaemon(swarm1, store1, wakeup1, creators) |
| 97 | await swarm1.flush().catch(() => {}) |
| 98 | |
| 99 | const store2 = new Corestore(tmp()); await store2.ready() |
| 100 | const swarm2 = new Hyperswarm({ bootstrap: testnet.bootstrap }) |
| 101 | const wakeup2 = new ProtomuxWakeup() |
| 102 | const members = [] |
| 103 | for (const d of dids) { const sb = mkSb(store2, wakeup2, d, 'member'); await sb.ready(); members.push(sb) } |
| 104 | wireDaemon(swarm2, store2, wakeup2, members) |
| 105 | await swarm2.flush().catch(() => {}) |
| 106 | |
| 107 | t.teardown(async () => { |
| 108 | for (const sb of [...creators, ...members]) { try { await sb.close() } catch {} } |
| 109 | try { await swarm1.destroy(); await swarm2.destroy() } catch {} |
| 110 | try { await store1.close(); await store2.close() } catch {} |
| 111 | await testnet.destroy() |
| 112 | }) |
| 113 | |
| 114 | // ONE optimistic write per member — the encrypted doorbell. Deliberately not retried: the bug is that |
| 115 | // a single doorbell arriving before __members__ is refused and never re-rung, so retrying here would |
| 116 | // mask it. |
| 117 | for (let i = 0; i < members.length; i++) { try { await members[i].set('post/1', { i }) } catch {} } |
| 118 | await sleep(1500) |
| 119 | return members.map((sb) => !!(sb.base && sb.base.writable)) |
| 120 | } |
| 121 | |
| 122 | test('CONTROL: projectMembers AFTER ready() admits (the known-good ordering)', async (t) => { |
| 123 | t.alike(await run(t, { nDids: 1, order: 'after' }), [true], 'single DID admits') |
| 124 | }) |
| 125 | |
| 126 | test('projectMembers BEFORE ready() admits too — ordering must not decide admission', async (t) => { |
| 127 | // Before the fix this was [false]: the roster was stashed, __members__ landed only on a later |
| 128 | // promote-timer tick, and the member's doorbell had already been refused. |
| 129 | t.alike(await run(t, { nDids: 1, order: 'before' }), [true], 'a roster retained before ready() is projected by ready() itself') |
| 130 | }) |
| 131 | |
| 132 | test('two DIDs sharing one store/swarm/wakeup: BOTH admit regardless of ordering', async (t) => { |
| 133 | // The sharpest case. Before the fix this was [false, true] — identical wiring, identical connection |
| 134 | // handler, and only the FIRST DID failed, which is what proved the cause was per-StateBase open-time |
| 135 | // state rather than anything shared. |
| 136 | t.alike(await run(t, { nDids: 2, order: 'before' }), [true, true], 'neither DID is left behind') |
| 137 | t.alike(await run(t, { nDids: 2, order: 'after' }), [true, true], 'and the known-good ordering still works') |
| 138 | }) |