OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-did-peer commits / f9e1e78

0.2.6: the co-owner SET collapsed to ONE aliased front (freshest-by-rev had no candidates)
DidPeerRouter._ensureFront reused the primary DidPeer for a second co-owner whenever `peer.holds` was false. `holds` is `!!seq`, which flips true only once _openCores COMPLETES — but `_firehoseKey` is assigned synchronously inside provide(). Two co-owners announcing at once (the normal case: both join the DID topic together) therefore both passed the guard, the second reused the primary, and its provide() returned the FIRST co-owner's in-flight open. Result: _fronts held N keys aliasing ONE DidPeer bound to one co-owner. The co-owner SET silently collapsed to a single front, so freshest-by-rev had nothing to choose between and WHICH co-owner got served was a coin flip. It also survives forever, because _ensureFront returns the existing (aliased) entry on every later announce. Guard on BINDING instead. There is no await between the check and provide(), so reading _firehoseKey there is race-free. test/co-owned.test.mjs was flaking on exactly this (~3 runs in 4 served the BEHIND co-owner for the full 12s poll) and could not catch it: it asserted diagnose().cachedFronts === 2, which counts map ENTRIES, not distinct fronts. Added test/coowner-set.test.mjs, which asserts the real invariant — N announced co-owners => N DISTINCT DidPeers, each bound to the firehose key it is filed under — with three co-owners, since two aliased onto the freshest half the time and looked correct. It derives the expected winner from the holders' actual head revs (a shared-base injected clock can put two co-owners on the same TID timestamp, ordered only by trailing clock-id). Verified: probe reproduced the aliasing 4/4 before, 0/3 after; co-owned.test.mjs 5/5 green (was ~1/4); full suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f9e1e78b78c9a3052466f0373868159ee4d82d78
by plankatron <fleeky@gmail.com> · 2026-07-28T14:05:36.000Z

Changed files (3) +125 -4

