OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-bridge commits / 9643f51

atproto-bridge: PeerFront standing download — direction-free live-follow, delete the destroy-watchdog
Fixes the recurring peer<->membrane connection FLAP that degraded posting over time and only cleared on a bridge restart. Authoritative holepunch audit (grounded in hyperswarm 4.17 / hypercore 11.33 / corestore 7.11 source) pinned the root cause: PeerFront never issued a standing download, so its repo/firehose cores were never `downloading`. corestore `_shouldReplicate` (corestore/index.js:475-479) only re-attaches a core to a FRESH connection when `replicator.downloading` is true -- so after a flaky-link stall the reconnected stream never re-attached the core; recovery depended on the REMOTE re-sending its discovery key (a directionality dependency the project forbids), and the served head froze until a bridge restart. A clean testnet reconnect self-heals (why it never reproduced offline); a real flaky link does not. Fix (copies the repo's own audited did-peer.js primitive verbatim): PeerFront.fromEntry issues a standing core.download({start:0,end:-1}) re-pulled on 'append' for both cores -> downloading:true -> cores re-attach direction-free on every reconnect + actively pull, event-driven. Satisfies no-directionality / event-driven / rip-out-polling-timers (feedback_p2p-no-directionality-event-driven). Deletes the watchdog's f._conn.destroy() escalation -- an anti-pattern: it fought hyperswarm's own 5s keepalive + topic-rejoin reconnect (force-destroying an INBOUND conn is worse: hyperswarm doesn't auto- requeue those), manufactured the churn behind the ~87-fd accumulation, and masked the missing download. _refreshFronts is now a pull-only staleness backstop that never destroys. Tests: new peer-restart-durability.test.js (cores enter downloading + live-follow across a peer restart); the two old watchdog tests rewritten to the nudge-never-destroy contract. 45/45 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
9643f515e7b8667620aec5f74e8f21c88c981cf2
by plankatron <fleeky@gmail.com> · 2026-07-18T17:43:22.000Z

Changed files (6) +157 -54

