// connectSwarm's identity gate + the failed-connect swarm leak.
//
// Context (measured 2026-08-08, not hypothetical): `serverKey` only derives the
// topic — SwarmTransport.listen() builds its Hyperswarm with NO keyPair, so a
// server's Noise key is random per process and never equals the serverKey.
// In a pear-git-v3 fleet a mirror joins its ORIGIN's topic, making it a server
// on the very topic clients use to reach the origin. On stortron,
// connectSwarm({serverKey: <tabertron>}) was answered by STORTRON ITSELF 6/6:
// it asked itself for the origin's release index, got its own, and every
// artifact fetch failed "no holder". A wrong ANSWER, not a transport error.
import test from 'brittle'
import b4a from 'b4a'
import crypto from 'hypercore-crypto'
import createTestnet from '@hyperswarm/testnet'
import Corestore from 'corestore'
import { mkdtempSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import { Service } from '../lib/service.js'
import { Client } from '../lib/client.js'
const tmpStore = () => new Corestore(mkdtempSync(join(tmpdir(), 'hsvc-verify-')))
// Two services sharing ONE topic — the prod shape (origin + mirror).
async function twoOnOneTopic (t, testnet, topic) {
const made = []
for (const name of ['origin', 'impostor']) {
const store = tmpStore()
const svc = new Service({
name,
corestore: store,
swarm: { topic, swarm: { bootstrap: testnet.bootstrap } }
})
svc.handle('status', async () => ({ identity: name }))
svc.handle('who', async () => name)
await svc.listen()
t.teardown(async () => {
try { await svc.close() } catch {}
try { await store.close() } catch {}
})
made.push(svc)
}
return made
}
test('without verify, ANY peer on the topic can answer (the bug)', async (t) => {
const testnet = await createTestnet(2, t.teardown)
const serverKey = b4a.alloc(32, 'unverified-topic')
const topic = crypto.hash(b4a.concat([serverKey, b4a.from('pear-git-v3')]))
await twoOnOneTopic(t, testnet, topic)
const client = await Client.connectSwarm({
serverKey, topicSalt: 'pear-git-v3', swarm: { bootstrap: testnet.bootstrap }
})
t.teardown(async () => { try { await client.close() } catch {} })
const who = await client.call('who')
// Deliberately NOT asserting which one — that is the point. Whoever gets
// there first answers, and the caller cannot tell. Documented, not desired.
t.ok(who === 'origin' || who === 'impostor', 'some peer answered: ' + who)
})
test('verify rejects the wrong peer and keeps waiting for the right one', async (t) => {
const testnet = await createTestnet(2, t.teardown)
const serverKey = b4a.alloc(32, 'verified-topic')
const topic = crypto.hash(b4a.concat([serverKey, b4a.from('pear-git-v3')]))
await twoOnOneTopic(t, testnet, topic)
const seen = []
const client = await Client.connectSwarm({
serverKey,
topicSalt: 'pear-git-v3',
timeout: 20000,
swarm: { bootstrap: testnet.bootstrap },
verify: async (c) => {
const st = await c.call('status', {})
seen.push(st.identity)
return st.identity === 'origin'
}
})
t.teardown(async () => { try { await client.close() } catch {} })
t.is(await client.call('who'), 'origin', 'reached the daemon that was ASKED for')
t.ok(seen.includes('origin'), 'verify ran against the accepted peer')
})
test('all peers failing verification is a loud timeout, not a wrong answer', async (t) => {
const testnet = await createTestnet(2, t.teardown)
const serverKey = b4a.alloc(32, 'nobody-matches')
const topic = crypto.hash(b4a.concat([serverKey, b4a.from('pear-git-v3')]))
await twoOnOneTopic(t, testnet, topic)
await t.exception(
() => Client.connectSwarm({
serverKey,
topicSalt: 'pear-git-v3',
timeout: 3000,
swarm: { bootstrap: testnet.bootstrap },
verify: async () => false
}),
/timeout/,
'fails rather than silently returning the wrong daemon'
)
})
test('a verify that throws is treated as rejection, not a crash', async (t) => {
const testnet = await createTestnet(2, t.teardown)
const serverKey = b4a.alloc(32, 'verify-throws')
const topic = crypto.hash(b4a.concat([serverKey, b4a.from('pear-git-v3')]))
await twoOnOneTopic(t, testnet, topic)
await t.exception(
() => Client.connectSwarm({
serverKey,
topicSalt: 'pear-git-v3',
timeout: 3000,
swarm: { bootstrap: testnet.bootstrap },
verify: async () => { throw new Error('handler missing on that peer') }
}),
/timeout/,
'a throwing verify cannot escape as an unhandled rejection'
)
})
test('a failed connect does not leak the Hyperswarm', async (t) => {
// The leak that hung pear-git-v3's basic.test.js: 123/123 passed, then the
// process never exited because each failed connect left a swarm holding the
// event loop open.
const testnet = await createTestnet(2, t.teardown)
const serverKey = b4a.alloc(32, 'nobody-home') // nothing serves this topic
const before = process._getActiveHandles ? process._getActiveHandles().length : null
await t.exception(
() => Client.connectSwarm({
serverKey, topicSalt: 'pear-git-v3', timeout: 1500,
swarm: { bootstrap: testnet.bootstrap }
}),
/timeout/,
'times out with nobody serving'
)
// Destruction is awaited inside connectSwarm's catch, so by here the swarm's
// sockets are already gone rather than merely scheduled for teardown.
if (before !== null) {
const after = process._getActiveHandles().length
t.ok(after <= before + 2, 'no swarm left running (handles ' + before + ' → ' + after + ')')
} else {
t.pass('handle introspection unavailable in this runtime')
}
})