OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-did-peer commits / decfb82

0.2.9: a front's application RPC is a CANDIDATE SET, not a slot won by the last announcer
Fronts are keyed by firehoseKey, but the key HOLDER and every keyless SEEDER announce the SAME core key. The router kept one {conn, rpc} per key, so whoever announced LAST won the application-RPC slot — and a seeder serves no application method, so the front's write/session/blob relay could sit pointed at a peer that answers `UNKNOWN_METHOD: Unknown method 'get-blob'` for everything. That is favela's getBlob failure and e2jev's intermittent images: the bytes were fine, the front was asking the wrong peer. favela-web's own relay already named the suspicion at bin/favela-web.js:252 ('front-has-no-rpc-channel'). - _byDid entries hold { coords, conns: Map<conn, rpc> } — every announcer of a (did, firehoseKey) is kept, not overwritten. _onConnClose drops only that connection's candidate, so one seeder leaving cannot un-identify the DID. - DidPeer keeps a candidate list. `rpc` becomes an accessor over it (reads the preferred live candidate, assignment adds one), so every existing caller — `if (!this.rpc)`, `peer.rpc = entry.rpc`, favela's `front.rpc` gate, diagnose's `f.rpc && !f.rpc.closed` — works unchanged. - _call() tries candidates until one ANSWERS, and is deliberate about which failures move on: UNKNOWN_METHOD / CHANNEL_CLOSED / CHANNEL_DESTROYED always; TIMEOUT_EXCEEDED for READS only, never for a write (a request that may have been applied must not be replayed against another peer). A REQUEST_ERROR is the peer answering with a real error and is surfaced, not masked by retrying elsewhere. The answering peer becomes sticky so later calls skip failed hops. Also fixes the propagation race this exposed: an announce arriving while the front is still OPENING takes the dedup path and that in-flight open had already captured its candidates, so the second announcer's rpc was silently dropped and the set stayed stuck at one. Each announce now adds its own rpc once the front resolves, and _ensureFront re-syncs from the entry after provide(). test/rpc-candidates.test.mjs: a real holder (serves get-blob) and a real seeder (same core keys, same RPC protocol, no application method) both announcing. getBlob is answered by the holder in BOTH candidate orders — proving order independence rather than getting lucky — a real error still surfaces as REQUEST_ERROR, and a method nobody serves still reports UNKNOWN_METHOD. did-peer 58 asserts green; bridge 45/45, 193/193. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
decfb82e1555270652b7715f6d8c667337ef3414
by plankatron <fleeky@gmail.com> · 2026-07-28T15:34:29.000Z

Changed files (3) +215 -19