package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-bridge",
3 "version": "0.9.23",
3 "version": "0.9.24",
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-front.js +12 -0
……@@ -69,6 +69,18 @@ export class PeerFront {
6969 front.repo = await new RepoStore(corestore, { key: front.repoKey }).ready()
7070 front.seq = await new FirehoseSequencer(corestore.get({ key: front.firehoseKey })).ready()
7171
72 // DIRECTION-FREE LIVE-FOLLOW — the corrected primitive from did-peer.js (see
73 // memory/feedback_p2p-no-directionality-event-driven). Issue a STANDING full-range download on both
74 // cores and re-pull on 'append'. This flips `core.replicator.downloading = true`, which is the ONLY
75 // condition under which `corestore.replicate()` re-attaches a core to a FRESH connection after a flaky
76 // link drops (corestore `_shouldReplicate`). WITHOUT it, the front only recovered if the REMOTE re-sent
77 // its discovery key (a directionality dependency the project forbids) — so over a flaky link the served
78 // head froze until a bridge restart. WITH it, a reconnect (hyperswarm's own 5s keepalive + topic-rejoin)
79 // re-attaches the cores and the standing download actively pulls, event-driven — no watchdog needed to
80 // force-destroy connections (see PeerRouter._refreshFronts, now pull-only).
81 front._seed = (core) => { const pull = () => { try { core.download({ start: 0, end: -1 }) } catch {} }; pull(); core.on('append', pull) }
82 front._seed(front.repo.core); front._seed(front.seq.core)
83
7284 const signed = await front._waitSignedCommit(timeout)
7385 if (!signed || !verifyCommit(signed, verifyKey)) {
7486 await front.close() // closes only the cores this front opened; leaves the router's conn/rpc alone
pds/lib/peer-router.js +21 -24
……@@ -46,16 +46,17 @@ export class PeerRouter {
4646 this._handleToDid = new Map(Object.entries(handles || {}))
4747 this._onHandles = onHandles // (obj) => void — persist the handle map when it grows
4848 this.relay = relay // optional FleetRelay: aggregates fronted peers' firehoses
49 // Freshness watchdog config (see _refreshFronts): the front's firehose core is a REMOTE replica;
50 // over a flaky P2P link a connection can go counted-but-stalled (swarm still lists it, but passive
51 // replication no longer delivers new blocks), freezing the served head until a bridge restart. The
52 // watchdog forces a round-trip pull and, on repeated failure, drops the stalled connection so
53 // hyperswarm re-establishes a FRESH one (proven to recover). Root cause: PeerRouter held one
54 // long-lived conn per peer and assumed passive replicate stays live across a degraded link.
49 // Stall-backstop config (see _refreshFronts): the front's cores are REMOTE replicas kept live by a
50 // STANDING download (PeerFront.fromEntry) that flips `downloading:true`, so `corestore.replicate`
51 // re-attaches them direction-free on every reconnect and the download actively pulls. This timer is
52 // ONLY a pull-only staleness ceiling — a bounded update({wait}) nudge when a front has seen no 'append'
53 // for a window. It does NOT destroy connections: that earlier watchdog fought hyperswarm's own 5s
54 // keepalive + topic-rejoin reconnect (and force-destroying an inbound conn is worse — no auto-requeue),
55 // and it masked the real bug (the missing standing download). Set refreshMs<=0 to disable (tests).
5556 this._refreshMs = refreshMs
5657 this._refreshTimeoutMs = refreshTimeoutMs
57 this._staleStrikeLimit = staleStrikeLimit
58 this._staleStrikes = new Map() // did -> consecutive failed refreshes (reset on success)
58 this._staleStrikeLimit = staleStrikeLimit // retained for back-compat; no longer drives a destroy
59 this._staleStrikes = new Map() // did -> consecutive failed nudges (diagnose() visibility only)
5960 this._lastAppend = new Map() // did -> ts of the firehose's last 'append' (event-driven liveness)
6061
6162 // The membrane is ONE edge fronting MANY DIDs (rooms) over ONE swarm — the keet model: keet keeps
……@@ -103,23 +104,19 @@ export class PeerRouter {
103104 async _refreshFronts () {
104105 for (const [did, f] of this._fronts) {
105106 if (!f || !f.seq || !f.seq.core) continue
106 // EVENT-INFORMED (Step 3): if the firehose appended within the last refresh window, the front is
107 // demonstrably live (passive replication is delivering) — SKIP the round-trip poll entirely.
108 // Only when there's been NO append (idle OR stalled) do we force update({wait:true}) to tell them
109 // apart + strike/drop a zombie connection. This replaces the blind per-tick data-poll with a stall
110 // BACKSTOP: events prove liveness, the timer only catches absence.
107 // EVENT-INFORMED: if the firehose appended within the last window the front is demonstrably live
108 // (PeerFront's standing per-core download is delivering) — skip the nudge entirely.
111109 if (Date.now() - (this._lastAppend.get(did) || 0) < this._refreshMs) { this._staleStrikes.set(did, 0); continue }
112 try {
113 await withTimeout(f.seq.core.update({ wait: true }), this._refreshTimeoutMs)
114 this._staleStrikes.set(did, 0)
115 } catch {
116 const n = (this._staleStrikes.get(did) || 0) + 1
117 this._staleStrikes.set(did, n)
118 if (n >= this._staleStrikeLimit && f._conn) {
119 try { f._conn.destroy() } catch {}
120 this._staleStrikes.set(did, 0) // reconnect will re-identify + re-replicate
121 }
122 }
110 // PULL-ONLY stall backstop (staleness ceiling): a bounded update({wait}) that pulls from whatever peer
111 // is currently attached. It NEVER destroys the connection. The old strike-then-`f._conn.destroy()`
112 // escalation was an anti-pattern: it fought hyperswarm's own 5s keepalive + topic-rejoin reconnect
113 // (and force-destroying an INBOUND connection is strictly worse — hyperswarm does not auto-requeue
114 // those), AND it masked the real bug — the front had no standing download, so `corestore.replicate`
115 // never re-attached the core to a reconnected stream (`_shouldReplicate` needs `downloading:true`).
116 // PeerFront.fromEntry's standing download now makes recovery direction-free + event-driven; this
117 // backstop only nudges a quiet-but-alive link. Strikes are tracked for `diagnose()` visibility only.
118 try { await withTimeout(f.seq.core.update({ wait: true }), this._refreshTimeoutMs); this._staleStrikes.set(did, 0) }
119 catch { this._staleStrikes.set(did, (this._staleStrikes.get(did) || 0) + 1) }
123120 }
124121 }
125122
pds/test/live-follow-repro.test.js +17 -17
……@@ -95,34 +95,34 @@ test('REPRO: front must live-follow a new commit AFTER the peer PROCESS restarts
9595 t.ok(followed, 'front live-follows a new commit AFTER the peer PROCESS restarts (corestore reopen)')
9696})
9797
98// Deterministic unit test of the freshness watchdog's RECOVERY path — the actual fix. A flaky-link
99// stall (counted-but-not-syncing connection) can't be reproduced over local testnet (a clean
100// reconnect self-heals — see the test above), so this drives `_refreshFronts` directly against a
101// front whose `update` always fails, and asserts the stalled connection is destroyed at the strike
102// limit (forcing hyperswarm to re-establish a FRESH connection — the proven recovery).
103test('watchdog destroys a stalled front connection at the strike limit (forces fresh reconnect)', async (t) => {
98// Deterministic unit test of the watchdog's PULL-ONLY backstop (post-audit). A flaky-link stall
99// (counted-but-not-syncing) can't be reproduced over local testnet (a clean reconnect self-heals — see
100// the test above). The REAL recovery is PeerFront's standing download (flips downloading:true → corestore
101// re-attaches direction-free on reconnect); the watchdog only NUDGES a stalled front with a bounded pull
102// and MUST NEVER destroy the connection (force-destroying fought hyperswarm's own keepalive+reconnect and
103// masked the missing download). This drives `_refreshFronts` directly and asserts the nudge-not-destroy.
104test('watchdog nudges a stalled front with a pull but NEVER destroys the connection', async (t) => {
104105 const fakeSwarm = { on () {}, off () {}, join () { return { flushed: async () => {} } }, flush: async () => {}, connections: new Set() }
105106 // refreshMs:0 disables the auto-timer so we drive _refreshFronts manually.
106107 const router = new PeerRouter(fakeSwarm, {}, { fronted: [], refreshMs: 0, staleStrikeLimit: 3 })
107108
108109 let stalledDestroyed = 0
109 const stalled = { seq: { core: { update: async () => { throw new Error('stalled link') } } }, _conn: { destroy: () => { stalledDestroyed++ } } }
110 let stalledPulls = 0
111 const stalled = { seq: { core: { update: async () => { stalledPulls++; throw new Error('stalled link') } } }, _conn: { destroy: () => { stalledDestroyed++ } } }
110112 let healthyDestroyed = 0
111113 const healthy = { seq: { core: { update: async () => {} } }, _conn: { destroy: () => { healthyDestroyed++ } } }
112114 router._fronts.set('did:plc:stalled', stalled)
113115 router._fronts.set('did:plc:healthy', healthy)
114116
115 await router._refreshFronts() // strike 1
116 await router._refreshFronts() // strike 2
117 t.is(stalledDestroyed, 0, 'not destroyed before the strike limit')
117 await router._refreshFronts() // pull 1
118 await router._refreshFronts() // pull 2
119 await router._refreshFronts() // pull 3 — the OLD strike-limit "destroy" point
120 await router._refreshFronts() // pull 4 — well past it
118121
119 await router._refreshFronts() // strike 3 → destroy
120 t.is(stalledDestroyed, 1, 'stalled connection destroyed at the strike limit')
121
122 await router._refreshFronts() // strikes reset after destroy → no immediate re-destroy
123 t.is(stalledDestroyed, 1, 'strikes reset after destroy (one destroy per stall window)')
124
125 t.is(healthyDestroyed, 0, 'a healthy front (update resolves) is never destroyed')
122 t.is(stalledPulls, 4, 'a stalled front is NUDGED with an update({wait}) pull each window')
123 t.is(stalledDestroyed, 0, 'stalled connection is NEVER destroyed — recovery is the standing download + hyperswarm reconnect')
124 t.ok((router._staleStrikes.get('did:plc:stalled') || 0) >= 1, 'failed pulls accrue strikes for diagnose() visibility only')
125 t.is(healthyDestroyed, 0, 'a healthy front (update resolves) is never destroyed either')
126126
127127 await router.close()
128128})
pds/test/peer-restart-durability.test.js +87 -0
……@@ -0,0 +1,87 @@
1// REPRO: does a fronted peer's RESTART (same keys, new swarm session) self-heal on the membrane, or does
2// it need a membrane restart? Mirrors peer-router.test.js scaffolding. Run: node test/zz-repro-peer-restart.mjs
3import test from 'brittle'
4import { mkdtempSync, rmSync } from 'node:fs'
5import { tmpdir } from 'node:os'
6import { join } from 'node:path'
7import crypto from 'hypercore-crypto'
8import Hyperswarm from 'hyperswarm'
9import createTestnet from '@hyperswarm/testnet'
10import { openCorestore } from 'atproto-store/lib/corestore.js'
11import { RepoStore } from 'atproto-store/lib/repo-store.js'
12import { RepoManager } from 'atproto-store/lib/repo-manager.js'
13import { FirehoseSequencer } from 'atproto-store/lib/sequencer.js'
14import { keyFromPrivate } from 'atproto-identity'
15import { discoveryTopic } from 'atproto-identity/lib/discovery.js'
16import { PeerRouter } from '../lib/peer-router.js'
17import { FleetRelay } from '../lib/fleet-relay.js'
18import { pushAnnounce } from './serve-announce.js'
19
20const DID = 'did:plc:restartme'
21const HANDLE = 'restart.7rnx.net'
22const PRIV = new Uint8Array(32).fill(9)
23const NOW = '2026-07-18T00:00:00.000Z'
24
25test('peer RESTART (same keys) — front self-heals, new commit replicates without a membrane restart', async (t) => {
26 const testnet = await createTestnet(3)
27 const peerDir = mkdtempSync(join(tmpdir(), 'restart-peer-'))
28 const peerStore = openCorestore(peerDir)
29 const repo = await new RepoStore(peerStore, { name: `atproto-repo:${DID}` }).ready()
30 let micros = 1_700_000_000_000_000n
31 const mgr = await new RepoManager(repo, { did: DID, privateKey: PRIV, clock: () => (micros += 1_000_000n) }).init()
32 const seq = await new FirehoseSequencer(peerStore.get({ name: 'firehose' })).ready()
33 const w1 = await mgr.applyWrite({ action: 'create', collection: 'app.bsky.feed.post', rkey: '3aaaaaaaaaaa2', record: { $type: 'app.bsky.feed.post', text: 'before-restart', createdAt: NOW, langs: ['en'] } })
34 await seq.sequenceCommit(w1, { time: NOW })
35
36 const peerKeyPair = crypto.keyPair() // STABLE swarm identity across the restart (mimics loadOrCreateSwarmKeyPair)
37 const servePeer = (conn) => { peerStore.replicate(conn); pushAnnounce(conn, { did: DID, handle: HANDLE, repo, seq }) }
38 let peerSwarm = new Hyperswarm({ bootstrap: testnet.bootstrap, keyPair: peerKeyPair })
39 peerSwarm.on('connection', servePeer)
40 await peerSwarm.join(discoveryTopic(DID), { server: true, client: false }).flushed()
41
42 const routerStore = openCorestore(mkdtempSync(join(tmpdir(), 'restart-router-')))
43 const routerSwarm = new Hyperswarm({ bootstrap: testnet.bootstrap })
44 const relay = await new FleetRelay(routerStore.get({ name: 'peer-relay-fh', valueEncoding: 'json' })).ready()
45 const router = new PeerRouter(routerSwarm, routerStore, {
46 fronted: [DID], resolveVerifyKey: async () => keyFromPrivate(PRIV).publicKey, relay,
47 refreshMs: 1000, refreshTimeoutMs: 800, staleStrikeLimit: 2 // fast watchdog so the test doesn't wait 30s
48 })
49
50 t.teardown(async () => {
51 await router.close(); await relay.close()
52 try { await peerSwarm.destroy() } catch {}
53 await routerSwarm.destroy()
54 await seq.close(); await repo.close(); await peerStore.close(); await routerStore.close()
55 await testnet.destroy(); rmSync(peerDir, { recursive: true, force: true })
56 })
57
58 // baseline
59 const front = await router.front(DID)
60 // FIX: the standing download must put both cores in `downloading` state — the one condition under which
61 // corestore.replicate re-attaches them to a FRESH connection after a flaky-link drop (direction-free).
62 t.ok(front.seq.core.replicator && front.seq.core.replicator.downloading, 'FIX: firehose core is downloading (standing download)')
63 t.ok(front.repo.core.replicator && front.repo.core.replicator.downloading, 'FIX: repo core is downloading (standing download)')
64 const r1 = await front.listRecords('app.bsky.feed.post')
65 t.is(r1.length, 1, 'baseline: front serves the pre-restart post')
66
67 // --- RESTART the peer: swarm dies (process restart), recreate SAME keypair + SAME storage, rejoin ---
68 await peerSwarm.destroy()
69 await new Promise(r => setTimeout(r, 500))
70 peerSwarm = new Hyperswarm({ bootstrap: testnet.bootstrap, keyPair: peerKeyPair })
71 peerSwarm.on('connection', servePeer)
72 await peerSwarm.join(discoveryTopic(DID), { server: true, client: false }).flushed()
73
74 // author a NEW commit AFTER the restart
75 const w2 = await mgr.applyWrite({ action: 'create', collection: 'app.bsky.feed.post', rkey: '3bbbbbbbbbbb2', record: { $type: 'app.bsky.feed.post', text: 'after-restart', createdAt: NOW, langs: ['en'] } })
76 await seq.sequenceCommit(w2, { time: NOW })
77
78 // ASSERT: the front replicates the new commit within ~12s, WITHOUT re-creating the router.
79 let got = 0
80 for (let i = 0; i < 60; i++) {
81 const recs = await front.listRecords('app.bsky.feed.post').catch(() => [])
82 got = recs.length
83 if (got >= 2) break
84 await new Promise(r => setTimeout(r, 200))
85 }
86 t.is(got, 2, 'DURABILITY: after a peer restart, the front replicates the new commit without a membrane restart')
87})
pds/test/watchdog-event-driven.test.js +19 -12
……@@ -1,8 +1,8 @@
1// Step 3: the stale-front watchdog is now EVENT-INFORMED. A firehose 'append' (block arriving via
2// passive replication) proves the front is live → the watchdog SKIPS the update({wait:true}) poll.
3// Only when there's been NO append (idle OR stalled) does it poll to distinguish idle-alive from a
4// zombie connection, and drop the conn at the strike limit. Events set the liveness floor; the timer
5// only catches ABSENCE (the audit's rule). This unit-drives _refreshFronts deterministically.
1// The stale-front watchdog is EVENT-INFORMED + PULL-ONLY. A firehose 'append' (a block arriving via the
2// standing download) proves the front is live → the watchdog SKIPS the update({wait:true}) poll. Only on
3// NO append does it nudge with a bounded pull. It NEVER destroys the connection — recovery is PeerFront's
4// standing download (re-attaches direction-free on reconnect) + hyperswarm's keepalive/topic-rejoin.
5// Events set the liveness floor; the timer only catches ABSENCE. This unit-drives _refreshFronts.
66import test from 'brittle'
77import { PeerRouter } from '../lib/peer-router.js'
88
……@@ -32,20 +32,27 @@ test('watchdog SKIPS the poll for a LIVE front (recent append) — event-informe
3232 t.absent(destroyed, 'live front connection is NOT dropped')
3333})
3434
35test('watchdog POLLS + drops a STALLED front (no append + poll times out) — safety net intact', async (t) => {
35test('watchdog NUDGES a stalled front but NEVER destroys the connection (pull-only backstop)', async (t) => {
36 // Post-audit contract: recovery is PeerFront's STANDING download (flips downloading:true → corestore
37 // re-attaches direction-free on reconnect) + hyperswarm's own keepalive/topic-rejoin. The watchdog is a
38 // pure staleness ceiling: on no-append it nudges with a bounded update({wait}) pull, and NEVER force-
39 // destroys the connection (that fought hyperswarm's connection management and masked the missing download).
3640 const router = mkRouter()
41 let updateCalls = 0
3742 let destroyed = false
3843 const stalled = {
39 seq: { core: { update: () => new Promise(() => {}) } }, // never resolves → withTimeout fires → strike
44 seq: { core: { update: () => { updateCalls++; return new Promise(() => {}) } } }, // never resolves → withTimeout fires
4045 _conn: { destroy: () => { destroyed = true } }
4146 }
4247 router._fronts.set('did:plc:stalled', stalled)
4348 router._lastAppend.set('did:plc:stalled', Date.now() - 10 * 60 * 1000) // no append for 10 min → suspect stall
4449
45 await router._refreshFronts() // strike 1 (poll times out)
46 t.is(router._staleStrikes.get('did:plc:stalled'), 1, 'stalled front (no append + poll timeout) accrues a strike')
47 t.absent(destroyed, 'not dropped before the strike limit')
50 await router._refreshFronts() // nudge 1 (poll times out)
51 t.is(updateCalls, 1, 'stalled front (no recent append) IS nudged with a bounded update({wait}) pull')
52 t.is(router._staleStrikes.get('did:plc:stalled'), 1, 'a failed nudge accrues a strike (diagnose visibility only)')
53 t.absent(destroyed, 'connection NOT destroyed after a failed nudge')
4854
49 await router._refreshFronts() // strike 2 == staleStrikeLimit → drop the zombie conn
50 t.ok(destroyed, 'stalled front connection DROPPED at the strike limit — stall detection preserved')
55 await router._refreshFronts() // nudge 2 — the OLD strike-limit "drop" point
56 await router._refreshFronts() // nudge 3 — well past the old limit
57 t.absent(destroyed, 'connection is NEVER force-destroyed — recovery is the standing download + hyperswarm reconnect')
5158})