lib/did-peer.js +11 -2
……@@ -580,8 +580,17 @@ export class DidPeerRouter {
580580 const verifyKey = await this._verifyKey(did)
581581 const o = this._opts.get(did) || {}
582582 let peer = this._peers.get(did)
583 if (peer && peer.holds) {
584 const boundFh = peer._firehoseKey ? b4a.toString(peer._firehoseKey, 'hex') : null
583 // Reuse the primary only while it is UNBOUND. Test BINDING (_firehoseKey), not `holds`: `holds`
584 // (=> !!seq) flips true only once _openCores COMPLETES, whereas _firehoseKey is assigned
585 // synchronously inside provide(). A second co-owner announcing while the primary's FIRST provide()
586 // was still in flight therefore read holds=false, reused the primary, and its provide() returned the
587 // first co-owner's in-flight open — so _fronts held two keys ALIASING ONE DidPeer bound to one
588 // co-owner. The co-owner SET silently collapsed to a single front, freshest-by-rev had nothing to
589 // choose between, and which co-owner got served was a coin flip (co-owned.test.mjs flaked on exactly
590 // that; diagnose() counted map ENTRIES so it still reported 2). There is no await between this check
591 // and provide() below, so reading _firehoseKey here is race-free.
592 if (peer && peer._firehoseKey) {
593 const boundFh = b4a.toString(peer._firehoseKey, 'hex')
585594 if (boundFh !== fhKeyHex) peer = null // primary already bound to a different co-owner -> new front
586595 }
587596 if (!peer) peer = new DidPeer(this.corestore, { did, verifyKey, handle: entry.coords.handle || o.handle || null, onHead: o.onHead || null, onError: o.onError || null, noRepo: o.noRepo })
package.json +2 -2
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-did-peer",
3 "version": "0.2.5",
3 "version": "0.2.6",
44 "description": "Keyless, direction-free P2P reader/seeder for an atproto DID's repo+firehose Hypercores: announce-push discovery + event-driven head reconciliation + a keyless read surface. The shared primitive beneath atproto-bridge, atproto-relay and favela (extracted from atproto-bridge did-peer.js).",
55 "type": "module",
66 "main": "index.js",
……@@ -8,7 +8,7 @@
88 ".": "./index.js"
99 },
1010 "scripts": {
11 "test": "node test/commitops.test.mjs && node test/router.test.mjs && node test/co-owned.test.mjs && node test/no-repo.test.mjs",
11 "test": "node test/commitops.test.mjs && node test/router.test.mjs && node test/co-owned.test.mjs && node test/coowner-set.test.mjs && node test/no-repo.test.mjs",
1212 "test:live": "node test/read-live.mjs"
1313 },
1414 "dependencies": {
test/coowner-set.test.mjs +112 -0
……@@ -0,0 +1,112 @@
1// Co-owner SET integrity — the invariant co-owned.test.mjs could NOT see.
2//
3// co-owned.test.mjs asserted `diagnose().cachedFronts === 2`, which counts MAP ENTRIES. It stayed green
4// while both entries aliased ONE DidPeer: _ensureFront reused the primary for a second co-owner because
5// `peer.holds` (=> !!seq) is false until the primary's first provide() COMPLETES, so the second front's
6// provide() returned the FIRST co-owner's in-flight open. The co-owner SET collapsed to a single front,
7// freshest-by-rev had nothing to choose between, and which co-owner got served was a coin flip — which is
8// why co-owned.test.mjs flaked (~3 in 4 runs served the BEHIND co-owner forever).
9//
10// This test asserts the real invariant: N announced co-owners => N DISTINCT DidPeers, each bound to the
11// firehose key it is filed under. Run: node test/coowner-set.test.mjs
12import { mkdtempSync, rmSync } from 'node:fs'
13import { join } from 'node:path'
14import { tmpdir } from 'node:os'
15import createTestnet from '@hyperswarm/testnet'
16import Hyperswarm from 'hyperswarm'
17import b4a from 'b4a'
18import { RepoStore } from 'atproto-store/lib/repo-store.js'
19import { RepoManager } from 'atproto-store/lib/repo-manager.js'
20import { FirehoseSequencer } from 'atproto-store/lib/sequencer.js'
21import { openCorestore } from 'atproto-store/lib/corestore.js'
22import { createAtprotoIdentityFromKey } from 'atproto-identity/lib/atproto-identity.js'
23import { discoveryTopic } from 'atproto-identity/lib/discovery.js'
24import { DidPeer, DidPeerRouter } from '../index.js'
25
26const assert = (c, m) => { if (!c) { console.error('FAIL:', m); cleanupSync(); process.exit(1) } console.log(' ok -', m) }
27const iso = '2026-07-21T00:00:00.000Z'
28const dirs = []
29const toClose = []
30function newDir (p) { const d = mkdtempSync(join(tmpdir(), p)); dirs.push(d); return d }
31function cleanupSync () { for (const d of dirs) { try { rmSync(d, { recursive: true, force: true }) } catch {} } }
32const post = (rk, text) => ({ action: 'create', collection: 'app.bsky.feed.post', rkey: rk, record: { $type: 'app.bsky.feed.post', text, createdAt: iso, langs: ['en'] } })
33
34const id = createAtprotoIdentityFromKey(new Uint8Array(32).fill(7), { handle: 'coowned.test', pdsUrl: 'https://pds.local' })
35const DID = id.did
36
37const testnet = await createTestnet(3)
38const bootstrap = testnet.bootstrap
39
40// A co-owner: its own cores, nCommits signed over the SHARED DID key. All co-owners join the DID topic
41// AT ONCE, which is what puts their announces inside the primary's first in-flight provide().
42async function coOwner (nCommits) {
43 const store = openCorestore(newDir('coowner-'))
44 const repo = await new RepoStore(store, { name: `atproto-repo:${DID}` }).ready()
45 let micros = 1_700_000_000_000_000n
46 const mgr = await new RepoManager(repo, { did: DID, privateKey: id.privateKey, clock: () => (micros += 1_000_000n) }).init()
47 const seq = await new FirehoseSequencer(store.get({ name: 'firehose' })).ready()
48 for (let i = 0; i < nCommits; i++) { const w = await mgr.applyWrite(post('3post' + i + 'aaaaaa2', 'c' + i)); await seq.sequenceCommit(w, { time: iso }) }
49 const peer = new DidPeer(store, { did: DID, verifyKey: id.publicKey })
50 await peer.provide({ repoKey: repo.core.key, firehoseKey: seq.core.key })
51 const swarm = new Hyperswarm({ bootstrap })
52 swarm.on('connection', (c) => peer.attach(c))
53 await swarm.join(discoveryTopic(DID), { server: true, client: false }).flushed()
54 toClose.push(async () => { try { await swarm.destroy() } catch {}; try { await peer.close() } catch {}; try { await store.close() } catch {} })
55 return { peer, fhKey: seq.core.key.toString('hex'), nCommits, rev: peer.head.rev }
56}
57
58// THREE co-owners: with two, an aliasing bug still had a 50% chance of aliasing onto the freshest and
59// looking correct. The record counts also disambiguate WHICH co-owner is being served.
60const owners = [await coOwner(1), await coOwner(3), await coOwner(2)]
61const expectedKeys = new Set(owners.map((o) => o.fhKey))
62// Derive the expected winner from the holders' ACTUAL head revs rather than assuming "most commits wins".
63// The injected clock is shared-base, so two co-owners can land the same TID timestamp and be ordered by
64// the trailing clock-id — a fixture artifact, not a router property. Record counts differ per co-owner,
65// so the served count still identifies WHICH front was picked.
66const winner = owners.reduce((b, o) => (!b || o.rev > b.rev ? o : b), null)
67console.log('co-owners:', owners.map((o) => `${o.fhKey.slice(0, 8)}(n=${o.nCommits} rev=${o.rev})`).join(' '))
68console.log('highest-rev co-owner:', winner.fhKey.slice(0, 8), `(n=${winner.nCommits})`)
69
70const store = openCorestore(newDir('reader-'))
71const swarm = new Hyperswarm({ bootstrap })
72const router = new DidPeerRouter(store)
73swarm.on('connection', (c) => router.onconnection(c))
74router.add(DID, { verifyKey: id.publicKey })
75await swarm.join(discoveryTopic(DID), { server: false, client: true }).flushed()
76
77// Let every co-owner announce and open, polling front() the way a real getRepo request does. A front
78// EXISTING is not the same as its head having replicated + verified, so poll for convergence rather than
79// breaking on front count (an early break reads a set that is complete but not yet reconciled).
80const counts = []
81for (let i = 0; i < 40; i++) {
82 const f = await router.front(DID).catch(() => null)
83 const recs = f ? await f.listRecords('app.bsky.feed.post').catch(() => []) : []
84 counts.push(recs.length)
85 await new Promise((r) => setTimeout(r, 300))
86}
87console.log(' served record counts:', JSON.stringify(counts))
88
89const set = router._fronts.get(DID) || new Map()
90assert(set.size === owners.length, `every announced co-owner has a front entry (${set.size}/${owners.length})`)
91
92// THE INVARIANT: distinct objects, each bound to the key it is filed under.
93const objects = new Set()
94let mismatched = null
95for (const [mapKey, f] of set) {
96 objects.add(f)
97 const boundFh = f._firehoseKey ? b4a.toString(f._firehoseKey, 'hex') : null
98 if (boundFh !== mapKey) mismatched = `${mapKey.slice(0, 8)} is bound to ${boundFh ? boundFh.slice(0, 8) : 'null'}`
99}
100assert(!mismatched, `each front is bound to its OWN firehose key (${mismatched || 'all match'})`)
101assert(objects.size === owners.length, `fronts are DISTINCT objects, not aliases (${objects.size}/${owners.length})`)
102assert([...set.keys()].every((k) => expectedKeys.has(k)), 'front keys are exactly the announced co-owners')
103
104// With a real SET to choose from, freshest-by-rev serves the highest-rev co-owner.
105assert(counts[counts.length - 1] === winner.nCommits,
106 `freshest-by-rev serves the highest-rev co-owner (${winner.nCommits} records), got ${counts[counts.length - 1]}`)
107
108console.log('\nCO-OWNER SET OK: N announced co-owners => N distinct fronts, each correctly bound; freshest served')
109await swarm.destroy(); await router.close(); await store.close()
110for (const close of toClose) await close()
111await testnet.destroy()
112cleanupSync()