OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-did-peer commits / f2cfc34

0.2.7: front EVICTION — dropFront/allowFront/forgetDid + bounded age-out
A cached front lived for the life of the PROCESS. _onConnClose deliberately keeps it ("its cores keep replicating over any remaining/next conn") and _ensureFront returns the existing entry on every later announce, so a front serving a wrong head — a peer that stopped announcing, or one announcing a FORKED line whose rev sorts higher — could only be dropped by restarting the consumer. For atproto-bridge that means bouncing a production read surface and throwing away every OTHER fronted DID's warm replica. hyper-social#16 P0. Adds: - dropFront(did, { firehoseKey, forget }) — evict one co-owner (or all fronts of a DID). `forget` DENIES that firehose key; required whenever the reason to evict is what the front is SERVING, because peers re-announce on every append and would otherwise resurrect it milliseconds later. Re-points the primary at a survivor and releases the relay slot before closing. - allowFront(did, firehoseKey?) — the undo. An eviction with no undo is an outage waiting to happen. - forgetDid(did) — drop every cached front + resolved key so the next request re-fronts from scratch. Denies nothing; the DID stays fronted. - _ageOutFronts, run from the existing refresh timer: a front whose peer has been silent past staleFrontMs (default 5min) is dropped, but ONLY while a live VERIFIED sibling remains. The LAST front is never dropped, so age-out can never be the reason a DID becomes unservable. - diagnose(): per-front silentMs, the denied map, staleFrontMs, and distinctFronts (cachedFronts counts map ENTRIES — which is why the aliased set fixed in 0.2.6 read as healthy). Also fixes a reachability gap this exposed: _freshestFront documents a fallback to any verified front when no co-owner is announced, but front() blocked on _waitForDid first, so a read during a blip stalled the full timeout and then threw with a verified head sitting in cache. That fallback is what makes "never drop the last front" worth anything, so front() now serves the verified cache when nothing is identified. test/evict.test.mjs proves both paths over a real testnet (16 asserts): eviction survives the offender's re-announce, reads fail over with no restart, allowFront restores, a silent co-owner ages out, the last front never does. Full suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f2cfc347eb68d0fa56e951f304b616ca8c8a0dca
by plankatron <fleeky@gmail.com> · 2026-07-28T14:37:54.000Z

Changed files (3) +285 -6

