From d222c8c677cba982afffb602c38bd0a85f1cdc98 Mon Sep 17 00:00:00 2001 From: tabcat Date: Tue, 5 May 2026 22:46:56 +0700 Subject: [PATCH 1/3] feat(autonat-v2): emit address reachability events Adds address:verifying, address:reachable, address:unreachable, and address:removed events on the AutoNATv2 service, plus verifying, reachable, and unreachable getters exposing current state. Co-Authored-By: Claude Opus 4.7 --- packages/protocol-autonat-v2/src/autonat.ts | 23 +++++- packages/protocol-autonat-v2/src/client.ts | 83 ++++++++++++++++++--- packages/protocol-autonat-v2/src/index.ts | 12 +++ 3 files changed, 104 insertions(+), 14 deletions(-) diff --git a/packages/protocol-autonat-v2/src/autonat.ts b/packages/protocol-autonat-v2/src/autonat.ts index 61c8a5711a..1da65829e7 100644 --- a/packages/protocol-autonat-v2/src/autonat.ts +++ b/packages/protocol-autonat-v2/src/autonat.ts @@ -1,22 +1,27 @@ import { serviceCapabilities, serviceDependencies, start, stop } from '@libp2p/interface' +import { TypedEventEmitter } from 'main-event' import { AutoNATv2Client } from './client.ts' import { DIAL_BACK, DIAL_REQUEST, PROTOCOL_NAME, PROTOCOL_PREFIX, PROTOCOL_VERSION } from './constants.ts' import { AutoNATv2Server } from './server.ts' -import type { AutoNATv2Components, AutoNATv2ServiceInit } from './index.ts' +import type { AutoNATv2Components, AutoNATv2Events, AutoNATv2ServiceInit } from './index.ts' import type { Startable } from '@libp2p/interface' +import type { Multiaddr } from '@multiformats/multiaddr' -export class AutoNATv2Service implements Startable { +export class AutoNATv2Service extends TypedEventEmitter implements Startable { private readonly client: AutoNATv2Client private readonly server: AutoNATv2Server constructor (components: AutoNATv2Components, init: AutoNATv2ServiceInit) { + super() + const dialRequestProtocol = `/${init.protocolPrefix ?? PROTOCOL_PREFIX}/${PROTOCOL_NAME}/${PROTOCOL_VERSION}/${DIAL_REQUEST}` const dialBackProtocol = `/${init.protocolPrefix ?? PROTOCOL_PREFIX}/${PROTOCOL_NAME}/${PROTOCOL_VERSION}/${DIAL_BACK}` this.client = new AutoNATv2Client(components, { ...init, dialRequestProtocol, - dialBackProtocol + dialBackProtocol, + safeDispatchEvent: this.safeDispatchEvent.bind(this) }) this.server = new AutoNATv2Server(components, { ...init, @@ -25,6 +30,18 @@ export class AutoNATv2Service implements Startable { }) } + get verifying (): Multiaddr[] { + return this.client.verifying + } + + get reachable (): Multiaddr[] { + return this.client.reachable + } + + get unreachable (): Multiaddr[] { + return this.client.unreachable + } + readonly [Symbol.toStringTag] = '@libp2p/autonat-v2' readonly [serviceCapabilities]: string[] = [ diff --git a/packages/protocol-autonat-v2/src/client.ts b/packages/protocol-autonat-v2/src/client.ts index fea63168e7..47307539d7 100644 --- a/packages/protocol-autonat-v2/src/client.ts +++ b/packages/protocol-autonat-v2/src/client.ts @@ -6,12 +6,15 @@ import { setMaxListeners } from 'main-event' import { DEFAULT_CONNECTION_THRESHOLD, DIAL_DATA_CHUNK_SIZE, MAX_DIAL_DATA_BYTES, MAX_INBOUND_STREAMS, MAX_MESSAGE_SIZE, MAX_OUTBOUND_STREAMS, TIMEOUT } from './constants.ts' import { DialBack, DialBackResponse, DialResponse, DialStatus, Message } from './pb/index.ts' import { randomNumber } from './utils.ts' -import type { AutoNATv2Components, AutoNATv2ServiceInit } from './index.ts' +import type { AutoNATv2Components, AutoNATv2Events, AutoNATv2ServiceInit } from './index.ts' import type { Logger, Connection, Startable, AbortOptions, Stream } from '@libp2p/interface' import type { AddressType } from '@libp2p/interface-internal' import type { PeerSet } from '@libp2p/peer-collections' import type { Filter, RepeatingTask } from '@libp2p/utils' import type { Multiaddr } from '@multiformats/multiaddr' +import type { TypedEventEmitter } from 'main-event' + +export type DispatchAutoNATv2Event = TypedEventEmitter['safeDispatchEvent'] // if more than 3 peers manage to dial us on what we believe to be our external // IP then we are convinced that it is, in fact, our external IP @@ -68,6 +71,12 @@ export interface AutoNATv2ClientInit extends AutoNATv2ServiceInit { dialBackProtocol: string maxDialDataBytes?: bigint dialDataChunkSize?: number + safeDispatchEvent: DispatchAutoNATv2Event +} + +interface Verdict { + addr: Multiaddr + state: 'verifying' | 'reachable' | 'unreachable' } export class AutoNATv2Client implements Startable { @@ -84,14 +93,17 @@ export class AutoNATv2Client implements Startable { private readonly log: Logger private topologyId?: string private readonly dialResults: Map + private readonly verdicts: Map private readonly findPeers: RepeatingTask private readonly addressFilter: Filter private readonly connectionThreshold: number private readonly queue: PeerQueue private readonly nonces: Set + private readonly safeDispatchEvent: DispatchAutoNATv2Event constructor (components: AutoNATv2Components, init: AutoNATv2ClientInit) { this.components = components + this.safeDispatchEvent = init.safeDispatchEvent this.log = components.logger.forComponent('libp2p:auto-nat-v2:client') this.started = false this.dialRequestProtocol = init.dialRequestProtocol @@ -105,6 +117,7 @@ export class AutoNATv2Client implements Startable { name: 'libp2p_autonat_v2_dial_results', metrics: components.metrics }) + this.verdicts = new Map() this.findPeers = repeatingTask(this.findRandomPeers.bind(this), 60_000) this.addressFilter = createScalableCuckooFilter(1024) this.queue = new PeerQueue({ @@ -117,6 +130,24 @@ export class AutoNATv2Client implements Startable { this.nonces = new Set() } + get verifying (): Multiaddr[] { + return [...this.verdicts.values()] + .filter(v => v.state === 'verifying') + .map(v => v.addr) + } + + get reachable (): Multiaddr[] { + return [...this.verdicts.values()] + .filter(v => v.state === 'reachable') + .map(v => v.addr) + } + + get unreachable (): Multiaddr[] { + return [...this.verdicts.values()] + .filter(v => v.state === 'unreachable') + .map(v => v.addr) + } + readonly [Symbol.toStringTag] = '@libp2p/autonat-v2' readonly [serviceCapabilities]: string[] = [ @@ -170,6 +201,7 @@ export class AutoNATv2Client implements Startable { } this.dialResults.clear() + this.verdicts.clear() this.findPeers.stop() this.started = false } @@ -358,6 +390,14 @@ export class AutoNATv2Client implements Startable { } this.dialResults.set(addrString, results) + + // First-ever probe of this address — surface as verifying. Re-probes + // of an address that already has a verdict stay silent: the verdicts + // entry persists across re-probes and only flips on an actual change. + if (!this.verdicts.has(addrString)) { + this.verdicts.set(addrString, { addr: addr.multiaddr, state: 'verifying' }) + this.safeDispatchEvent('address:verifying', { detail: { addr: addr.multiaddr } }) + } } output.push(results) @@ -368,26 +408,31 @@ export class AutoNATv2Client implements Startable { /** * Removes any multiaddr result objects created for old multiaddrs that we are - * no longer waiting on + * no longer waiting on, and prunes verdicts for addresses no longer tracked + * by the AddressManager (emitting `address:removed` for each). */ private removeOutdatedMultiaddrResults (): void { - const unverifiedMultiaddrs = new Set(this.components.addressManager.getAddressesWithMetadata() - .filter(({ expires }) => { - if (expires < Date.now()) { - return true - } - - return false - }) + const allAddresses = this.components.addressManager.getAddressesWithMetadata() + const allKeys = new Set(allAddresses.map(({ multiaddr }) => multiaddr.toString())) + const unverifiedKeys = new Set(allAddresses + .filter(({ expires }) => expires < Date.now()) .map(({ multiaddr }) => multiaddr.toString()) ) for (const multiaddr of this.dialResults.keys()) { - if (!unverifiedMultiaddrs.has(multiaddr)) { + if (!unverifiedKeys.has(multiaddr)) { this.log.trace('remove results for %a', multiaddr) this.dialResults.delete(multiaddr) } } + + for (const [key, verdict] of this.verdicts) { + if (!allKeys.has(key)) { + this.log.trace('verdict no longer applies for %a', verdict.addr) + this.verdicts.delete(key) + this.safeDispatchEvent('address:removed', { detail: { addr: verdict.addr } }) + } + } } /** @@ -605,6 +650,8 @@ export class AutoNATv2Client implements Startable { // abort & remove any outstanding verification jobs for this multiaddr results.result = true + + this.recordVerdict(results.multiaddr, 'reachable') } private unconfirmAddress (results: DialResults): void { @@ -615,6 +662,20 @@ export class AutoNATv2Client implements Startable { // abort & remove any outstanding verification jobs for this multiaddr results.result = false + + this.recordVerdict(results.multiaddr, 'unreachable') + } + + private recordVerdict (addr: Multiaddr, state: 'reachable' | 'unreachable'): void { + const key = addr.toString() + const previous = this.verdicts.get(key)?.state + + if (previous === state) { + return + } + + this.verdicts.set(key, { addr, state }) + this.safeDispatchEvent(`address:${state}`, { detail: { addr } }) } private getNetworkSegment (ma: Multiaddr): string { diff --git a/packages/protocol-autonat-v2/src/index.ts b/packages/protocol-autonat-v2/src/index.ts index 802fd01b93..b130ce5473 100644 --- a/packages/protocol-autonat-v2/src/index.ts +++ b/packages/protocol-autonat-v2/src/index.ts @@ -25,6 +25,18 @@ import { AutoNATv2Service } from './autonat.ts' import type { ComponentLogger, Metrics, PeerStore } from '@libp2p/interface' import type { AddressManager, ConnectionManager, RandomWalk, Registrar } from '@libp2p/interface-internal' +import type { Multiaddr } from '@multiformats/multiaddr' + +export interface AddressReachabilityChange { + addr: Multiaddr +} + +export interface AutoNATv2Events { + 'address:verifying': CustomEvent + 'address:reachable': CustomEvent + 'address:unreachable': CustomEvent + 'address:removed': CustomEvent +} export interface AutoNATv2ServiceInit { /** From da415830bdb1e9e8e22cd3ce831ac4091d71f584 Mon Sep 17 00:00:00 2001 From: tabcat Date: Tue, 5 May 2026 22:47:00 +0700 Subject: [PATCH 2/3] test(autonat-v2): cover address reachability events Co-Authored-By: Claude Opus 4.7 --- .../protocol-autonat-v2/test/events.spec.ts | 500 ++++++++++++++++++ 1 file changed, 500 insertions(+) create mode 100644 packages/protocol-autonat-v2/test/events.spec.ts diff --git a/packages/protocol-autonat-v2/test/events.spec.ts b/packages/protocol-autonat-v2/test/events.spec.ts new file mode 100644 index 0000000000..1707c95e00 --- /dev/null +++ b/packages/protocol-autonat-v2/test/events.spec.ts @@ -0,0 +1,500 @@ +/* eslint max-depth: ["error", 5] */ + +import { generateKeyPair } from '@libp2p/crypto/keys' +import { start, stop } from '@libp2p/interface' +import { defaultLogger } from '@libp2p/logger' +import { peerIdFromPrivateKey } from '@libp2p/peer-id' +import { streamPair, pbStream } from '@libp2p/utils' +import { multiaddr } from '@multiformats/multiaddr' +import { expect } from 'aegir/chai' +import delay from 'delay' +import pRetry from 'p-retry' +import sinon from 'sinon' +import { stubInterface } from 'sinon-ts' +import { AutoNATv2Service } from '../src/autonat.ts' +import { PROTOCOL_NAME, PROTOCOL_PREFIX, PROTOCOL_VERSION } from '../src/constants.ts' +import { DialResponse, DialStatus, Message } from '../src/pb/index.ts' +import type { AddressReachabilityChange, AutoNATv2Components, AutoNATv2ServiceInit } from '../src/index.ts' +import type { Connection, PeerId, PeerStore, Peer } from '@libp2p/interface' +import type { AddressManager, ConnectionManager, NodeAddress, RandomWalk, Registrar } from '@libp2p/interface-internal' +import type { Multiaddr } from '@multiformats/multiaddr' +import type { StubbedInstance } from 'sinon-ts' + +const defaultInit: AutoNATv2ServiceInit = { + protocolPrefix: 'libp2p', + maxInboundStreams: 1, + maxOutboundStreams: 1, + timeout: 100, + startupDelay: 120000, + refreshInterval: 120000 +} + +interface StubbedResponse { + host: string + peerId?: PeerId + messages: Record +} + +describe('autonat v2 - events', () => { + let service: any + let components: AutoNATv2Components + let randomWalk: StubbedInstance + let registrar: StubbedInstance + let addressManager: StubbedInstance + let connectionManager: StubbedInstance + let peerStore: StubbedInstance + + beforeEach(async () => { + randomWalk = stubInterface() + registrar = stubInterface() + addressManager = stubInterface() + addressManager.getAddresses.returns([]) + + connectionManager = stubInterface({ + getConnections: () => [], + getMaxConnections: () => 100 + }) + peerStore = stubInterface() + + components = { + logger: defaultLogger(), + randomWalk, + registrar, + addressManager, + connectionManager, + peerStore + } + + service = new AutoNATv2Service(components, defaultInit) + + await start(components) + await start(service) + }) + + afterEach(async () => { + sinon.restore() + + await stop(service) + await stop(components) + }) + + async function stubPeerResponse (data: StubbedResponse): Promise { + const peer: Peer = { + id: data.peerId ?? peerIdFromPrivateKey(await generateKeyPair('Ed25519')), + addresses: [{ + multiaddr: multiaddr(`/ip4/${data.host}/tcp/28319`), + isCertified: true + }], + protocols: [ + '/libp2p/autonat/2/dial-request', + '/libp2p/autonat/2/dial-back' + ], + metadata: new Map(), + tags: new Map() + } + + peerStore.get.withArgs(peer.id).resolves(peer) + + const connection = stubInterface() + connection.remoteAddr = multiaddr(`/ip4/${data.host}/tcp/28319/p2p/${peer.id.toString()}`) + connection.remotePeer = peer.id + connectionManager.openConnection.withArgs(peer.id).resolves(connection) + + const [outgoingStream, incomingStream] = await streamPair() + + connection.newStream.withArgs(`/${PROTOCOL_PREFIX}/${PROTOCOL_NAME}/${PROTOCOL_VERSION}/dial-request`).resolves(outgoingStream) + + const messages = pbStream(incomingStream).pb(Message) + + Promise.resolve().then(async () => { + const message = await messages.read() + + if (message.dialRequest == null) { + throw new Error('Unexpected message') + } + + for (const addr of message.dialRequest.addrs.map(buf => multiaddr(buf))) { + let responses = data.messages[addr.toString()] + + if (responses == null) { + throw new Error(`No response defined for address ${addr}`) + } + + if (!Array.isArray(responses)) { + responses = [responses] + } + + for (const response of responses) { + await messages.write(response) + } + } + + await incomingStream.close() + }) + + return connection + } + + async function makeResponseConnection (host: string, addr: Multiaddr, dialStatus: DialStatus): Promise { + return stubPeerResponse({ + host, + messages: { + [addr.toString()]: { + dialResponse: { + addrIdx: 0, + status: DialResponse.ResponseStatus.OK, + dialStatus + } + } + } + }) + } + + async function stubResponses (count: number, addr: Multiaddr, dialStatus: DialStatus, hostBase = 100): Promise { + const conns: Connection[] = [] + for (let i = 0; i < count; i++) { + conns.push(await makeResponseConnection(`${hostBase + i}.124.124.124`, addr, dialStatus)) + } + return conns + } + + async function driveConnections (conns: Connection[]): Promise { + for (const conn of conns) { + await service.client.verifyExternalAddresses(conn) + await delay(100) + } + } + + function captureEvent (name: 'address:verifying' | 'address:reachable' | 'address:unreachable' | 'address:removed'): Array> { + const fired: Array> = [] + service.addEventListener(name, (evt: CustomEvent) => { + fired.push(evt) + }) + return fired + } + + function observedEntry (addr: Multiaddr, opts: { verified?: boolean, expires?: number } = {}): NodeAddress { + return { + multiaddr: addr, + verified: opts.verified ?? false, + type: 'observed', + expires: opts.expires ?? 0 + } + } + + function transportEntry (addr: Multiaddr, opts: { verified?: boolean, expires?: number } = {}): NodeAddress { + return { + multiaddr: addr, + verified: opts.verified ?? false, + type: 'transport', + expires: opts.expires ?? 0 + } + } + + it('emits address:verifying when probe starts for an unverdicted address', async () => { + const addr = multiaddr('/ip4/123.123.123.123/tcp/28319') + addressManager.getAddressesWithMetadata.returns([observedEntry(addr)]) + + const verifying = captureEvent('address:verifying') + + // Drive a single connection to trigger getUnverifiedMultiaddrs without + // pushing to a verdict (1 OK is below the 4-success threshold for observed). + await driveConnections(await stubResponses(1, addr, DialStatus.OK)) + + await pRetry(() => { + expect(verifying).to.have.lengthOf(1) + }) + + expect(verifying[0].detail.addr.toString()).to.equal(addr.toString()) + expect(service.verifying.map((m: Multiaddr) => m.toString())).to.deep.equal([addr.toString()]) + expect(service.reachable).to.deep.equal([]) + expect(service.unreachable).to.deep.equal([]) + }) + + it('moves address out of service.verifying once a verdict is reached', async () => { + const addr = multiaddr('/ip4/123.123.123.123/tcp/28319') + addressManager.getAddressesWithMetadata.returns([observedEntry(addr)]) + + const verifying = captureEvent('address:verifying') + const reachable = captureEvent('address:reachable') + + await driveConnections(await stubResponses(4, addr, DialStatus.OK)) + + await pRetry(() => { + expect(reachable).to.have.lengthOf(1) + }) + + expect(verifying).to.have.lengthOf(1) + expect(service.verifying).to.deep.equal([]) + expect(service.reachable.map((m: Multiaddr) => m.toString())).to.deep.equal([addr.toString()]) + }) + + it('does not re-emit address:verifying on re-probe of a verdict\'d address', async () => { + const addr = multiaddr('/ip4/123.123.123.123/tcp/28319') + addressManager.getAddressesWithMetadata.returns([observedEntry(addr)]) + + const verifying = captureEvent('address:verifying') + const reachable = captureEvent('address:reachable') + + await driveConnections(await stubResponses(4, addr, DialStatus.OK)) + await pRetry(() => { expect(reachable).to.have.lengthOf(1) }) + expect(verifying).to.have.lengthOf(1) + + // TTL lapses; re-probe runs. + addressManager.getAddressesWithMetadata.returns([observedEntry(addr, { verified: true, expires: Date.now() - 1000 })]) + await driveConnections(await stubResponses(4, addr, DialStatus.OK, 200)) + await delay(200) + + // verifying should still be 1 — no re-emission for a verdict'd address. + expect(verifying).to.have.lengthOf(1) + expect(service.reachable.map((m: Multiaddr) => m.toString())).to.deep.equal([addr.toString()]) + }) + + it('emits address:reachable on first verdict for an observed address', async () => { + const addr = multiaddr('/ip4/123.123.123.123/tcp/28319') + addressManager.getAddressesWithMetadata.returns([observedEntry(addr)]) + + const reachable = captureEvent('address:reachable') + + await driveConnections(await stubResponses(4, addr, DialStatus.OK)) + + await pRetry(() => { + expect(reachable).to.have.lengthOf(1) + }) + + expect(reachable[0].detail.addr.toString()).to.equal(addr.toString()) + expect(service.reachable.map((m: Multiaddr) => m.toString())).to.deep.equal([addr.toString()]) + expect(service.unreachable).to.deep.equal([]) + }) + + it('emits address:reachable on first verdict for a non-observed address (1 success)', async () => { + const addr = multiaddr('/ip4/123.123.123.123/tcp/28319') + addressManager.getAddressesWithMetadata.returns([transportEntry(addr)]) + + const reachable = captureEvent('address:reachable') + + await driveConnections(await stubResponses(1, addr, DialStatus.OK)) + + await pRetry(() => { + expect(reachable).to.have.lengthOf(1) + }) + + expect(reachable[0].detail.addr.toString()).to.equal(addr.toString()) + expect(service.reachable.map((m: Multiaddr) => m.toString())).to.deep.equal([addr.toString()]) + }) + + it('emits address:unreachable on first verdict for an observed address (8 failures)', async () => { + const addr = multiaddr('/ip4/123.123.123.123/tcp/28319') + addressManager.getAddressesWithMetadata.returns([observedEntry(addr)]) + + const unreachable = captureEvent('address:unreachable') + + await driveConnections(await stubResponses(8, addr, DialStatus.E_DIAL_ERROR)) + + await pRetry(() => { + expect(unreachable).to.have.lengthOf(1) + }) + + expect(unreachable[0].detail.addr.toString()).to.equal(addr.toString()) + expect(service.unreachable.map((m: Multiaddr) => m.toString())).to.deep.equal([addr.toString()]) + expect(service.reachable).to.deep.equal([]) + }) + + it('does not re-emit address:reachable on re-confirmation', async () => { + const addr = multiaddr('/ip4/123.123.123.123/tcp/28319') + addressManager.getAddressesWithMetadata.returns([observedEntry(addr)]) + + const reachable = captureEvent('address:reachable') + + await driveConnections(await stubResponses(4, addr, DialStatus.OK)) + await pRetry(() => { expect(reachable).to.have.lengthOf(1) }) + + // Simulate the address being marked as needing re-verification (TTL lapsed) + // and re-probe with another set of successful responses. + addressManager.getAddressesWithMetadata.returns([observedEntry(addr, { verified: true, expires: Date.now() - 1000 })]) + + await driveConnections(await stubResponses(4, addr, DialStatus.OK, 200)) + await delay(200) + + expect(reachable).to.have.lengthOf(1) + }) + + it('flips REACHABLE → UNREACHABLE and emits address:unreachable', async () => { + const addr = multiaddr('/ip4/123.123.123.123/tcp/28319') + addressManager.getAddressesWithMetadata.returns([observedEntry(addr)]) + + const reachable = captureEvent('address:reachable') + const unreachable = captureEvent('address:unreachable') + + await driveConnections(await stubResponses(4, addr, DialStatus.OK)) + await pRetry(() => { expect(reachable).to.have.lengthOf(1) }) + + // TTL lapses; re-probe fails 8 times. + addressManager.getAddressesWithMetadata.returns([observedEntry(addr, { verified: true, expires: Date.now() - 1000 })]) + + await driveConnections(await stubResponses(8, addr, DialStatus.E_DIAL_ERROR, 200)) + + await pRetry(() => { + expect(unreachable).to.have.lengthOf(1) + }) + + expect(unreachable[0].detail.addr.toString()).to.equal(addr.toString()) + expect(service.reachable).to.deep.equal([]) + expect(service.unreachable.map((m: Multiaddr) => m.toString())).to.deep.equal([addr.toString()]) + }) + + it('flips UNREACHABLE → REACHABLE for non-observed and emits address:reachable', async () => { + const addr = multiaddr('/ip4/123.123.123.123/tcp/28319') + addressManager.getAddressesWithMetadata.returns([transportEntry(addr)]) + + const reachable = captureEvent('address:reachable') + const unreachable = captureEvent('address:unreachable') + + // 8 failures → unreachable + await driveConnections(await stubResponses(8, addr, DialStatus.E_DIAL_ERROR)) + await pRetry(() => { expect(unreachable).to.have.lengthOf(1) }) + + // retry TTL lapses; re-probe succeeds (1 success for non-observed) + addressManager.getAddressesWithMetadata.returns([transportEntry(addr, { expires: Date.now() - 1000 })]) + + await driveConnections(await stubResponses(1, addr, DialStatus.OK, 200)) + + await pRetry(() => { + expect(reachable).to.have.lengthOf(1) + }) + + expect(reachable[0].detail.addr.toString()).to.equal(addr.toString()) + expect(service.unreachable).to.deep.equal([]) + expect(service.reachable.map((m: Multiaddr) => m.toString())).to.deep.equal([addr.toString()]) + }) + + it('emits address:removed on the next reconcile after observed UNREACHABLE (dual-fire)', async () => { + const addr = multiaddr('/ip4/123.123.123.123/tcp/28319') + addressManager.getAddressesWithMetadata.returns([observedEntry(addr)]) + + const unreachable = captureEvent('address:unreachable') + const removed = captureEvent('address:removed') + + await driveConnections(await stubResponses(8, addr, DialStatus.E_DIAL_ERROR)) + await pRetry(() => { expect(unreachable).to.have.lengthOf(1) }) + + expect(removed).to.have.lengthOf(0) + + // The hard-delete from observed.remove would cause the address to disappear + // from getAddressesWithMetadata. Simulate that. + addressManager.getAddressesWithMetadata.returns([]) + + // Trigger a reconcile pass via verifyExternalAddresses (cleanup runs at the + // start of that method). Use a fresh peer connection. + const triggerConn = await stubPeerResponse({ + host: '200.0.0.1', + messages: {} + }) + await service.client.verifyExternalAddresses(triggerConn) + await delay(100) + + await pRetry(() => { + expect(removed).to.have.lengthOf(1) + }) + + expect(removed[0].detail.addr.toString()).to.equal(addr.toString()) + expect(service.unreachable).to.deep.equal([]) + }) + + it('does not emit address:removed for transport UNREACHABLE (entry stays in AddressManager)', async () => { + const addr = multiaddr('/ip4/123.123.123.123/tcp/28319') + addressManager.getAddressesWithMetadata.returns([transportEntry(addr)]) + + const unreachable = captureEvent('address:unreachable') + const removed = captureEvent('address:removed') + + await driveConnections(await stubResponses(8, addr, DialStatus.E_DIAL_ERROR)) + await pRetry(() => { expect(unreachable).to.have.lengthOf(1) }) + + // Transport entry stays in AddressManager (verified: false, retry TTL). + addressManager.getAddressesWithMetadata.returns([transportEntry(addr, { verified: false, expires: Date.now() + 60_000 })]) + + // Trigger a reconcile pass. + const triggerConn = await stubPeerResponse({ + host: '200.0.0.1', + messages: {} + }) + await service.client.verifyExternalAddresses(triggerConn) + await delay(100) + + expect(removed).to.have.lengthOf(0) + expect(service.unreachable.map((m: Multiaddr) => m.toString())).to.deep.equal([addr.toString()]) + }) + + it('emits address:removed without a prior unreachable when a reachable address disappears', async () => { + const addr = multiaddr('/ip4/123.123.123.123/tcp/28319') + addressManager.getAddressesWithMetadata.returns([observedEntry(addr)]) + + const reachable = captureEvent('address:reachable') + const unreachable = captureEvent('address:unreachable') + const removed = captureEvent('address:removed') + + await driveConnections(await stubResponses(4, addr, DialStatus.OK)) + await pRetry(() => { expect(reachable).to.have.lengthOf(1) }) + + // Address is removed for an unrelated reason (relay drop, transport + // shutdown, etc.) - no AutoNAT v2 unreachable verdict involved. + addressManager.getAddressesWithMetadata.returns([]) + + const triggerConn = await stubPeerResponse({ + host: '200.0.0.1', + messages: {} + }) + await service.client.verifyExternalAddresses(triggerConn) + await delay(100) + + await pRetry(() => { + expect(removed).to.have.lengthOf(1) + }) + + expect(removed[0].detail.addr.toString()).to.equal(addr.toString()) + expect(unreachable).to.have.lengthOf(0) + expect(service.reachable).to.deep.equal([]) + }) + + it('does not emit any event when an un-verdicted address disappears', async () => { + const addr = multiaddr('/ip4/123.123.123.123/tcp/28319') + addressManager.getAddressesWithMetadata.returns([observedEntry(addr)]) + + const reachable = captureEvent('address:reachable') + const unreachable = captureEvent('address:unreachable') + const removed = captureEvent('address:removed') + + // The address is removed before any verdict is reached. + addressManager.getAddressesWithMetadata.returns([]) + + const triggerConn = await stubPeerResponse({ + host: '200.0.0.1', + messages: {} + }) + await service.client.verifyExternalAddresses(triggerConn) + await delay(200) + + expect(reachable).to.have.lengthOf(0) + expect(unreachable).to.have.lengthOf(0) + expect(removed).to.have.lengthOf(0) + }) + + it('clears verdicts state on stop without emitting events', async () => { + const addr = multiaddr('/ip4/123.123.123.123/tcp/28319') + addressManager.getAddressesWithMetadata.returns([observedEntry(addr)]) + + await driveConnections(await stubResponses(4, addr, DialStatus.OK)) + await pRetry(() => { + expect(service.reachable.map((m: Multiaddr) => m.toString())).to.deep.equal([addr.toString()]) + }) + + const removed = captureEvent('address:removed') + + await stop(service) + + expect(removed).to.have.lengthOf(0) + expect(service.reachable).to.deep.equal([]) + expect(service.unreachable).to.deep.equal([]) + }) +}) From 91efef4d6be1942afe29ca66b75ce1a7561f7567 Mon Sep 17 00:00:00 2001 From: tabcat Date: Thu, 7 May 2026 16:45:17 +0700 Subject: [PATCH 3/3] test(autonat-v2): reword event test descriptions Replaces coined verb forms (verdicted/unverdicted) that tripped CSpell with standard English ("new" / "reachable"). Co-Authored-By: Claude Opus 4.7 --- packages/protocol-autonat-v2/test/events.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/protocol-autonat-v2/test/events.spec.ts b/packages/protocol-autonat-v2/test/events.spec.ts index 1707c95e00..2789b778b5 100644 --- a/packages/protocol-autonat-v2/test/events.spec.ts +++ b/packages/protocol-autonat-v2/test/events.spec.ts @@ -191,7 +191,7 @@ describe('autonat v2 - events', () => { } } - it('emits address:verifying when probe starts for an unverdicted address', async () => { + it('emits address:verifying when probe starts for a new address', async () => { const addr = multiaddr('/ip4/123.123.123.123/tcp/28319') addressManager.getAddressesWithMetadata.returns([observedEntry(addr)]) @@ -229,7 +229,7 @@ describe('autonat v2 - events', () => { expect(service.reachable.map((m: Multiaddr) => m.toString())).to.deep.equal([addr.toString()]) }) - it('does not re-emit address:verifying on re-probe of a verdict\'d address', async () => { + it('does not re-emit address:verifying on re-probe of a reachable address', async () => { const addr = multiaddr('/ip4/123.123.123.123/tcp/28319') addressManager.getAddressesWithMetadata.returns([observedEntry(addr)]) @@ -245,7 +245,7 @@ describe('autonat v2 - events', () => { await driveConnections(await stubResponses(4, addr, DialStatus.OK, 200)) await delay(200) - // verifying should still be 1 — no re-emission for a verdict'd address. + // verifying should still be 1 — no re-emission for a reachable address. expect(verifying).to.have.lengthOf(1) expect(service.reachable.map((m: Multiaddr) => m.toString())).to.deep.equal([addr.toString()]) }) @@ -457,7 +457,7 @@ describe('autonat v2 - events', () => { expect(service.reachable).to.deep.equal([]) }) - it('does not emit any event when an un-verdicted address disappears', async () => { + it('does not emit any event when a new address disappears before being probed', async () => { const addr = multiaddr('/ip4/123.123.123.123/tcp/28319') addressManager.getAddressesWithMetadata.returns([observedEntry(addr)])