OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-bridge commits / 05d7455

0.9.27: freshness = latest commit REV, not firehose frame count
Prod diagnose exposed the flaw in 0.9.26: a co-owner's freshness was compared by firehose LENGTH, but two membranes can frame the SAME deterministic __commit__ log differently — an old membrane with MANY small incremental frames (83 frames @ rev 3mr436) out-"lengthed" the current membrane with a FEW batched frames (6 frames @ rev 3mr74lg), so the bridge served the stale head. _freshestFront now scores each front by its latest signed commit's REV (TID strings sort chronologically), preferring a live-announced co-owner, so the highest-rev membrane wins regardless of framing. front()/_pickRelaySource await it. Repro updated to order the two peers in real wall-clock time (RepoManager revs are wall-clock TIDs, so "ahead" must be built later). 21/21 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
05d7455ab120a02f023eefdac240f3064a23cf21
by plankatron <fleeky@gmail.com> · 2026-07-22T01:34:54.000Z

Changed files (3) +32 -20

package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-bridge",
3 "version": "0.9.26",
3 "version": "0.9.27",
44 "description": "Stateless keyless atproto MEMBRANE — fronts self-custody hyper-proto-peers to bluesky over HTTP/WS, holds no keys and never signs. Bare-runnable. Deploy root / flat closure over the identity+repo+store+pds packages.",
55 "type": "module",
66 "main": "index.js",
pds/lib/peer-router.js +24 -16
……@@ -96,23 +96,31 @@ export class PeerRouter {
9696 return this._verifyKeys.get(did)
9797 }
9898
99 // The freshest OPEN front for a did = the co-owner membrane with the longest replicated firehose.
100 // PREFER a front whose (did, firehoseKey) is CURRENTLY announced (live in _byDid): a peer that REBUILT
101 // its cores (or a stale membrane a daemon abandoned) leaves a frozen front behind whose OLD key is no
102 // longer announced — and it can tie the new core on raw length (both 1 commit), so a pure length pick
103 // would keep serving the dead one. Among live co-owners take the longest firehose; fall back to any open
104 // front only if none are currently announced (all co-owners momentarily disconnected → serve last-known).
105 _freshestFront (did) {
99 // The freshest OPEN front for a did = the co-owner membrane whose LATEST SIGNED COMMIT has the highest
100 // rev. Freshness is the commit REV, NOT the firehose frame count: two co-owners project the SAME
101 // deterministic __commit__ log but can FRAME it differently (many small incremental frames at an OLD
102 // head vs a few batched frames at a NEW head), so a frame-count pick can serve a stale membrane with
103 // more-but-older frames (the wj62 prod symptom: an 83-frame membrane frozen at rev 3mr436 out-"lengthed"
104 // the current 6-frame membrane at rev 3mr74lg). PREFER a front whose key is CURRENTLY announced (live in
105 // _byDid) — a rebuilt/abandoned membrane leaves a frozen front behind; fall back to any open front only
106 // if none are currently announced (all co-owners momentarily disconnected → serve last-known). Revs are
107 // TID strings (base32 lexicographically = chronologically sortable). Async: reads each front's head once.
108 async _freshestFront (did) {
106109 const set = this._fronts.get(did)
107110 if (!set || !set.size) return null
108111 const live = this._byDid.get(did) || new Map()
109 let best = null; let bestLive = null
112 const scored = []
110113 for (const [fhKeyHex, f] of set) {
111114 if (!f || !f.seq || !f.seq.core) continue
112 if (!best || f.seq.core.length > best.seq.core.length) best = f
113 if (live.has(fhKeyHex) && (!bestLive || f.seq.core.length > bestLive.seq.core.length)) bestLive = f
115 let rev = ''
116 try { const s = await f.latestSignedCommit(); if (s && s.rev) rev = s.rev } catch {}
117 scored.push({ f, rev, live: live.has(fhKeyHex) })
114118 }
115 return bestLive || best
119 if (!scored.length) return null
120 const pickMaxRev = (arr) => arr.reduce((b, x) => (!b || x.rev > b.rev ? x : b), null)
121 const liveOnes = scored.filter((x) => x.live)
122 const chosen = pickMaxRev(liveOnes) || pickMaxRev(scored)
123 return chosen ? chosen.f : null
116124 }
117125
118126 // Open (once) the front for ONE co-owner (did, firehoseKey) from its identified connection. Idempotent
……@@ -138,7 +146,7 @@ export class PeerRouter {
138146 }
139147 if (f.handle) this._setHandle(f.handle, did)
140148 const ws = this._waiters.get(did); if (ws) { this._waiters.delete(did); for (const fn of ws) fn() }
141 this._pickRelaySource(did) // fold the freshest into the aggregate (single source per did)
149 this._pickRelaySource(did).catch(() => {}) // fold the freshest into the aggregate (single source per did)
142150 return f
143151 })().catch(() => null) // a co-owner that doesn't verify / has no commit yet just yields null; others still front
144152 this._opening.set(openKey, p)
……@@ -157,7 +165,7 @@ export class PeerRouter {
157165 try { await withTimeout(f.seq.core.update({ wait: true }), this._refreshTimeoutMs); this._staleStrikes.set(did + '|' + fhKeyHex, 0) }
158166 catch { this._staleStrikes.set(did + '|' + fhKeyHex, (this._staleStrikes.get(did + '|' + fhKeyHex) || 0) + 1) }
159167 }
160 this._pickRelaySource(did) // failover: ensure the relay is folding the freshest co-owner
168 this._pickRelaySource(did).catch(() => {}) // failover: ensure the relay is folding the freshest co-owner
161169 }
162170 }
163171
……@@ -283,7 +291,7 @@ export class PeerRouter {
283291 }
284292 const entries = this._byDid.get(did) || new Map()
285293 await Promise.all([...entries.keys()].map((fhKeyHex) => this._ensureFront(did, fhKeyHex).catch(() => null)))
286 const best = this._freshestFront(did)
294 const best = await this._freshestFront(did)
287295 if (!best) throw new Error(`PeerRouter: no frontable co-owner for ${did}`)
288296 return best
289297 }
……@@ -292,9 +300,9 @@ export class PeerRouter {
292300 // SAME frames under different sourceIds, so folding both duplicates every #commit to bsky's relay). On a
293301 // source change (a fresher co-owner won, or the folded one stalled) swap removeSource→addSource + emit a
294302 // #sync so the relay re-baselines. Idempotent when the freshest is unchanged.
295 _pickRelaySource (did) {
303 async _pickRelaySource (did) {
296304 if (!this.relay) return
297 const best = this._freshestFront(did)
305 const best = await this._freshestFront(did)
298306 if (!best || !best.seq || !best.seq.core) return
299307 const key = b4a.toString(best.seq.core.key, 'hex')
300308 if (this._relaySourceKey.get(did) === key) return
pds/test/co-owned-front-thrash.test.js +7 -3
……@@ -26,7 +26,10 @@ const PRIV = new Uint8Array(32).fill(7)
2626const NOW = '2026-07-21T00:00:00.000Z'
2727const post = (rk, text) => ({ action: 'create', collection: 'app.bsky.feed.post', rkey: rk, record: { $type: 'app.bsky.feed.post', text, createdAt: NOW, langs: ['en'] } })
2828
29// Build a co-owner peer: its own corestore/cores, N signed commits over the shared DID key.
29// Build a co-owner peer: its own corestore/cores, N signed commits over the shared DID key. Commit revs
30// are wall-clock TIDs, so a peer built LATER has strictly higher revs — the caller creates the BEHIND peer
31// first and the AHEAD peer after a beat, modelling "ahead = a later head" (in prod the two co-owners share
32// the SAME deterministic log, so a lagging co-owner literally sits at an earlier commit with a lower rev).
3033async function coOwner (bootstrap, nCommits) {
3134 const dir = mkdtempSync(join(tmpdir(), 'coowner-'))
3235 const store = openCorestore(dir)
……@@ -43,8 +46,9 @@ async function coOwner (bootstrap, nCommits) {
4346
4447test('co-owned: bridge must not front the BEHIND co-owner (front stability under 2 membranes)', async (t) => {
4548 const testnet = await createTestnet(3)
46 const AHEAD = await coOwner(testnet.bootstrap, 2) // notary: 2 commits (the current head)
47 const BEHIND = await coOwner(testnet.bootstrap, 1) // co-owner lagging: 1 commit
49 const BEHIND = await coOwner(testnet.bootstrap, 1) // co-owner lagging at an EARLIER head (built first → lower revs)
50 await new Promise((r) => setTimeout(r, 1500)) // ensure AHEAD's wall-clock revs are strictly higher
51 const AHEAD = await coOwner(testnet.bootstrap, 2) // notary at a LATER head (higher revs, more records)
4852
4953 const routerStore = openCorestore(mkdtempSync(join(tmpdir(), 'coowned-router-')))
5054 const routerSwarm = new Hyperswarm({ bootstrap: testnet.bootstrap })