lib/did-peer.js +83 -17
……@@ -65,7 +65,13 @@ export class DidPeer {
6565 // Optional write/session/blob RPC to the peer (peer-signs-over-RPC). Set by DidPeerRouter from the
6666 // per-connection ProtomuxRPC when the router runs with `{ rpc: true }`. Null for a read-only consumer
6767 // (favela) — the keyless read surface below needs no RPC.
68 this.rpc = null
68 //
69 // A SET, not a slot. A front is keyed by firehoseKey, but the key HOLDER and every keyless SEEDER
70 // announce the SAME core key, so a single slot was won by whoever announced last — and a seeder answers
71 // `UNKNOWN_METHOD: Unknown method 'get-blob'` because it serves no application RPC at all. That is the
72 // favela getBlob failure and e2jev's intermittent images. `rpc` stays a property (assignment still
73 // works, and consumers still read it) but is now backed by the candidate list.
74 this._rpcs = []
6975 this._repoKey = null
7076 this._firehoseKey = null
7177 this._channels = new Set()
……@@ -73,6 +79,44 @@ export class DidPeer {
7379 this._opening = null
7480 }
7581
82 // ---- application RPC candidates ----
83 // `rpc` reads as the preferred LIVE candidate and assigning to it adds one (or clears the set with null),
84 // so every existing caller — `if (!this.rpc) throw`, `peer.rpc = entry.rpc`, `f.rpc && !f.rpc.closed` —
85 // keeps working unchanged against a set.
86 get rpc () { const live = this._liveRpcs(); return live.length ? live[0] : null }
87 set rpc (v) { if (v) this.addRpc(v); else this._rpcs = [] }
88
89 addRpc (rpc) { if (rpc && !this._rpcs.includes(rpc)) this._rpcs.push(rpc) }
90 _liveRpcs () { this._rpcs = this._rpcs.filter((r) => r && !r.closed); return this._rpcs }
91
92 // Try each candidate until one ANSWERS. Only a "wrong peer" / "dead link" failure moves on:
93 // - UNKNOWN_METHOD the peer serves no such method (a seeder, not the holder) -> try the next
94 // - CHANNEL_CLOSED/DESTROYED the link died -> try the next
95 // - TIMEOUT_EXCEEDED ambiguous: retried for READS only, never for a write (a request that may have
96 // been applied must not be replayed against another peer)
97 // Anything else is the peer ANSWERING with a real error (REQUEST_ERROR: "blob not found",
98 // "unauthorized"); surfacing that beats masking it behind a retry.
99 async _call (method, params, { readOnly = false } = {}) {
100 const cands = this._liveRpcs()
101 if (!cands.length) throw new Error('DidPeer: no write RPC (read-only front)')
102 const nextOn = new Set(['UNKNOWN_METHOD', 'CHANNEL_CLOSED', 'CHANNEL_DESTROYED'])
103 if (readOnly) nextOn.add('TIMEOUT_EXCEEDED')
104 let last = null
105 for (let i = 0; i < cands.length; i++) {
106 const rpc = cands[i]
107 try {
108 const res = await rpc.request(method, params)
109 // Sticky: the peer that answered goes to the front so later calls do not re-pay the failed hops.
110 if (i > 0) { this._rpcs = [rpc, ...this._rpcs.filter((r) => r !== rpc)] }
111 return res
112 } catch (e) {
113 last = e
114 if (!nextOn.has(e && e.code)) throw e
115 }
116 }
117 throw last
118 }
119
76120 // This node HOLDS the cores (a signing device, a relay that already seeded, or a bootstrap). Open
77121 // them so we can serve + announce. Idempotent.
78122 async provide ({ repoKey, firehoseKey } = {}) { return this._openCores(repoKey, firehoseKey) }
……@@ -241,7 +285,7 @@ export class DidPeer {
241285 // the head we already verified off the firehose. A peer that serves a forged or rolled-back repo is
242286 // rejected exactly as before — we never serve bytes we have not authenticated.
243287 if (!this.rpc) throw new Error(`DidPeer: ${this.did} has no repo core and no RPC to derive one`)
244 const r = await this.rpc.request('get-repo-car', {})
288 const r = await this._call('get-repo-car', {}, { readOnly: true })
245289 if (!r || !r.car) throw new Error(`DidPeer: ${this.did} served no CAR`)
246290 const carBytes = b4a.from(r.car, 'hex')
247291 const signed = loadCommit(await readCar(carBytes)).value
……@@ -328,15 +372,15 @@ export class DidPeer {
328372 // ---- write / session / blob relay (peer-signs-over-RPC) — the PEER holds the key + applies + signs;
329373 // this consumer never holds a key. Requires the router to run with `{ rpc: true }` (this.rpc set). The
330374 // signed result flows back on the peer's firehose (which this DidPeer already replicates + reconciles).
331 applyWrite (op) { if (!this.rpc) throw new Error('DidPeer: no write RPC (read-only front)'); return this.rpc.request('apply-write', op) }
332 applyWrites (op) { if (!this.rpc) throw new Error('DidPeer: no write RPC (read-only front)'); return this.rpc.request('apply-writes', op) }
333 createSession ({ identifier, password } = {}) { if (!this.rpc) throw new Error('DidPeer: no write RPC (read-only front)'); return this.rpc.request('create-session', { identifier, password }) }
334 refreshSession (refreshJwt) { if (!this.rpc) throw new Error('DidPeer: no write RPC (read-only front)'); return this.rpc.request('refresh-session', { refreshJwt }) }
335 getServiceAuth ({ aud, lxm, token } = {}) { if (!this.rpc) throw new Error('DidPeer: no write RPC (read-only front)'); return this.rpc.request('get-service-auth', { aud, lxm, token }) }
336 getPreferences ({ token } = {}) { if (!this.rpc) throw new Error('DidPeer: no write RPC (read-only front)'); return this.rpc.request('get-preferences', { token }) }
337 putPreferences ({ preferences, token } = {}) { if (!this.rpc) throw new Error('DidPeer: no write RPC (read-only front)'); return this.rpc.request('put-preferences', { preferences, token }) }
338 getBlob (cid) { if (!this.rpc) throw new Error('DidPeer: no write RPC (read-only front)'); return this.rpc.request('get-blob', { cid }) }
339 uploadBlob ({ bytes, mimeType, token } = {}) { if (!this.rpc) throw new Error('DidPeer: no write RPC (read-only front)'); return this.rpc.request('upload-blob', { bytes, mimeType, token }) }
375 applyWrite (op) { return this._call('apply-write', op) }
376 applyWrites (op) { return this._call('apply-writes', op) }
377 createSession ({ identifier, password } = {}) { return this._call('create-session', { identifier, password }) }
378 refreshSession (refreshJwt) { return this._call('refresh-session', { refreshJwt }) }
379 getServiceAuth ({ aud, lxm, token } = {}) { return this._call('get-service-auth', { aud, lxm, token }, { readOnly: true }) }
380 getPreferences ({ token } = {}) { return this._call('get-preferences', { token }, { readOnly: true }) }
381 putPreferences ({ preferences, token } = {}) { return this._call('put-preferences', { preferences, token }) }
382 getBlob (cid) { return this._call('get-blob', { cid }, { readOnly: true }) }
383 uploadBlob ({ bytes, mimeType, token } = {}) { return this._call('upload-blob', { bytes, mimeType, token }) }
340384
341385 // Decode a #commit frame BODY (from firehose.decodeFrame) into normalized ops with the changed record
342386 // INLINE — read from the frame's OWN CAR (body.blocks) keyed by op.cid, so it is race-free (does NOT
……@@ -437,7 +481,8 @@ export class DidPeerRouter {
437481
438482 this._peers = new Map() // did -> primary DidPeer (backward-compat add()/get())
439483 this._fronts = new Map() // did -> Map<fhKeyHex, DidPeer> (co-owned multi-front)
440 this._byDid = new Map() // did -> Map<fhKeyHex, { conn, rpc, coords }> (identified connections)
484 this._byDid = new Map() // did -> Map<fhKeyHex, { coords, conns: Map<conn, rpc|null> }>
485 // MANY peers announce one (did, firehoseKey): the holder + every seeder
441486 this._opts = new Map() // did -> { verifyKey, handle, onHead, onError } from add()
442487 this._verifyKeys = new Map() // did -> Promise<pubkey>
443488 this._fronted = new Set(fronted)
……@@ -556,10 +601,17 @@ export class DidPeerRouter {
556601 if (this._isDenied(did, fhKeyHex)) return
557602 this._lastLive.set(did + '|' + fhKeyHex, Date.now())
558603 if (!this._byDid.has(did)) this._byDid.set(did, new Map())
559 this._byDid.get(did).set(fhKeyHex, { conn, rpc, coords: a })
604 // MANY peers announce the SAME (did, firehoseKey): the key holder AND every keyless seeder. Keep them
605 // ALL — a single {conn, rpc} slot was overwritten by whoever announced last, so the front's application
606 // RPC could end up pointed at a seeder that answers UNKNOWN_METHOD for every method.
607 const entries = this._byDid.get(did)
608 if (!entries.has(fhKeyHex)) entries.set(fhKeyHex, { coords: a, conns: new Map() })
609 const entry = entries.get(fhKeyHex)
610 entry.coords = a
611 entry.conns.set(conn, rpc || null)
560612 const existing = this._fronts.get(did) && this._fronts.get(did).get(fhKeyHex)
561613 if (existing) { // refresh write rpc + handle; nudge fetch (cores re-replicate via corestore regardless)
562 if (this._useRpc && rpc) existing.rpc = rpc
614 if (this._useRpc && rpc) existing.addRpc(rpc)
563615 if (a.handle) existing.handle = a.handle
564616 // The peer went STATELESS while we still hold a repo-core replica (it used to project, now it
565617 // announces repoKey:null). That replica is frozen at whatever it last replicated, so once the head
……@@ -580,7 +632,13 @@ export class DidPeerRouter {
580632 return
581633 }
582634 this._wake(did)
583 this._ensureFront(did, fhKeyHex).catch(() => {})
635 // Add THIS announcer's rpc once the front exists. An announce that arrives while the front is still
636 // OPENING takes the dedup path (it awaits the in-flight promise), and that open captured only the
637 // candidates present when it started — so without this the second announcer's rpc is silently dropped
638 // and the candidate set stays stuck at one, which is the single-slot bug wearing a different hat.
639 this._ensureFront(did, fhKeyHex)
640 .then((f) => { if (f && this._useRpc && rpc) f.addRpc(rpc) })
641 .catch(() => {})
584642 }
585643
586644 // Open (once, dedup-guarded) the front for ONE co-owner (did, firehoseKey). Reuses the primary DidPeer
……@@ -613,8 +671,11 @@ export class DidPeerRouter {
613671 }
614672 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 })
615673 if (entry.coords.handle) peer.handle = entry.coords.handle
616 if (this._useRpc && entry.rpc) peer.rpc = entry.rpc
674 // EVERY peer announcing this (did, firehoseKey) is a candidate; _call tries them until one answers.
675 if (this._useRpc) for (const r of entry.conns.values()) if (r) peer.addRpc(r)
617676 await peer.provide({ repoKey: entry.coords.repoKey, firehoseKey: entry.coords.firehoseKey })
677 // Re-sync: more peers may have announced this same (did, firehoseKey) during the open above.
678 if (this._useRpc) for (const r of entry.conns.values()) if (r) peer.addRpc(r)
618679 if (!this._fronts.has(did)) this._fronts.set(did, new Map())
619680 this._fronts.get(did).set(fhKeyHex, peer)
620681 if (!this._peers.has(did)) this._peers.set(did, peer) // first front becomes the primary (get())
……@@ -791,7 +852,12 @@ export class DidPeerRouter {
791852 // (Keeping it is right for a transient reconnect and WRONG forever: see _ageOutFronts + dropFront.)
792853 _onConnClose (conn) {
793854 for (const [did, entries] of this._byDid) {
794 for (const [fhKeyHex, entry] of entries) { if (entry.conn === conn) entries.delete(fhKeyHex) }
855 // Drop only THIS connection's candidate. A (did, firehoseKey) announced by several peers stays
856 // identified while any of them is still connected — one seeder leaving must not un-identify the DID.
857 for (const [fhKeyHex, entry] of entries) {
858 entry.conns.delete(conn)
859 if (entry.conns.size === 0) entries.delete(fhKeyHex)
860 }
795861 if (entries.size === 0) this._byDid.delete(did)
796862 }
797863 }
package.json +2 -2
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-did-peer",
3 "version": "0.2.8",
3 "version": "0.2.9",
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/evict.test.mjs && node test/fork.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/fork.test.mjs && node test/rpc-candidates.test.mjs && node test/no-repo.test.mjs",
1212 "test:live": "node test/read-live.mjs"
1313 },
1414 "dependencies": {
test/rpc-candidates.test.mjs +130 -0
……@@ -0,0 +1,130 @@
1// FRONT IDENTITY COLLIDES: the holder and every keyless seeder announce the SAME core key
2// (hyper-social#16 P0 item 3).
3//
4// A front is keyed by firehoseKey, and the router kept ONE {conn, rpc} per key — so whoever announced LAST
5// won the application-RPC slot. A seeder replicates the same cores and announces the same keys but serves
6// no application RPC, so the front's write/session/blob relay could be pointed at a peer that answers
7// `UNKNOWN_METHOD: Unknown method 'get-blob'` for everything. That is favela's getBlob failure and e2jev's
8// intermittent images: the bytes were fine, the front was asking the wrong peer.
9//
10// A front must hold a SET of candidates and try them until one ANSWERS — while still surfacing a real
11// error from a peer that DID answer, instead of masking it by retrying elsewhere.
12// Run: node test/rpc-candidates.test.mjs
13import { mkdtempSync, rmSync } from 'node:fs'
14import { join } from 'node:path'
15import { tmpdir } from 'node:os'
16import createTestnet from '@hyperswarm/testnet'
17import Hyperswarm from 'hyperswarm'
18import c from 'compact-encoding'
19import ProtomuxRPC from 'protomux-rpc'
20import { RepoStore } from 'atproto-store/lib/repo-store.js'
21import { RepoManager } from 'atproto-store/lib/repo-manager.js'
22import { FirehoseSequencer } from 'atproto-store/lib/sequencer.js'
23import { openCorestore } from 'atproto-store/lib/corestore.js'
24import { createAtprotoIdentityFromKey } from 'atproto-identity/lib/atproto-identity.js'
25import { discoveryTopic } from 'atproto-identity/lib/discovery.js'
26import { DidPeer, DidPeerRouter, RPC_PROTOCOL } from '../index.js'
27
28const assert = (c2, m) => { if (!c2) { console.error('FAIL:', m); cleanupSync(); process.exit(1) } console.log(' ok -', m) }
29const iso = '2026-07-27T00:00:00.000Z'
30const dirs = []
31const toClose = []
32function newDir (p) { const d = mkdtempSync(join(tmpdir(), p)); dirs.push(d); return d }
33function cleanupSync () { for (const d of dirs) { try { rmSync(d, { recursive: true, force: true }) } catch {} } }
34const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
35
36const id = createAtprotoIdentityFromKey(new Uint8Array(32).fill(5), { handle: 'rpc.test', pdsUrl: 'https://pds.local' })
37const DID = id.did
38
39const testnet = await createTestnet(3)
40const bootstrap = testnet.bootstrap
41
42// ---- the HOLDER: owns the cores AND serves the application RPC ----
43const hStore = openCorestore(newDir('holder-'))
44const hRepo = await new RepoStore(hStore, { name: `atproto-repo:${DID}` }).ready()
45let micros = 1_700_000_000_000_000n
46const hMgr = await new RepoManager(hRepo, { did: DID, privateKey: id.privateKey, clock: () => (micros += 1_000_000n) }).init()
47const hSeq = await new FirehoseSequencer(hStore.get({ name: 'firehose' })).ready()
48const w = await hMgr.applyWrite({ action: 'create', collection: 'app.bsky.feed.post', rkey: '3aaaaaaaaaaa2', record: { $type: 'app.bsky.feed.post', text: 'hi', createdAt: iso, langs: ['en'] } })
49await hSeq.sequenceCommit(w, { time: iso })
50const holder = new DidPeer(hStore, { did: DID, verifyKey: id.publicKey })
51await holder.provide({ repoKey: hRepo.core.key, firehoseKey: hSeq.core.key })
52const hSwarm = new Hyperswarm({ bootstrap })
53hSwarm.on('connection', (conn) => {
54 holder.attach(conn)
55 const rpc = new ProtomuxRPC(conn, { protocol: RPC_PROTOCOL, valueEncoding: c.json })
56 rpc.on('error', () => {})
57 rpc.respond('get-blob', (req) => ({ ok: true, from: 'holder', cid: req && req.cid }))
58 rpc.respond('boom', () => { throw new Error('the holder refuses this one') }) // a REAL answer, not a miss
59})
60await hSwarm.join(discoveryTopic(DID), { server: true, client: true }).flushed()
61toClose.push(async () => { try { await hSwarm.destroy() } catch {}; try { await holder.close() } catch {}; try { await hStore.close() } catch {} })
62
63// ---- the SEEDER: announces the SAME keys, opens the SAME RPC protocol, serves NO application method ----
64const sStore = openCorestore(newDir('seeder-'))
65const seeder = new DidPeer(sStore, { did: DID, verifyKey: id.publicKey })
66await seeder.provide({ repoKey: hRepo.core.key, firehoseKey: hSeq.core.key }) // same core KEYS => same front
67const sSwarm = new Hyperswarm({ bootstrap })
68sSwarm.on('connection', (conn) => {
69 seeder.attach(conn)
70 const rpc = new ProtomuxRPC(conn, { protocol: RPC_PROTOCOL, valueEncoding: c.json })
71 rpc.on('error', () => {})
72 rpc.respond('seeder-only-noop', () => ({ ok: true })) // anything else -> UNKNOWN_METHOD
73})
74await sSwarm.join(discoveryTopic(DID), { server: true, client: true }).flushed()
75toClose.push(async () => { try { await sSwarm.destroy() } catch {}; try { await seeder.close() } catch {}; try { await sStore.close() } catch {} })
76
77// ---- the reader: the bridge's posture, rpc: true (the write/session/blob relay) ----
78const store = openCorestore(newDir('reader-'))
79const swarm = new Hyperswarm({ bootstrap })
80const router = new DidPeerRouter(store, {
81 swarm,
82 join: (did) => { try { swarm.join(discoveryTopic(did), { server: true, client: true }); swarm.flush().catch(() => {}) } catch {} },
83 fronted: [DID],
84 resolveVerifyKey: async () => id.publicKey,
85 rpc: true
86})
87toClose.push(async () => { try { await swarm.destroy() } catch {}; try { await router.close() } catch {}; try { await store.close() } catch {} })
88
89const front = await router.front(DID)
90const gotBoth = await (async () => {
91 const end = Date.now() + 30000
92 while (Date.now() < end) { if (front._liveRpcs().length >= 2) return true; await sleep(250) }
93 return false
94})()
95{
96 const entries = router._byDid.get(DID) || new Map()
97 for (const [k, e] of entries) console.log(` entry ${k.slice(0, 8)}: conns=${e.conns.size} rpcs=${[...e.conns.values()].filter(Boolean).length}`)
98 console.log(` reader swarm conns=${swarm.connections.size} | front candidates=${front._liveRpcs().length} | fronts=${[...(router._fronts.get(DID) || new Map()).keys()].map((k) => k.slice(0, 8)).join(',')}`)
99}
100assert(gotBoth, `the front holds BOTH announcers as RPC candidates (got ${front._liveRpcs().length})`)
101
102// Both orders must work. Whichever announced last used to win the single slot, so proving order-independence
103// is proving the bug is gone — without needing to know which candidate is which.
104const first = await front.getBlob('bafyfake')
105assert(first && first.ok && first.from === 'holder', 'getBlob is answered by the HOLDER (candidate order as-found)')
106
107front._rpcs = [...front._rpcs].reverse() // simulate the other announce order
108const second = await front.getBlob('bafyfake')
109assert(second && second.ok && second.from === 'holder', 'getBlob is answered by the HOLDER with the order REVERSED (a seeder no longer wins the slot)')
110
111// Sticky: after falling through, the peer that answered is tried first next time.
112const preferred = front.rpc
113const third = await front.getBlob('bafyfake')
114assert(third.from === 'holder' && front.rpc === preferred, 'the answering peer stays preferred (no repeated failed hops)')
115
116// A peer that ANSWERS with a real error must surface it — not be retried away into someone else's miss.
117let boomErr = null
118try { await front._call('boom', {}) } catch (e) { boomErr = e }
119assert(!!boomErr, 'a real error from the answering peer is surfaced')
120assert(boomErr.code !== 'UNKNOWN_METHOD', `the surfaced error is the holder's, not a seeder miss (got ${boomErr.code})`)
121
122// And a method NOBODY serves still fails as unknown, rather than hanging or reporting success.
123let missErr = null
124try { await front._call('nobody-serves-this', {}) } catch (e) { missErr = e }
125assert(missErr && missErr.code === 'UNKNOWN_METHOD', 'a method no candidate serves still reports UNKNOWN_METHOD')
126
127console.log('\nRPC CANDIDATES OK: the holder answers regardless of who announced last; real errors still surface')
128for (const close of toClose) await close()
129await testnet.destroy()
130cleanupSync()