| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "atproto-bridge", |
| 3 | "version": "0.9.52", | |
| 3 | "version": "0.9.53", | |
| 4 | 4 | "description": "Stateless keyless atproto MEMBRANE \u2014 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.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -178,6 +178,9 @@ export async function startMembrane (cfg, { log = defaultLog, bootstrap = null } |
| 178 | 178 | // ⛔ The timer is CLEARED by a stop step: `unref` alone let it outlive a stopped membrane, and a |
| 179 | 179 | // restart in the same process would then run two guards. |
| 180 | 180 | let seqGuardTimer = null |
| 181 | let seqGuardSoon = null | |
| 182 | // What the last pass saw and did, for `metrics`; and the last self-heal, for its rate limit. | |
| 183 | const guard = { last: null, lastAt: null, lastHealAt: 0, heals: 0, configured: !!(cfg.crawlRelay && cfg.crawlHostname) } | |
| 181 | 184 | if (cfg.crawlRelay && cfg.crawlHostname) { |
| 182 | 185 | const { runSeqGuard } = await import('./seq-guard.js') |
| 183 | 186 | const crawl = async () => { |
| … | … | @@ -191,9 +194,21 @@ export async function startMembrane (cfg, { log = defaultLog, bootstrap = null } |
| 191 | 194 | relayBase: cfg.crawlRelay, |
| 192 | 195 | hostname: cfg.crawlHostname, |
| 193 | 196 | requestCrawl: crawl, |
| 194 | log | |
| 197 | log, | |
| 198 | lastHealAt: guard.lastHealAt, | |
| 199 | now: Date.now() | |
| 200 | }).then((r) => { | |
| 201 | guard.last = r | |
| 202 | guard.lastAt = Date.now() | |
| 203 | if (r && r.healedAt) guard.lastHealAt = r.healedAt | |
| 204 | if (r && r.healed) guard.heals++ | |
| 195 | 205 | }).catch((e) => log({ msg: 'seq-guard pass failed', error: e && e.message })) |
| 196 | 206 | pass() // boot pass, backgrounded — must never delay listen or the readiness probe |
| 207 | // ⛔ A SECOND PASS SHORTLY AFTER BOOT. A restart drops the relay's subscribeRepos connection, and the | |
| 208 | // relay only marks us offline after its reconnects fail — after the boot pass has already seen | |
| 209 | // "active". Without this an OTA could leave bsky disconnected for a full interval. | |
| 210 | seqGuardSoon = setTimeout(pass, 2 * 60 * 1000) | |
| 211 | if (seqGuardSoon.unref) seqGuardSoon.unref() | |
| 197 | 212 | seqGuardTimer = setInterval(pass, 15 * 60 * 1000) |
| 198 | 213 | if (seqGuardTimer.unref) seqGuardTimer.unref() |
| 199 | 214 | } else { |
| … | … | @@ -256,10 +271,12 @@ export async function startMembrane (cfg, { log = defaultLog, bootstrap = null } |
| 256 | 271 | relay, |
| 257 | 272 | handlers: membraneHandlers({ peers }), |
| 258 | 273 | status: () => ({ ok: true, membrane: true, port, fronted: peers.list() }), |
| 274 | // The relay guard's last pass and heals, for `metrics`. Read-only. | |
| 275 | guard: () => ({ ...guard }), | |
| 259 | 276 | // The stop sequence, unchanged in order from pds-serve.js (control is the caller's, so it is not |
| 260 | 277 | // here), plus the seq-guard timer. |
| 261 | 278 | steps: [ |
| 262 | ['seq-guard', () => { if (seqGuardTimer) clearInterval(seqGuardTimer) }], | |
| 279 | ['seq-guard', () => { if (seqGuardTimer) clearInterval(seqGuardTimer); if (seqGuardSoon) clearTimeout(seqGuardSoon) }], | |
| 263 | 280 | ['peers', () => peers.close()], |
| 264 | 281 | ['relay', () => relay.close()], |
| 265 | 282 | ['swarm', () => swarm.destroy()], |
| … | … | @@ -57,6 +57,23 @@ export function decideLift ({ emitted, cursor, margin = 1 } = {}) { |
| 57 | 57 | return { lift: true, to: c + Math.max(1, Number(margin) || 1), reason: 'behind-relay-cursor', emitted: e, cursor: c } |
| 58 | 58 | } |
| 59 | 59 | |
| 60 | // ⛔ SELF-HEAL (2026-10-13). The arithmetic above is the condition for a LIFT, and it is right not to key | |
| 61 | // on `offline`. But "offline while caught up" is a second, separate failure: measured live, getHostStatus | |
| 62 | // said status "offline", seq 40262, our emitted seq at or above it — so decideLift said healthy and the | |
| 63 | // guard did nothing — and bsky's relay NEVER reconnects on its own once it has marked a host offline | |
| 64 | // (reference_bsky-relay-offline-requestcrawl). One requestCrawl returned it to "active" in under 10 s. | |
| 65 | // Every OTA restart drops the relay's subscribeRepos connection and can cause it. | |
| 66 | // | |
| 67 | // So: crawl when the relay says offline and the lift branch did not already crawl, at most once per | |
| 68 | // `minIntervalMs`, so a relay that stays offline for its own reasons is asked, not hammered. | |
| 69 | export const HEAL_INTERVAL_MS = 15 * 60 * 1000 | |
| 70 | export function decideHeal ({ relayStatus, lifted = false, lastHealAt = 0, now = Date.now(), minIntervalMs = HEAL_INTERVAL_MS } = {}) { | |
| 71 | if (relayStatus !== 'offline') return { heal: false, reason: 'relay-not-offline' } | |
| 72 | if (lifted) return { heal: false, reason: 'lift-already-crawled' } | |
| 73 | if (lastHealAt && now - lastHealAt < minIntervalMs) return { heal: false, reason: 'healed-recently' } | |
| 74 | return { heal: true, reason: 'offline-while-caught-up' } | |
| 75 | } | |
| 76 | ||
| 60 | 77 | export function hostStatusUrl (relayBase, hostname) { |
| 61 | 78 | return `${String(relayBase).replace(/\/$/, '')}/xrpc/com.atproto.sync.getHostStatus?hostname=${encodeURIComponent(hostname)}` |
| 62 | 79 | } |
| … | … | @@ -70,7 +87,10 @@ export async function runSeqGuard ({ |
| 70 | 87 | fetchImpl = null, |
| 71 | 88 | margin = 1, |
| 72 | 89 | requestCrawl = null, |
| 73 | log = null | |
| 90 | log = null, | |
| 91 | // Self-heal rate limit: the caller keeps the last heal time across passes and hands it back in. | |
| 92 | lastHealAt = 0, | |
| 93 | now = Date.now() | |
| 74 | 94 | } = {}) { |
| 75 | 95 | const emit = (o) => { if (log) { try { log(o) } catch {} } return o } |
| 76 | 96 | if (!relay || !relayBase || !hostname) return { ok: false, skipped: 'not-configured' } |
| … | … | @@ -89,7 +109,22 @@ export async function runSeqGuard ({ |
| 89 | 109 | |
| 90 | 110 | const emitted = relay.length |
| 91 | 111 | const d = decideLift({ emitted, cursor: status && status.seq, margin }) |
| 92 | if (!d.lift) return { ok: true, lifted: false, emitted, cursor: status && status.seq, relayStatus: status && status.status, reason: d.reason } | |
| 112 | if (!d.lift) { | |
| 113 | const out = { ok: true, lifted: false, emitted, cursor: status && status.seq, relayStatus: status && status.status, reason: d.reason } | |
| 114 | const h = requestCrawl ? decideHeal({ relayStatus: out.relayStatus, lifted: false, lastHealAt, now }) : { heal: false } | |
| 115 | if (!h.heal) return out | |
| 116 | // LOUD, same reason as the lift: this repairs a state no local surface can see. | |
| 117 | emit({ msg: 'seq-guard: relay has us OFFLINE while we are caught up — asking it to crawl', hostname, relayCursor: out.cursor, ourEmitted: emitted }) | |
| 118 | try { | |
| 119 | await requestCrawl() | |
| 120 | emit({ msg: 'seq-guard: requestCrawl sent (self-heal)', hostname }) | |
| 121 | return { ...out, healed: true, healedAt: now } | |
| 122 | } catch (e) { | |
| 123 | emit({ msg: 'seq-guard: self-heal requestCrawl failed', error: e && e.message }) | |
| 124 | // Counted as an attempt, so a failing crawl is retried at the interval rather than every pass. | |
| 125 | return { ...out, healed: false, healError: (e && e.message) || String(e), healedAt: now } | |
| 126 | } | |
| 127 | } | |
| 93 | 128 | |
| 94 | 129 | // LOUD: this is a real repair of a state nothing else reports. The 20-hour outage was invisible |
| 95 | 130 | // precisely because no line like this existed. |
| … | … | @@ -4,7 +4,7 @@ |
| 4 | 4 | // frames while bsky's relay sat at cursor 9834, so the relay could only ever ask for frames we cannot |
| 5 | 5 | // produce. Everything local reported health. |
| 6 | 6 | import test from 'brittle' |
| 7 | import { decideLift, hostStatusUrl, runSeqGuard } from '../lib/seq-guard.js' | |
| 7 | import { decideLift, decideHeal, hostStatusUrl, runSeqGuard } from '../lib/seq-guard.js' | |
| 8 | 8 | |
| 9 | 9 | test('decideLift: the real outage numbers', (t) => { |
| 10 | 10 | const d = decideLift({ emitted: 79, cursor: 9834 }) |
| … | … | @@ -110,3 +110,59 @@ test('runSeqGuard stays inert when unconfigured — outward-facing means opt-in' |
| 110 | 110 | const r = await runSeqGuard({ relay: fakeRelay(79) }) |
| 111 | 111 | t.is(r.skipped, 'not-configured') |
| 112 | 112 | }) |
| 113 | ||
| 114 | // ── self-heal: a relay that marked us OFFLINE while we are caught up (2026-10-13) ─────────────────── | |
| 115 | // | |
| 116 | // Measured live: getHostStatus said status "offline", seq 40262, while we were caught up — the guard's | |
| 117 | // arithmetic was healthy, so it did nothing, and bsky's relay would never have reconnected on its own | |
| 118 | // (reference_bsky-relay-offline-requestcrawl). One requestCrawl brought it to "active" in under 10 s. | |
| 119 | // So: offline + not behind → crawl, at most once per interval. | |
| 120 | ||
| 121 | test('decideHeal: offline while caught up → crawl', (t) => { | |
| 122 | const d = decideHeal({ relayStatus: 'offline', lifted: false, lastHealAt: 0, now: 1e9 }) | |
| 123 | t.ok(d.heal, 'offline and nothing else will reconnect it') | |
| 124 | }) | |
| 125 | ||
| 126 | test('decideHeal: active → nothing', (t) => { | |
| 127 | t.absent(decideHeal({ relayStatus: 'active', lifted: false, lastHealAt: 0, now: 1e9 }).heal) | |
| 128 | }) | |
| 129 | ||
| 130 | test('decideHeal: a lift already crawled → no second crawl in the same pass', (t) => { | |
| 131 | t.absent(decideHeal({ relayStatus: 'offline', lifted: true, lastHealAt: 0, now: 1e9 }).heal) | |
| 132 | }) | |
| 133 | ||
| 134 | test('decideHeal: at most once per interval — a relay that stays offline is not hammered', (t) => { | |
| 135 | const now = 1e9 | |
| 136 | t.absent(decideHeal({ relayStatus: 'offline', lifted: false, lastHealAt: now - 5 * 60e3, now }).heal, '5 min after the last heal: wait') | |
| 137 | t.ok(decideHeal({ relayStatus: 'offline', lifted: false, lastHealAt: now - 16 * 60e3, now }).heal, '16 min after: crawl again') | |
| 138 | }) | |
| 139 | ||
| 140 | test('runSeqGuard heals an offline-but-caught-up relay with ONE crawl, and reports it', async (t) => { | |
| 141 | const relay = fakeRelay(40300) | |
| 142 | let crawls = 0 | |
| 143 | const r = await runSeqGuard({ | |
| 144 | relay, | |
| 145 | relayBase: 'https://bsky.network', | |
| 146 | hostname: 'pds.7rnx.net', | |
| 147 | fetchImpl: fakeFetch({ hostname: 'pds.7rnx.net', seq: 40262, status: 'offline' }), | |
| 148 | requestCrawl: async () => { crawls++ }, | |
| 149 | lastHealAt: 0, | |
| 150 | now: 1e9 | |
| 151 | }) | |
| 152 | t.absent(r.lifted, 'caught up: nothing to lift') | |
| 153 | t.is(crawls, 1, 'one requestCrawl') | |
| 154 | t.ok(r.healed, 'reported as a heal') | |
| 155 | t.is(r.healedAt, 1e9, 'with the time, so the next pass can rate-limit') | |
| 156 | t.is(r.relayStatus, 'offline', 'and what it saw') | |
| 157 | t.is(relay._base, 0, 'the floor is untouched') | |
| 158 | }) | |
| 159 | ||
| 160 | test('runSeqGuard does not heal without a configured crawl (outward-facing stays opt-in)', async (t) => { | |
| 161 | const r = await runSeqGuard({ | |
| 162 | relay: fakeRelay(40300), | |
| 163 | relayBase: 'https://bsky.network', | |
| 164 | hostname: 'pds.7rnx.net', | |
| 165 | fetchImpl: fakeFetch({ seq: 40262, status: 'offline' }) | |
| 166 | }) | |
| 167 | t.absent(r.healed) | |
| 168 | }) |