OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-room commits / 74af5e0

0.2.33: a late presence attach still pairs (fixes a split signer election)
Protomux rejects a remote open that finds no local channel and no pair() handler. loser-club on stortron boots late (resync side store): tabertron's presence open was rejected, its channel closed, and stortron's later open was rejected in turn - both attached, both received 0 (presenceDiag), both elected themselves: two signers. attachPresence now registers mux.pair() and (re)opens its channel on demand; a channel that closes is dropped so a later peer open pairs again. Test reproduces production (peer stream already muxed) red, then green; units + bare 83/83. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
74af5e0103341c2cc163856dbc21ea600b9da7bb
by plankatron <fleeky@gmail.com> · 2026-10-14T18:35:13.000Z

Changed files (3) +81 -17

lib/signer-presence-channel.js +53 -16
……@@ -35,26 +35,63 @@ export function attachPresence (conn, { selfKey, presence, id = null, getHead =
3535 const self = String(selfKey)
3636 let peerKey = null
3737 let done = false
38 let chan = null
39 let msg = null
3840 const clear = () => { if (peerKey) presence.markAbsent(peerKey) }
41 const announce = (headRev) => {
42 if (!msg) return
43 try { msg.send({ key: self, headRev: headRev !== undefined ? (headRev || null) : (getHead ? getHead() : null) }) } catch {}
44 }
3945 try {
40 const chan = Protomux.from(conn).createChannel({ protocol: PRESENCE_PROTOCOL, id })
41 if (!chan) return noop // duplicate (protocol,id) on this mux → harmless no-op
42 const msg = chan.addMessage({
43 encoding: c.json,
44 onmessage: (data) => {
45 if (!data || !data.key) return
46 peerKey = String(data.key)
47 if (onReceive) { try { onReceive(peerKey) } catch {} }
48 presence.markPresent(peerKey) // presence (fires onChange on connect)
49 if (data.headRev !== undefined) presence.noteHead(peerKey, data.headRev || null) // currency (fires onChange on a head change)
50 }
51 })
52 chan.open()
53 const announce = (headRev) => { try { msg.send({ key: self, headRev: headRev !== undefined ? (headRev || null) : (getHead ? getHead() : null) }) } catch {} }
54 announce() // initial announce with our current head
46 const mux = Protomux.from(conn)
47 // ⛔ A LATE ATTACH MUST STILL PAIR (2026-10-14, loser-club). Protomux REJECTS a remote open that finds no local
48 // channel and no pair() handler (_requestSession → _rejectSession), and the rejected side's channel closes.
49 // A DID that boots after the connection is up (a resync side store) attached after its peer had already
50 // opened: the peer's open was rejected, then ours hit a peer with no channel and no handler and was rejected
51 // too — both attached, both received nothing, both elected themselves. So the channel is (re)opened on
52 // demand: once now, and again from pair() whenever the peer opens while we have no live channel.
53 const openChannel = () => {
54 if (done) return false
55 if (chan) return true
56 const ch = mux.createChannel({
57 protocol: PRESENCE_PROTOCOL,
58 id,
59 onclose: () => {
60 if (chan !== ch) return
61 chan = null
62 msg = null
63 // A channel that closes with the connection still up (rejected, or the peer detached) loses the peer.
64 if (peerKey) { presence.markAbsent(peerKey); peerKey = null }
65 }
66 })
67 if (!ch) return false // duplicate (protocol,id) on this mux → another attach owns it
68 const m = ch.addMessage({
69 encoding: c.json,
70 onmessage: (data) => {
71 if (!data || !data.key) return
72 peerKey = String(data.key)
73 if (onReceive) { try { onReceive(peerKey) } catch {} }
74 presence.markPresent(peerKey) // presence (fires onChange on connect)
75 if (data.headRev !== undefined) presence.noteHead(peerKey, data.headRev || null) // currency (fires onChange on a head change)
76 }
77 })
78 chan = ch
79 msg = m
80 ch.open()
81 announce() // initial announce with our current head
82 return true
83 }
84 if (!openChannel()) return noop // duplicate → harmless no-op (the first attach owns pairing too)
85 mux.pair({ protocol: PRESENCE_PROTOCOL, id }, () => { openChannel() })
5586 const onclose = () => { if (done) return; done = true; clear() }
5687 conn.on('close', onclose)
57 const detach = () => { if (done) return; done = true; try { conn.off('close', onclose) } catch {}; clear() }
88 const detach = () => {
89 if (done) return
90 done = true
91 try { mux.unpair({ protocol: PRESENCE_PROTOCOL, id }) } catch {}
92 try { conn.off('close', onclose) } catch {}
93 clear()
94 }
5895 detach.send = (headRev) => announce(headRev) // re-announce our head (currency) when it changes
5996 detach.attached = true
6097 return detach
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-room",
3 "version": "0.2.32",
3 "version": "0.2.33",
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/signer-presence-channel.test.mjs +27 -0
……@@ -63,3 +63,30 @@ test('attach reports whether it got the channel; a duplicate (protocol, id) repo
6363 t.is(other.attached, true)
6464 t.ok(await waitFor(() => gotA.includes(B)), 'the peer\'s announce is reported to onReceive')
6565})
66
67// ⛔ A LATE ATTACH MUST STILL PAIR. 2026-10-14, loser-club: tabertron attached presence when the connection opened;
68// stortron's loser-club booted LATER (resync side store) and attached after. Protomux rejects a remote open that
69// has no local channel and no pair() handler (_requestSession → _rejectSession), so tabertron's channel closed;
70// stortron's later open then hit a peer with no channel and no handler and was rejected too. Both counted
71// `attached`, both received 0, both elected themselves → two signers. The first side must accept a later open.
72test('late attach: the side that attached first accepts the peer\'s later open (rejected first open is recovered)', async (t) => {
73 const spA = new SignerPresence({ self: A })
74 const spB = new SignerPresence({ self: B })
75 const a = new SecretStream(true)
76 const b = new SecretStream(false)
77 a.rawStream.pipe(b.rawStream).pipe(a.rawStream)
78 const id = Buffer.alloc(32, 9)
79 // As in production: B's stream ALREADY carries a mux (replication + another DID's presence — wj62), so A's
80 // open for this DID is processed on arrival and, with no channel and no pair handler there, rejected.
81 const otherA = attachPresence(a, { selfKey: A, presence: new SignerPresence({ self: A }), id: Buffer.alloc(32, 8) })
82 const otherB = attachPresence(b, { selfKey: B, presence: new SignerPresence({ self: B }), id: Buffer.alloc(32, 8) })
83 t.teardown(() => { otherA(); otherB() })
84 await sleep(200)
85 const detachA = attachPresence(a, { selfKey: A, presence: spA, id })
86 await sleep(300) // B has NO channel and NO pair handler for this id yet → B's mux rejects A's open
87 const detachB = attachPresence(b, { selfKey: B, presence: spB, id }) // B boots late
88 t.teardown(() => { detachA(); detachB(); try { a.destroy() } catch {} try { b.destroy() } catch {} })
89 t.ok(await waitFor(() => spA.effective().includes(B) && spB.effective().includes(A)), 'both see each other after a late attach')
90 t.is(spA.currentSigner(), A)
91 t.is(spB.currentSigner(), A, 'and agree on one signer')
92})