use multiaddr instead of multiserver addresses

This commit is contained in:
Andre Staltz 2024-01-16 10:58:57 +02:00
parent 4101e023cd
commit c2d1adb19e
No known key found for this signature in database
GPG Key ID: 9EDE23EA7E8A4890
11 changed files with 194 additions and 119 deletions

View File

@ -2,10 +2,11 @@ const debug = require('debug')('ppppp:net:connections')
const createNotify = require('pull-notify')
const run = require('promisify-tuple')
const IP = require('ip')
const Multiaddr = require('./multiaddr')
/**
* @typedef {import('./index').RpcConnectListener} RpcConnectListener
* @typedef {import('./index').Address} Address
* @typedef {import('./index').Multiaddr} Multiaddr
* @typedef {import('./index').RPC} RPC
* @typedef {import('./index').Peer} Peer
* @typedef {import('./infos').Info} Info
@ -17,7 +18,7 @@ const IP = require('ip')
* | 'connecting-failed'
* | 'disconnecting'
* | 'disconnected';
* address: Address;
* multiaddr: Multiaddr;
* pubkey: string | undefined;
* details?: any;
* }} ConnectionEvent
@ -37,12 +38,12 @@ class Connections {
#closed
/** @type {NotifyEvent} */
#notifyEvent
/** @type {Map<Address, RPC>} */
/** @type {Map<Multiaddr, RPC>} */
#rpcs
/**
* Used only to schedule a connect when a disconnect is in progress.
* @type {Set<Address>}
* @type {Set<Multiaddr>}
*/
#connectRetries
@ -62,7 +63,7 @@ class Connections {
}
/**
* @param {Address} address
* @param {string} address
* @returns {Info['inferredType']}
*/
static inferPeerType(address) {
@ -111,28 +112,29 @@ class Connections {
}
/**
* @type {(address: Address, rpc: RPC, weAreClient: boolean) => void}
* @type {(address: string, rpc: RPC, weAreClient: boolean) => void}
*/
#prepareConnectedRPC = (address, rpc, weAreClient) => {
const initiator = weAreClient ? 'we' : 'they'
debug('Connected to %s, %s initiated it', address, initiator)
const multiaddr = Multiaddr.fromMs(address)
debug('Connected to %s, %s initiated it', multiaddr, initiator)
const pubkey = Connections.extractSHSEPubkey(address)
this.#rpcs.set(address, rpc)
this.#rpcs.set(multiaddr, rpc)
rpc.once('closed', () => {
debug('Disconnected from %s', address)
this.#rpcs.delete(address)
this.#infos.update(address, { state: 'disconnected' })
this.#notifyEvent({ type: 'disconnected', address, pubkey })
debug('Disconnected from %s', multiaddr)
this.#rpcs.delete(multiaddr)
this.#infos.update(multiaddr, { state: 'disconnected' })
this.#notifyEvent({ type: 'disconnected', multiaddr, pubkey })
this.#infos.emit()
})
const state = /**@type {Info['state']}*/ ('connected')
const inferredType = Connections.inferPeerType(address)
this.#infos.update(address, { state, inferredType })
this.#infos.update(multiaddr, { state, inferredType })
this.#notifyEvent({
type: state,
address,
multiaddr,
pubkey,
details: { rpc, weAreClient },
})
@ -140,37 +142,38 @@ class Connections {
}
/**
* @param {string} address
* @param {Multiaddr} multiaddr
* @returns {Promise<RPC>}
*/
async connect(address) {
async connect(multiaddr) {
this.#assertNotClosed()
const prevInfo = this.#infos.get(address)
const address = Multiaddr.toMs(multiaddr)
const prevInfo = this.#infos.get(multiaddr)
switch (prevInfo?.state ?? 'disconnected') {
case 'connected': {
const rpc = this.#rpcs.get(address)
const rpc = this.#rpcs.get(multiaddr)
if (!rpc) {
// prettier-ignore
throw new Error(`Failed to connect to ${address} due to inconsistent internal state`);
throw new Error(`Failed to connect to ${multiaddr} due to inconsistent internal state`);
}
return rpc
}
case 'disconnecting': {
// If disconnecting, schedule a connect() after disconnection completed
this.#connectRetries.add(address)
this.#connectRetries.add(multiaddr)
// note: control flow should fall through below!
}
case 'connecting': {
return new Promise((resolve, reject) => {
let timeout = 100
const checkAgain = () => {
const rpc = this.#rpcs.get(address)
const rpc = this.#rpcs.get(multiaddr)
if (rpc) resolve(rpc)
else if (timeout > 5 * 60e3) {
// prettier-ignore
reject(new Error(`Failed to connect to ${address} after waiting a long time`))
reject(new Error(`Failed to connect to ${multiaddr} after waiting a long time`))
} else {
timeout *= 2
setTimeout(checkAgain, timeout)
@ -181,20 +184,20 @@ class Connections {
}
case 'disconnected': {
debug('Connecting to %s', address)
debug('Connecting to %s', multiaddr)
const state = /**@type {Info['state']}*/ ('connecting')
const pubkey = Connections.extractSHSEPubkey(address)
this.#infos.update(address, { state })
this.#notifyEvent({ type: state, address, pubkey })
this.#infos.update(multiaddr, { state })
this.#notifyEvent({ type: state, multiaddr, pubkey })
this.#infos.emit()
const [err, rpc] = await run(this.#peer.connect)(address)
if (err) {
this.#infos.update(address, { state: 'disconnected' })
debug('Failed to connect to %s because: %s', address, err.message)
this.#infos.update(multiaddr, { state: 'disconnected' })
debug('Failed to connect to %s because: %s', multiaddr, err.message)
this.#notifyEvent({
type: 'connecting-failed',
address,
multiaddr,
pubkey,
details: err,
})
@ -202,15 +205,15 @@ class Connections {
throw err
}
const concurrentInfo = this.#infos.get(address)
const concurrentInfo = this.#infos.get(multiaddr)
if (!concurrentInfo || concurrentInfo.state !== 'connected') {
this.#prepareConnectedRPC(address, rpc, true)
return rpc
} else {
const rpc2 = this.#rpcs.get(address)
const rpc2 = this.#rpcs.get(multiaddr)
if (!rpc2) {
// prettier-ignore
throw new Error(`Failed to connect to ${address} due to inconsistent internal state`);
throw new Error(`Failed to connect to ${multiaddr} due to inconsistent internal state`);
}
return rpc2
}
@ -218,20 +221,21 @@ class Connections {
default: {
// prettier-ignore
debug('Unexpected control flow, peer %s has bad state %o', address, prevInfo)
debug('Unexpected control flow, peer %s has bad state %o', multiaddr, prevInfo)
// prettier-ignore
throw new Error(`Unexpected control flow, peer ${address} has bad state "${prevInfo?.state ?? '?'}"`)
throw new Error(`Unexpected control flow, peer ${multiaddr} has bad state "${prevInfo?.state ?? '?'}"`)
}
}
}
/**
* @param {Address} address
* @param {Multiaddr} multiaddr
* @returns {Promise<boolean>}
*/
async disconnect(address) {
async disconnect(multiaddr) {
this.#assertNotClosed()
const prevInfo = this.#infos.get(address)
const address = Multiaddr.toMs(multiaddr)
const prevInfo = this.#infos.get(multiaddr)
if (!prevInfo || prevInfo?.state === 'disconnected') return false
if (prevInfo.state === 'disconnecting') return false
@ -241,7 +245,7 @@ class Connections {
rpc = await new Promise((resolve) => {
let timeout = 100
const checkAgain = () => {
const rpc = this.#rpcs.get(address)
const rpc = this.#rpcs.get(multiaddr)
if (rpc) resolve(rpc)
else {
timeout *= 2
@ -252,20 +256,20 @@ class Connections {
checkAgain()
})
} else if (prevInfo.state === 'connected') {
const maybeRPC = this.#rpcs.get(address)
const maybeRPC = this.#rpcs.get(multiaddr)
if (!maybeRPC) {
// prettier-ignore
throw new Error(`Failed to disconnect from ${address} due to inconsistent internal state`);
throw new Error(`Failed to disconnect from ${multiaddr} due to inconsistent internal state`);
} else {
rpc = maybeRPC
}
}
debug('Disconnecting from %s', address)
debug('Disconnecting from %s', multiaddr)
const state = /**@type {Info['state']}*/ ('disconnecting')
const pubkey = Connections.extractSHSEPubkey(address)
this.#infos.update(address, { state })
this.#notifyEvent({ type: state, address, pubkey })
this.#infos.update(multiaddr, { state })
this.#notifyEvent({ type: state, multiaddr, pubkey })
this.#infos.emit()
// @ts-ignore
await run(rpc.close)(true)
@ -273,9 +277,9 @@ class Connections {
// Re-connect because while disconnect() was running,
// someone called connect()
if (this.#connectRetries.has(address)) {
this.#connectRetries.delete(address)
this.connect(address)
if (this.#connectRetries.has(multiaddr)) {
this.#connectRetries.delete(multiaddr)
this.connect(multiaddr)
}
return true

View File

@ -3,7 +3,7 @@ const stats = require('statistics')
const ping = require('pull-ping')
/**
* @typedef {import('./index').Address} Address
* @typedef {import('./index').Multiaddr} Multiaddr
* @typedef {import('./index').RPC} RPC
* @typedef {import('./index').Peer} Peer
* @typedef {import('./connections')} Connections
@ -19,13 +19,13 @@ const PROGRAM_STARTUP = Date.now()
*/
function glue(infos, connections) {
/**
* @param {Address} address
* @param {Multiaddr} multiaddr
* @param {RPC} rpc
*/
function setupPing(address, rpc) {
function setupPing(multiaddr, rpc) {
const PING_TIMEOUT = 5 * 6e4 // 5 minutes
const pp = ping({ serve: true, timeout: PING_TIMEOUT }, () => {})
infos.updateStats(address, () => ({
infos.updateStats(multiaddr, () => ({
ping: {
rtt: pp.rtt,
skew: pp.skew,
@ -36,7 +36,7 @@ function glue(infos, connections) {
rpc.net.ping({ timeout: PING_TIMEOUT }, (err, _) => {
console.warn('remote peer ping err', err)
// if (err?.name === 'TypeError') {
// infos.update(address, {stats: {ping: {fail: true}}});
// infos.update(multiaddr, {stats: {ping: {fail: true}}});
// }
}),
pp
@ -47,7 +47,7 @@ function glue(infos, connections) {
* @param {Event} ev
*/
function onConnectingFailed(ev) {
infos.updateStats(ev.address, (prevStats) => ({
infos.updateStats(ev.multiaddr, (prevStats) => ({
failure: (prevStats?.failure ?? 0) + 1,
stateChange: Date.now(),
duration: stats(prevStats?.duration, 0),
@ -58,18 +58,18 @@ function glue(infos, connections) {
* @param {Event} ev
*/
function onConnected(ev) {
infos.updateStats(ev.address, () => ({
infos.updateStats(ev.multiaddr, () => ({
stateChange: Date.now(),
failure: 0,
}))
if (ev.details.weAreClient) setupPing(ev.address, ev.details.rpc)
if (ev.details.weAreClient) setupPing(ev.multiaddr, ev.details.rpc)
}
/**
* @param {Event} ev
*/
function bumpStateChange(ev) {
infos.updateStats(ev.address, () => ({
infos.updateStats(ev.multiaddr, () => ({
stateChange: Date.now(),
}))
}
@ -78,7 +78,7 @@ function glue(infos, connections) {
* @param {Event} ev
*/
function onDisconnected(ev) {
infos.updateStats(ev.address, (prevStats) => ({
infos.updateStats(ev.multiaddr, (prevStats) => ({
stateChange: Date.now(),
duration: stats(
prevStats?.duration,

View File

@ -9,7 +9,7 @@ const glue = require('./glue')
/**
* @typedef {import('pull-stream').Duplex<unknown, unknown>} Duplex
* @typedef {import('./connections').ConnectionEvent} ConnectionEvent
* @typedef {string} Address
* @typedef {`/${string}`} Multiaddr
* @typedef {(rpc: RPC, weAreClient: boolean) => void} RpcConnectListener
* @typedef {{
* shse: {pubkey: string};
@ -24,12 +24,14 @@ const glue = require('./glue')
* multiserver: {
* parse(address: string): any
* },
* }} Peer
* @typedef {Peer & {
* stream: {address: string}
* net: {
* ping(opts: {timeout: number}, cb: CB<void>): Duplex;
* listen(): import('pull-stream').Source<ConnectionEvent>;
* },
* }} Peer
* @typedef {Peer & {stream: {address: string}}} RPC
* }} RPC
* @typedef {{
* global: {
* path?: string
@ -97,56 +99,56 @@ function initNet(peer, config) {
}
/**
* @param {Address} address
* @param {Multiaddr} multiaddr
* @param {Partial<Info>} info
*/
function stage(address, info) {
function stage(multiaddr, info) {
if (info.state) throw new Error('Cannot stage peer info with "state" field')
if (infos.has(address)) {
if (infos.has(multiaddr)) {
return false
} else {
infos.update(address, info)
infos.update(multiaddr, info)
return true
}
}
/**
* @param {Address} address
* @param {Multiaddr} multiaddr
* @param {CB<RPC>=} cb
*/
function connect(address, cb) {
connections.connect(address).then(
function connect(multiaddr, cb) {
connections.connect(multiaddr).then(
(result) => cb?.(null, result),
(err) => cb?.(err)
)
}
/**
* @param {Address} address
* @param {Multiaddr} multiaddr
* @param {CB<boolean>=} cb
*/
function disconnect(address, cb) {
return connections.disconnect(address).then(
function disconnect(multiaddr, cb) {
return connections.disconnect(multiaddr).then(
(result) => cb?.(null, result),
(err) => cb?.(err)
)
}
/**
* @param {Address} address
* @param {Multiaddr} multiaddr
*/
function forget(address) {
disconnect(address, () => {
infos.remove(address)
function forget(multiaddr) {
disconnect(multiaddr, () => {
infos.remove(multiaddr)
})
}
/**
* @param {Address} address
* @param {Multiaddr} multiaddr
* @param {Info} info
*/
function updateInfo(address, info) {
infos.update(address, info)
function updateInfo(multiaddr, info) {
infos.update(multiaddr, info)
}
function listen() {

View File

@ -9,7 +9,7 @@ const Obz = require('obz')
*/
/**
* @typedef {import('./index').Address} Address
* @typedef {import('./index').Multiaddr} Multiaddr
* @typedef {import('./stats').StatsInfo} StatsInfo
* @typedef {{
* state: 'connected' | 'disconnected' | 'connecting' | 'disconnecting',
@ -19,11 +19,11 @@ const Obz = require('obz')
*/
class Infos {
/** @type {Map<Address, Info>} */
/** @type {Map<Multiaddr, Info>} */
#map
/** @type {ReturnType<createNotify>} */
#notify
/** @type {Obz<Address>} */
/** @type {Obz<Multiaddr>} */
#onStatsUpdated
constructor() {
@ -33,76 +33,76 @@ class Infos {
}
/**
* @param {Address} address
* @param {Multiaddr} multiaddr
* @returns {Info | undefined}
*/
get(address) {
return this.#map.get(address)
get(multiaddr) {
return this.#map.get(multiaddr)
}
/**
* @param {Address} address
* @param {Multiaddr} multiaddr
* @returns {boolean}
*/
has(address) {
return this.#map.has(address)
has(multiaddr) {
return this.#map.has(multiaddr)
}
/**
* @param {Address} address
* @param {Multiaddr} multiaddr
* @param {Partial<Info>} info
* @returns {void}
*/
update(address, info) {
update(multiaddr, info) {
const hasNewStats = !!info.stats
const prevInfo = this.#map.get(address)
const prevInfo = this.#map.get(multiaddr)
if (prevInfo) {
for (const key of Object.keys(info)) {
const k = /**@type {keyof Info}*/ (key)
if (typeof info[k] === 'undefined') delete info[k]
}
this.#map.set(address, { ...prevInfo, ...info })
this.#map.set(multiaddr, { ...prevInfo, ...info })
} else if (!info.state) {
this.#map.set(address, { ...info, state: 'disconnected' })
this.#map.set(multiaddr, { ...info, state: 'disconnected' })
} else {
this.#map.set(address, /**@type {Info}*/ (info))
this.#map.set(multiaddr, /**@type {Info}*/ (info))
}
if (hasNewStats) {
this.#onStatsUpdated.set(address)
this.#onStatsUpdated.set(multiaddr)
}
}
/**
* @param {Address} address
* @param {Multiaddr} multiaddr
* @param {(prevStats: Partial<Info['stats']>) => Partial<Info['stats']>} getStats
* @returns {void}
*/
updateStats(address, getStats) {
const prevInfo = this.#map.get(address)
updateStats(multiaddr, getStats) {
const prevInfo = this.#map.get(multiaddr)
if (!prevInfo) return
this.#map.set(address, {
this.#map.set(multiaddr, {
...prevInfo,
stats: {
...prevInfo?.stats,
...getStats(prevInfo?.stats),
},
})
this.#onStatsUpdated.set(address)
this.#onStatsUpdated.set(multiaddr)
}
/**
* @param {Parameters<Obz<Address>>[0]} listener
* @param {Parameters<Obz<Multiaddr>>[0]} listener
*/
onStatsUpdated(listener) {
return this.#onStatsUpdated(listener)
}
/**
* @param {Address} address
* @param {Multiaddr} multiaddr
*/
remove(address) {
this.#map.delete(address)
this.#onStatsUpdated.set(address)
remove(multiaddr) {
this.#map.delete(multiaddr)
this.#onStatsUpdated.set(multiaddr)
}
size() {
@ -118,7 +118,7 @@ class Infos {
}
/**
* @returns {pull.Source<[Address, Info]>}
* @returns {pull.Source<[Multiaddr, Info]>}
*/
liveEntries() {
return pullConcat([

67
lib/multiaddr.js Normal file
View File

@ -0,0 +1,67 @@
const IP = require('ip')
const Multiaddr = {
/**
* Converts (legacy) [multiserver](https://github.com/ssbc/multiserver-address)
* addresses to [multiaddr](https://multiformats.io/multiaddr/) (modern).
* @param {string} msaddr
* @returns {`/${string}`}
*/
fromMs(msaddr) {
const [msTransport, msTransform] = msaddr.split('~')
const [label1, host, port] = msTransport.split(':')
const hostFormat = IP.isV4Format(host)
? 'ip4'
: IP.isV6Format('ipv6')
? 'ip6'
: 'dns'
const transport = label1 === 'net' ? 'tcp' : label1 === 'ws' ? 'ws' : null
if (!transport) throw new Error(`Unknown transport "${label1}"`)
const soFar = `${hostFormat}/${host}/${transport}/${port}`
if (msTransform) {
const [label2, pubkey, token] = msTransform.split(':')
if (label2 !== 'shse') throw new Error(`Unknown transform "${label2}"`)
if (token) {
return `/${soFar}/shse/${pubkey}.${token}`
} else {
return `/${soFar}/shse/${pubkey}`
}
} else {
return `/${soFar}`
}
},
/**
* Converts [multiaddr](https://multiformats.io/multiaddr/) (modern) to
* [multiserver](https://github.com/ssbc/multiserver-address) address (legacy).
* @param {`/${string}`} multiaddr
* @returns {string}
*/
toMs(multiaddr) {
if (!multiaddr.startsWith('/')) {
// prettier-ignore
throw new Error(`Invalid multiaddr "${multiaddr}"`)
}
const [, , host, transport, port, transform, cred] = multiaddr.split('/')
const label1 =
transport === 'tcp' ? 'net' : transport === 'ws' ? 'ws' : null
if (!label1) throw new Error(`Unknown transport "${transport}"`)
const soFar = `${label1}:${host}:${port}`
if (transform) {
// prettier-ignore
if (transform !== 'shse') throw new Error(`Unknown transform "${transform}"`)
const [pubkey, token] = cred.split('.')
if (token) {
return `${soFar}~shse:${pubkey}:${token}`
} else {
return `${soFar}~shse:${pubkey}`
}
} else {
return soFar
}
},
}
module.exports = Multiaddr

View File

@ -4,7 +4,7 @@ const debug = require('debug')('ppppp:net:stats')
const atomic = require('atomic-file-rw')
/**
* @typedef {import('./index').Address} Address
* @typedef {import('./index').Multiaddr} Multiaddr
* @typedef {import('./infos')} Infos
* @typedef {import('statistics').Statistics} Statistics
* @typedef {{
@ -42,7 +42,7 @@ const SelfHealingJSONCodec = {
},
/**
* @param {any} input
* @returns {Record<string, any>}
* @returns {Record<`/${string}`, any>}
*/
decode(input) {
if (!input) return {}
@ -114,8 +114,10 @@ class Stats {
return
} else if (fileContents) {
const vals = SelfHealingJSONCodec.decode(fileContents)
for (const [address, statsInfo] of Object.entries(vals)) {
this.#infos.update(address, { stats: statsInfo })
for (const [multiaddr, statsInfo] of Object.entries(vals)) {
this.#infos.update(/**@type {`/${string}`}*/ (multiaddr), {
stats: statsInfo,
})
}
this.#loadedResolve(true)
debug('Loaded conn.json into ConnDB in memory')
@ -180,10 +182,10 @@ class Stats {
*/
#writeToDisk(cb) {
debug(`Begun serializing and writing ${Stats.FILENAME}`)
const record = /**@type {Record<Address, StatsInfo>}*/ ({})
for (let [address, info] of this.#infos.entries()) {
const record = /**@type {Record<Multiaddr, StatsInfo>}*/ ({})
for (let [multiaddr, info] of this.#infos.entries()) {
if (info.stats) {
record[address] = info.stats
record[multiaddr] = info.stats
}
}
const json = SelfHealingJSONCodec.encode(record)
@ -198,7 +200,7 @@ class Stats {
this.#closed = true
this.#cancelScheduleWrite()
this.#writeToDisk()
;/**@type {any}*/ (this).#infos = void 0
;/**@type {any}*/ (this.#infos) = void 0
debug('Closed the Stats instance')
}

View File

@ -1,5 +1,5 @@
{
"net:staltz.com:8008~noauth": {
"/dns/staltz.com/tcp/8008": {
"source": "stored"
}
},

View File

@ -1,5 +1,5 @@
{
"net:staltz.com:8008~noauth": {
"/dns/staltz.com/tcp/8008": {
"duration": {
"mean": 0,
"stdev": 0,

View File

@ -5,8 +5,8 @@ const Path = require('node:path')
const p = require('node:util').promisify
const { createPeerMock } = require('./util')
const TEST_ADDR =
'net:localhost:9752~shse:EqTMFv7zm8hpPyAkj789qdJgqtz81AEbcinpAs24RRUC'
const PUBKEY = 'EqTMFv7zm8hpPyAkj789qdJgqtz81AEbcinpAs24RRUC'
const TEST_ADDR = `/ip4/127.0.0.1/tcp/9752/shse/${PUBKEY}`
test('Glueing together stats with connections', async (t) => {
await t.test('stage() is ignored when peer already connected', async () => {

View File

@ -5,7 +5,7 @@ const pull = require('pull-stream')
const { createPeer } = require('./util')
const PUBKEY = 'EqTMFv7zm8hpPyAkj789qdJgqtz81AEbcinpAs24RRUC'
const TEST_ADDR = `net:localhost:9752~shse:${PUBKEY}`
const TEST_ADDR = `/ip4/127.0.0.1/tcp/9752/shse/${PUBKEY}`
test('net', async (t) => {
await t.test('connect() rejects given unreachable address', async () => {
@ -68,11 +68,11 @@ test('net', async (t) => {
++i
if (i === 1) {
assert.equal(ev.type, 'connecting', 'event.type ok')
assert.equal(ev.address, TEST_ADDR, 'event.address ok')
assert.equal(ev.multiaddr, TEST_ADDR, 'event.address ok')
assert.equal(ev.pubkey, PUBKEY, 'event.pubkey ok')
} else if (i === 2) {
assert.equal(ev.type, 'connecting-failed', 'event.type ok')
assert.equal(ev.address, TEST_ADDR, 'event.address ok')
assert.equal(ev.multiaddr, TEST_ADDR, 'event.address ok')
assert.ok(ev.details, 'event.details ok')
assert.equal(ev.details.code, 'ECONNREFUSED', 'event.details err')
queueMicrotask(resolve)

View File

@ -24,7 +24,7 @@ test('Stats', async (t) => {
const entriesAfter = Array.from(infos.entries())
assert.equal(entriesAfter.length, 1, 'after loaded(), there is data')
const [address, info] = entriesAfter[0]
assert.equal(address, 'net:staltz.com:8008~noauth', 'the address looks ok')
assert.equal(address, '/dns/staltz.com/tcp/8008', 'the address looks ok')
assert.equal(info.stats.source, 'stored', 'the info looks ok')
stats.close()
@ -64,7 +64,7 @@ test('Stats', async (t) => {
const entries = Array.from(infos.entries())
assert.equal(entries.length === 1, true, 'stats has one entry')
assert.equal(entries[0][0], 'net:staltz.com:8008~noauth', 'entry addr ok')
assert.equal(entries[0][0], '/dns/staltz.com/tcp/8008', 'entry addr ok')
assert.ok(entries[0][1].stats.duration, 'entry stats.duration ok')
})