OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-room commits / 74b57f2

0.2.17: projectMembers BEFORE ready() silently blocked admission forever
A roster projected before ready() was only STASHED — projectMembers early-returns on `!this._ready || !this.base || !this.base.writable`. The actual __members__ append then waited for whichever came first: a swarm connection, or a promote-timer tick up to promoteRetryMs later. Under network:false — the multi-DID daemon mode, where the daemon owns the swarm and drives replication — the connection hook that would drive it lives inside `if (this.swarm)` and NEVER RUNS, leaving only the timer. So __members__ landed late, a member whose optimistic doorbell arrived first was refused by _verifyAdmissionInApply, and nothing ever re-rang it. Admission silently never happened. WHY THIS WAS SO HARD TO SEE: afterwards every surface reads healthy — __members__ present, membersProjected true, roster loaded, creator writable — from the very first poll. The failure is invisible in the steady state, which is why it read as a replication or daemon-wiring fault. Fix: one awaited _ensureMembersProjected() right after `_ready = true`, so a retained roster is projected by ready() itself. It no-ops via the four guards already in _ensureMembersProjected (already projected / no roster / not ready / not writable), so it changes nothing for callers that do not pre-project. MEASURED, one variable at a time (test/members-projection-order.test.mjs): before fix after fix 1 DID, project BEFORE [false] [true] 2 DIDs, project BEFORE [false,true] [true,true] 1 DID, project AFTER [true] [true] 2 DIDs, project AFTER [true,true] [true,true] The 2-DID BEFORE row is what localised it: two DIDs sharing ONE store, ONE swarm, ONE wakeup and ONE connection handler, and only the FIRST failed — so nothing shared could be the cause. The regression test was verified to FAIL with the fix disabled (the after-ready control kept passing), so it discriminates on exactly this ordering and not on something incidental. Corrects the diagnosis in atproto-room's own admission-multidid repro, which blames the multi-DID connection hook and prescribes a device-node fix. The daemon wiring is provably fine — identical in all four cells above. The hook does exist; it simply cannot fire under network:false, and the right place to close that gap is this open path. atproto-room 54/54, 205/205. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
74b57f23496bfe06bbb62bfb39111ef01f9e91f2
by plankatron <fleeky@gmail.com> · 2026-07-29T01:07:14.000Z

Changed files (4) +158 -1

lib/state-base.js +18 -0
……@@ -266,6 +266,24 @@ export class StateBaseV3 {
266266 await this._quarantineAndReset()
267267 }
268268 this._ready = true
269 // PROJECT A ROSTER RETAINED BEFORE ready(). projectMembers() called before the base exists can only
270 // stash the roster (`if (!this._ready || !this.base || !this.base.writable) return false`), so the
271 // actual __members__ append waited for whichever came first: a swarm connection, or a promote-timer
272 // tick up to promoteRetryMs later.
273 //
274 // Under network:false — the multi-DID daemon mode — the connection hook that would drive this lives
275 // inside `if (this.swarm)` and NEVER RUNS, leaving only the timer. So __members__ landed late, and a
276 // member whose optimistic doorbell arrived first was refused by _verifyAdmissionInApply and never
277 // re-rang it: admission silently never happened, while __members__ and _membersProjected both read
278 // perfectly healthy afterwards — which is why this looked like a replication or daemon-wiring bug.
279 //
280 // Measured (test/members-projection-order.test.mjs): projectMembers BEFORE ready() left the member
281 // unadmitted; AFTER admitted immediately. Same wiring, same DID count — ordering was the ONLY
282 // variable. This makes the two equivalent, which is what callers already assume.
283 //
284 // Awaited so the roster is in the view before ready() returns, hence before any consumer can wire up
285 // connections. A no-op when nothing was retained, when not writable, or when already projected.
286 await this._ensureMembersProjected().catch(() => {})
269287 // One-time in-place re-adopt migration for the namespace rename: if this is the writable creator
270288 // and the (renamed) base is empty but a legacy-namespace base is present in the same store, copy
271289 // its durable entries across. Best-effort + awaited so the room is seeded before consumers read.
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-room",
3 "version": "0.2.16",
3 "version": "0.2.17",
44 "description": "Attestation-gated, encrypted multi-writer room engine — StateBase (keet-model admission) + Lease + signer + RepoBase + LabelBase/LabelRoom + serve-membrane. Extracted from hyper-proto-peer; consumed by the atproto peer (repo devices) and the co-authored labeler.",
55 "type": "module",
66 "main": "index.js",
test/all.js +1 -0
……@@ -16,6 +16,7 @@ import './admission-members-projection.test.mjs'
1616import './devices-projection.test.js'
1717import './phase2-nonblocking.test.js'
1818import './multi-did.test.js'
19import './members-projection-order.test.mjs'
1920import './non-indexer-default.test.js'
2021import './co-authored-labeler.test.js'
2122import './migration-readopt.test.js'
test/members-projection-order.test.mjs +138 -0
……@@ -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
23import '../lib/bare-globals.js'
24import test from 'brittle'
25import crypto from 'hypercore-crypto'
26import fs from 'bare-fs'
27import b4a from 'b4a'
28import Corestore from 'corestore'
29import Hyperswarm from 'hyperswarm'
30import ProtomuxWakeup from 'protomux-wakeup'
31import createTestnet from '@hyperswarm/testnet'
32import { StateBaseV3 } from '../lib/state-base.js'
33import { makeAttest, makeVerifyAttest, attestDeviceChain, deviceKeetKeyPair, rootIdentityPublicKey } from '../lib/membership.js'
34
35const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
36const 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]
42const tmp = () => { const d = '/tmp/hpp-projorder-' + Math.floor(Math.random() * 1e9); fs.mkdirSync(d, { recursive: true }); return d }
43const sleep = (ms) => new Promise(r => setTimeout(r, ms))
44
45async 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.
58function 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
68function 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.
79async 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
122test('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
126test('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
132test('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})