lib/did-peer.js +130 -4
……@@ -417,7 +417,7 @@ export class DidPeer {
417417export class DidPeerRouter {
418418 constructor (corestore, {
419419 swarm = null, fronted = [], relay = null, resolveVerifyKey = null, rpc = false, join = null,
420 refreshMs = 10000, refreshTimeoutMs = 5000, handles = {}, onHandles = null
420 refreshMs = 10000, refreshTimeoutMs = 5000, staleFrontMs = 300000, handles = {}, onHandles = null
421421 } = {}) {
422422 this.corestore = corestore
423423 this.swarm = swarm
……@@ -427,6 +427,9 @@ export class DidPeerRouter {
427427 this._join = join
428428 this._refreshMs = refreshMs
429429 this._refreshTimeoutMs = refreshTimeoutMs
430 // Age-out ceiling: a front whose peer has not announced for this long is dropped — but ONLY while a
431 // live, verified sibling remains to serve the DID (see _ageOutFronts). 0 disables age-out.
432 this._staleFrontMs = staleFrontMs
430433
431434 this._peers = new Map() // did -> primary DidPeer (backward-compat add()/get())
432435 this._fronts = new Map() // did -> Map<fhKeyHex, DidPeer> (co-owned multi-front)
……@@ -437,6 +440,8 @@ export class DidPeerRouter {
437440 this._opening = new Map() // "did|fhKeyHex" -> in-flight open promise
438441 this._waiters = new Map() // did -> Set<fn> woken when ANY co-owner of that did lands
439442 this._lastAppend = new Map() // did -> ts of last firehose 'append' (event-driven liveness)
443 this._lastLive = new Map() // "did|fhKeyHex" -> ts of that co-owner's last announce (age-out input)
444 this._denied = new Map() // did -> Set<fhKeyHex> evicted by an operator; a re-announce must NOT resurrect
440445 this._relaySourceKey = new Map() // did -> firehoseKeyHex currently folded into the relay
441446 this._handleToDid = new Map(Object.entries(handles || {}))
442447 this._onHandles = onHandles
……@@ -538,6 +543,11 @@ export class DidPeerRouter {
538543 const fhKeyHex = a.firehoseKey
539544 if (a.handle) this._setHandle(a.handle, did)
540545 if (!this._isCared(did)) return
546 // An operator-EVICTED co-owner must stay evicted. Without this, dropFront({forget:true}) is undone by
547 // the offender's very next announce (peers re-announce on connect AND on every append), which is the
548 // difference between an eviction an operator can rely on and one that lasts milliseconds.
549 if (this._isDenied(did, fhKeyHex)) return
550 this._lastLive.set(did + '|' + fhKeyHex, Date.now())
541551 if (!this._byDid.has(did)) this._byDid.set(did, new Map())
542552 this._byDid.get(did).set(fhKeyHex, { conn, rpc, coords: a })
543553 const existing = this._fronts.get(did) && this._fronts.get(did).get(fhKeyHex)
……@@ -569,6 +579,7 @@ export class DidPeerRouter {
569579 // Open (once, dedup-guarded) the front for ONE co-owner (did, firehoseKey). Reuses the primary DidPeer
570580 // (from add()) for its FIRST co-owner; a co-owner with a different firehoseKey gets its own DidPeer.
571581 async _ensureFront (did, fhKeyHex) {
582 if (this._isDenied(did, fhKeyHex)) return null // operator-evicted; re-front only after allowFront()
572583 const set = this._fronts.get(did)
573584 if (set && set.has(fhKeyHex)) return set.get(fhKeyHex)
574585 const openKey = did + '|' + fhKeyHex
……@@ -640,7 +651,16 @@ export class DidPeerRouter {
640651 async front (did, { timeout } = {}) {
641652 if (!this._isCared(did)) throw new Error(`DidPeerRouter: ${did} is not a fronted DID`)
642653 this._joinDid(did)
643 if (!this._byDid.has(did) || this._byDid.get(did).size === 0) await this._waitForDid(did, timeout || 20000)
654 if (!this._byDid.has(did) || this._byDid.get(did).size === 0) {
655 // Every co-owner is momentarily away. _freshestFront already documents a fallback to any VERIFIED
656 // front for exactly this case, but it was unreachable from here: _waitForDid blocked first, so a read
657 // during a blip stalled for the full timeout and then threw, even though a verified head was cached.
658 // (It is also what makes age-out's "never drop the last front" worth anything.) Cores keep
659 // replicating over the next connection, so the cached head is the freshest thing that exists.
660 const cached = this._freshestFront(did)
661 if (cached) return cached
662 await this._waitForDid(did, timeout || 20000)
663 }
644664 const entries = this._byDid.get(did) || new Map()
645665 await Promise.all([...entries.keys()].map((fh) => this._ensureFront(did, fh).catch(() => null)))
646666 // A front is opened once provide() kicks off replication, but its head is only set once the firehose
……@@ -680,6 +700,7 @@ export class DidPeerRouter {
680700
681701 // A peer disconnected: drop its (did, firehoseKey) identified-connection entries so _onAnnounce re-runs
682702 // on reconnect. Do NOT close the cached front — its cores keep replicating over any remaining/next conn.
703 // (Keeping it is right for a transient reconnect and WRONG forever: see _ageOutFronts + dropFront.)
683704 _onConnClose (conn) {
684705 for (const [did, entries] of this._byDid) {
685706 for (const [fhKeyHex, entry] of entries) { if (entry.conn === conn) entries.delete(fhKeyHex) }
……@@ -687,6 +708,96 @@ export class DidPeerRouter {
687708 }
688709 }
689710
711 // ---- eviction ----------------------------------------------------------------------------------
712 // A cached front used to live for the life of the PROCESS: _onConnClose deliberately keeps it, and
713 // _ensureFront returns the existing entry on every later announce. So the only way to stop serving a
714 // wrong head (a peer that stopped announcing, or one announcing a FORKED line) was to restart the
715 // bridge — unacceptable for a production read surface. These are the surgical alternatives.
716
717 _isDenied (did, fhKeyHex) { const s = this._denied.get(did); return !!(s && s.has(fhKeyHex)) }
718
719 // Evict one co-owner's front (or every front of a DID with no firehoseKey). `forget: true` also
720 // DENIES that firehose key, so the peer's next announce cannot resurrect it — required whenever the
721 // reason for evicting is the content the front is serving, because the offender keeps announcing.
722 // Never denies when dropping ALL of a DID's fronts implicitly (that is forgetDid's job, and denying a
723 // whole DID would make it permanently unservable).
724 async dropFront (did, { firehoseKey = null, forget = false } = {}) {
725 const set = this._fronts.get(did)
726 const targets = firehoseKey ? [firehoseKey] : (set ? [...set.keys()] : [])
727 const dropped = []
728 for (const fhKeyHex of targets) {
729 const f = set && set.get(fhKeyHex)
730 if (set) set.delete(fhKeyHex)
731 const entries = this._byDid.get(did)
732 if (entries) { entries.delete(fhKeyHex); if (entries.size === 0) this._byDid.delete(did) }
733 this._lastLive.delete(did + '|' + fhKeyHex)
734 if (forget && firehoseKey) {
735 if (!this._denied.has(did)) this._denied.set(did, new Set())
736 this._denied.get(did).add(fhKeyHex)
737 }
738 // Stop folding an evicted front into the aggregate relay before it can emit another frame.
739 if (this._relaySourceKey.get(did) === fhKeyHex) {
740 this._relaySourceKey.delete(did)
741 if (this.relay) { try { this.relay.removeSource(fhKeyHex) } catch {} }
742 }
743 // Re-point the primary (get()/announce-held) at a surviving front before closing this one.
744 if (this._peers.get(did) === f) {
745 const survivor = set && set.size ? [...set.values()][0] : null
746 if (survivor) this._peers.set(did, survivor); else this._peers.delete(did)
747 }
748 if (f) { dropped.push(fhKeyHex); try { await f.close() } catch {} }
749 }
750 if (set && set.size === 0) this._fronts.delete(did)
751 // A surviving co-owner must take over the relay slot immediately, not at the next refresh tick.
752 this._pickRelaySource(did).catch(() => {})
753 return { did, dropped, forgotten: forget && firehoseKey ? [firehoseKey] : [] }
754 }
755
756 // Un-deny a previously forgotten front (omit firehoseKey to clear every denial for the DID) so the
757 // peer's next announce re-fronts it. An eviction with no undo is an outage waiting to happen.
758 allowFront (did, firehoseKey = null) {
759 const s = this._denied.get(did)
760 if (!s) return { did, allowed: [] }
761 const allowed = firehoseKey ? (s.delete(firehoseKey) ? [firehoseKey] : []) : [...s]
762 if (!firehoseKey) s.clear()
763 if (s.size === 0) this._denied.delete(did)
764 return { did, allowed }
765 }
766
767 // Forget EVERYTHING cached for a DID — every co-owner front, its identified connections and its
768 // resolved verify key — so the next request re-fronts from scratch. The DID stays allow-listed (it must
769 // still be servable) and nothing is denied: this is the "re-derive, don't restart" lever. To evict a
770 // specific misbehaving co-owner and keep it out, use dropFront({ firehoseKey, forget: true }).
771 async forgetDid (did) {
772 const r = await this.dropFront(did)
773 this._byDid.delete(did)
774 this._verifyKeys.delete(did)
775 this._lastAppend.delete(did)
776 this._relaySourceKey.delete(did)
777 return { did, dropped: r.dropped }
778 }
779
780 // Age out fronts whose peer has stopped announcing. Conservative by construction: a front is only
781 // dropped while a LIVE, VERIFIED sibling remains to serve the DID, so age-out can never be the reason a
782 // DID becomes unservable — the single-holder case (the overwhelmingly common one) is never touched.
783 async _ageOutFronts (did) {
784 if (!this._staleFrontMs) return []
785 const set = this._fronts.get(did)
786 if (!set || set.size < 2) return []
787 const live = this._byDid.get(did) || new Map()
788 const hasLiveVerified = [...set].some(([k, f]) => live.has(k) && f && f.head)
789 if (!hasLiveVerified) return []
790 const now = Date.now()
791 const stale = [...set.keys()].filter((k) => !live.has(k) && now - (this._lastLive.get(did + '|' + k) || now) > this._staleFrontMs)
792 const dropped = []
793 for (const k of stale) {
794 if (this._fronts.get(did) && this._fronts.get(did).size < 2) break // never drop the last front
795 const r = await this.dropFront(did, { firehoseKey: k })
796 dropped.push(...r.dropped)
797 }
798 return dropped
799 }
800
690801 // Fold the FRESHEST co-owner's firehose into the aggregate relay (single source per DID — two co-owners
691802 // are the SAME frames under different sourceIds, so folding both duplicates every #commit). On a source
692803 // change (fresher co-owner won, or the folded one stalled) swap removeSource->addSource + emit #sync.
……@@ -722,20 +833,25 @@ export class DidPeerRouter {
722833 // cores live-follow via the standing download; this just nudges a front that saw no 'append' for a window
723834 // and re-picks the relay source if the folded co-owner stalled while another advanced.
724835 async _refreshFronts () {
725 for (const [did, set] of this._fronts) {
836 for (const did of [...this._fronts.keys()]) { // snapshot: _ageOutFronts mutates _fronts
837 const set = this._fronts.get(did)
838 if (!set) continue
726839 for (const [, f] of set) {
727840 if (!f || !f.seq || !f.seq.core) continue
728841 if (Date.now() - (this._lastAppend.get(did) || 0) < this._refreshMs) continue
729842 try { await withTimeout(f.seq.core.update({ wait: true }), this._refreshTimeoutMs) } catch {}
730843 }
844 try { await this._ageOutFronts(did) } catch {}
731845 this._pickRelaySource(did).catch(() => {})
732846 }
733847 }
734848
735849 diagnose () {
736850 const fronts = []
851 const now = Date.now()
737852 for (const [did, set] of this._fronts) {
738853 for (const [fhKeyHex, f] of set) {
854 const live = (this._byDid.get(did) || new Map()).has(fhKeyHex)
739855 fronts.push({
740856 did,
741857 handle: f.handle || null,
……@@ -745,14 +861,24 @@ export class DidPeerRouter {
745861 rev: f.head ? f.head.rev : null,
746862 rpcConnected: !!(f.rpc && !f.rpc.closed),
747863 relaySource: this._relaySourceKey.get(did) === fhKeyHex,
748 live: (this._byDid.get(did) || new Map()).has(fhKeyHex)
864 live,
865 // How long this co-owner has been silent, and whether age-out would take it — so an operator can
866 // SEE an evictable front instead of inferring one.
867 silentMs: live ? 0 : now - (this._lastLive.get(did + '|' + fhKeyHex) || now)
749868 })
750869 }
751870 }
871 const denied = {}
872 for (const [did, s] of this._denied) denied[did] = [...s]
752873 return {
753874 fronted: [...this._fronted],
754875 cachedFronts: fronts.length,
876 // Distinct front OBJECTS. cachedFronts counts map entries, which is why an aliased set (one DidPeer
877 // filed under N keys) read as healthy right up to 0.2.6.
878 distinctFronts: new Set([].concat(...[...this._fronts.values()].map((s) => [...s.values()]))).size,
755879 fronts,
880 denied,
881 staleFrontMs: this._staleFrontMs,
756882 relay: this.relay ? { sources: this.relay.sourceCount ? this.relay.sourceCount() : null } : null
757883 }
758884 }
package.json +2 -2
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-did-peer",
3 "version": "0.2.6",
3 "version": "0.2.7",
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/coowner-set.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/evict.test.mjs && node test/no-repo.test.mjs",
1212 "test:live": "node test/read-live.mjs"
1313 },
1414 "dependencies": {
test/evict.test.mjs +153 -0
……@@ -0,0 +1,153 @@
1// Front EVICTION — the loser-club shape (hyper-social #16 P0).
2//
3// A cached front used to live for the life of the PROCESS: _onConnClose deliberately keeps it and
4// _ensureFront returns the existing entry on every later announce. So when a peer announced a FORKED line
5// whose rev sorted higher, the only way to stop serving that wrong head was to RESTART the bridge. This
6// asserts the two surgical alternatives:
7//
8// A. dropFront(did, { firehoseKey, forget: true }) — evict one co-owner and KEEP it out. Reads fall back
9// to the authoritative co-owner immediately, with no restart, and the offender's continued announces
10// (peers re-announce on every append) do not resurrect it. allowFront() puts it back.
11// B. age-out — a front whose peer stopped announcing is dropped by the refresh timer, but ONLY while a
12// live verified sibling remains. The LAST front is never dropped, so age-out can't cause an outage.
13//
14// Owns-swarm posture, exactly as atproto-bridge/pds/bin/pds-serve.js constructs the router.
15// Run: node test/evict.test.mjs
16import { mkdtempSync, rmSync } from 'node:fs'
17import { join as pjoin } from 'node:path'
18import { tmpdir } from 'node:os'
19import createTestnet from '@hyperswarm/testnet'
20import Hyperswarm from 'hyperswarm'
21import { RepoStore } from 'atproto-store/lib/repo-store.js'
22import { RepoManager } from 'atproto-store/lib/repo-manager.js'
23import { FirehoseSequencer } from 'atproto-store/lib/sequencer.js'
24import { openCorestore } from 'atproto-store/lib/corestore.js'
25import { createAtprotoIdentityFromKey } from 'atproto-identity/lib/atproto-identity.js'
26import { discoveryTopic } from 'atproto-identity/lib/discovery.js'
27import { DidPeer, DidPeerRouter } from '../index.js'
28
29const assert = (c, m) => { if (!c) { console.error('FAIL:', m); cleanupSync(); process.exit(1) } console.log(' ok -', m) }
30const iso = '2026-07-21T00:00:00.000Z'
31const dirs = []
32const toClose = []
33function newDir (p) { const d = mkdtempSync(pjoin(tmpdir(), p)); dirs.push(d); return d }
34function cleanupSync () { for (const d of dirs) { try { rmSync(d, { recursive: true, force: true }) } catch {} } }
35const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
36const post = (rk, text) => ({ action: 'create', collection: 'app.bsky.feed.post', rkey: rk, record: { $type: 'app.bsky.feed.post', text, createdAt: iso, langs: ['en'] } })
37
38const id = createAtprotoIdentityFromKey(new Uint8Array(32).fill(9), { handle: 'evict.test', pdsUrl: 'https://pds.local' })
39const DID = id.did
40
41const testnet = await createTestnet(3)
42const bootstrap = testnet.bootstrap
43
44// A co-owner holding its own cores over the SHARED DID key. `append` adds a commit, which makes the holder
45// re-announce (DidPeer wires _announceAll to the firehose 'append') — that is how we prove denial holds.
46async function coOwner (nCommits, tag) {
47 const store = openCorestore(newDir('evict-' + tag + '-'))
48 const repo = await new RepoStore(store, { name: `atproto-repo:${DID}` }).ready()
49 let micros = 1_700_000_000_000_000n + BigInt(nCommits) * 60_000_000n // distinct bases => unambiguous rev order
50 const mgr = await new RepoManager(repo, { did: DID, privateKey: id.privateKey, clock: () => (micros += 1_000_000n) }).init()
51 const seq = await new FirehoseSequencer(store.get({ name: 'firehose' })).ready()
52 let n = 0
53 const append = async () => { const w = await mgr.applyWrite(post('3ev' + tag + n++ + 'aaaaa2', 'c' + n)); await seq.sequenceCommit(w, { time: iso }) }
54 for (let i = 0; i < nCommits; i++) await append()
55 const peer = new DidPeer(store, { did: DID, verifyKey: id.publicKey })
56 await peer.provide({ repoKey: repo.core.key, firehoseKey: seq.core.key })
57 const swarm = new Hyperswarm({ bootstrap })
58 swarm.on('connection', (c) => peer.attach(c))
59 await swarm.join(discoveryTopic(DID), { server: true, client: false }).flushed()
60 toClose.push(async () => { try { await swarm.destroy() } catch {}; try { await peer.close() } catch {}; try { await store.close() } catch {} })
61 return { peer, swarm, append, fhKey: seq.core.key.toString('hex'), tag }
62}
63
64// Reader in the bridge's posture: the router owns the swarm, joins topics itself, runs the refresh timer.
65function reader (opts = {}) {
66 const store = openCorestore(newDir('reader-'))
67 const swarm = new Hyperswarm({ bootstrap })
68 const router = new DidPeerRouter(store, {
69 swarm,
70 join: (did) => { try { swarm.join(discoveryTopic(did), { server: false, client: true }); swarm.flush().catch(() => {}) } catch {} },
71 fronted: [DID],
72 resolveVerifyKey: async () => id.publicKey,
73 ...opts
74 })
75 return { router, store, swarm, close: async () => { try { await swarm.destroy() } catch {}; try { await router.close() } catch {}; try { await store.close() } catch {} } }
76}
77
78// Served record count (which co-owner is being served — they hold different numbers of records).
79async function served (router) {
80 const f = await router.front(DID).catch(() => null)
81 if (!f) return -1
82 return (await f.listRecords('app.bsky.feed.post').catch(() => [])).length
83}
84async function waitServed (router, want, ms = 15000) {
85 const end = Date.now() + ms
86 let last = -1
87 while (Date.now() < end) { last = await served(router); if (last === want) return last; await sleep(250) }
88 return last
89}
90const frontKeys = (router) => [...(router._fronts.get(DID) || new Map()).keys()]
91
92const behind = await coOwner(1, 'b') // authoritative-but-lower-rev stand-in
93const ahead = await coOwner(2, 'a') // the "forked line that sorts higher" stand-in
94console.log('behind fh', behind.fhKey.slice(0, 8), '(1 rec) | ahead fh', ahead.fhKey.slice(0, 8), '(2 rec)')
95
96// ---------------- A. operator eviction ----------------
97console.log('\nA. dropFront / allowFront (no restart)')
98const A = reader({ staleFrontMs: 0 }) // age-out off — isolate operator eviction
99assert(await waitServed(A.router, 2) === 2, 'the higher-rev co-owner is served (the wrong-head situation)')
100assert(frontKeys(A.router).length === 2, 'both co-owners are fronted')
101
102const drop = await A.router.dropFront(DID, { firehoseKey: ahead.fhKey, forget: true })
103assert(drop.dropped.includes(ahead.fhKey), 'dropFront reports the evicted firehose key')
104assert(!frontKeys(A.router).includes(ahead.fhKey), 'the evicted front is gone from the co-owner set')
105assert(await waitServed(A.router, 1) === 1, 'reads fall back to the other co-owner WITHOUT a restart')
106
107// The offender keeps announcing (a peer re-announces on every append). Denial must survive that.
108await ahead.append()
109await sleep(2000)
110assert(!frontKeys(A.router).includes(ahead.fhKey), 're-announce does NOT resurrect a forgotten front')
111assert(await served(A.router) === 1, 'still serving the surviving co-owner after the offender re-announced')
112
113const allow = A.router.allowFront(DID, ahead.fhKey)
114assert(allow.allowed.includes(ahead.fhKey), 'allowFront clears the denial')
115await ahead.append() // next announce may re-front now
116assert(await waitServed(A.router, 4) === 4, 'after allowFront the evicted co-owner is fronted again (4 records)')
117await A.close()
118
119// ---------------- B. age-out ----------------
120console.log('\nB. age-out of a front whose peer stopped announcing')
121const B = reader({ refreshMs: 500, refreshTimeoutMs: 500, staleFrontMs: 1500 })
122const okBoth = await (async () => {
123 const end = Date.now() + 20000
124 while (Date.now() < end) { await served(B.router); if (frontKeys(B.router).length === 2) return true; await sleep(250) }
125 return false
126})()
127assert(okBoth, 'both co-owners fronted before the age-out window')
128
129await behind.swarm.destroy() // behind stops announcing (its connection closes -> no longer live)
130const gone = await (async () => {
131 const end = Date.now() + 20000
132 while (Date.now() < end) { if (!frontKeys(B.router).includes(behind.fhKey)) return true; await sleep(250) }
133 return false
134})()
135assert(gone, 'the silent co-owner ages out')
136assert(frontKeys(B.router).includes(ahead.fhKey), 'the live co-owner is kept')
137
138// The remaining front goes silent too. It must NOT be dropped — age-out may never make a DID unservable.
139await ahead.swarm.destroy()
140await sleep(5000) // >> staleFrontMs (1500) + several refresh ticks
141assert(frontKeys(B.router).includes(ahead.fhKey), 'the LAST front is never aged out (no self-inflicted outage)')
142// ...and the kept front is actually REACHABLE with every co-owner away: front() serves the verified cache
143// instead of stalling on _waitForDid until it throws.
144const t0 = Date.now()
145const stillServed = await served(B.router)
146assert(stillServed >= 1, `the DID is still servable from the kept front (got ${stillServed})`)
147assert(Date.now() - t0 < 5000, 'serving from cache does not stall on the identified-connection wait')
148await B.close()
149
150console.log('\nEVICT OK: a wrong head is evictable in place; age-out is bounded by "never the last front"')
151for (const close of toClose) await close()
152await testnet.destroy()
153cleanupSync()