Skip to content

Commit 0b4a2ee

Browse files
authored
fix: use optimistic protocol negotation (#2253)
When negotiating connection encrypters, multiplexers or stream protocols, if we only have one protocol to negotiate there's no point sending it, then waiting for a response, then sending some data, we can just send it, and the data, then assuming the remote doesn't immediately close the negotiation channel, read the response at our leisure. This saves a round trip for the first chunk of stream data and reduces our connection latency in the [libp2p perf tests](https://observablehq.com/@libp2p-workspace/performance-dashboard?branch=c8022cb77397759bd7e71a73e93f9074854989fe) from 0.45 to 0.3ms. It changes stream behaviour a little, since we now don't start the protocol negotiation until we interact with the stream (e.g. try to read or write data) and most of our tests assume that negotiation has succeeded when the stream is returned so it's not been a straightforward fix.
1 parent 6b6ba9a commit 0b4a2ee

File tree

19 files changed

+408
-97
lines changed

19 files changed

+408
-97
lines changed

packages/integration-tests/test/circuit-relay.node.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -522,7 +522,7 @@ describe('circuit-relay', () => {
522522
expect(conns).to.have.lengthOf(1)
523523

524524
// this should fail as the local peer has HOP disabled
525-
await expect(conns[0].newStream(RELAY_V2_HOP_CODEC))
525+
await expect(conns[0].newStream([RELAY_V2_HOP_CODEC, '/other/1.0.0']))
526526
.to.be.rejected()
527527

528528
// we should still be connected to the relay

packages/interface-compliance-tests/src/pubsub/two-nodes.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export default (common: TestSetup<PubSub, PubSubArgs>): void => {
7373
expect(psA.getTopics()).to.deep.equal([topic])
7474
expect(psB.getPeers()).to.have.lengthOf(1)
7575
expect(psB.getSubscribers(topic).map(p => p.toString())).to.deep.equal([componentsA.peerId.toString()])
76-
expect(changedPeerId).to.deep.equal(psB.getPeers()[0])
76+
expect(changedPeerId.toString()).to.equal(psB.getPeers()[0].toString())
7777
expect(changedSubs).to.have.lengthOf(1)
7878
expect(changedSubs[0].topic).to.equal(topic)
7979
expect(changedSubs[0].subscribe).to.equal(true)
@@ -243,7 +243,7 @@ export default (common: TestSetup<PubSub, PubSubArgs>): void => {
243243
const { peerId: changedPeerId, subscriptions: changedSubs } = evt.detail
244244
expect(psB.getPeers()).to.have.lengthOf(1)
245245
expect(psB.getTopics()).to.be.empty()
246-
expect(changedPeerId).to.deep.equal(psB.getPeers()[0])
246+
expect(changedPeerId.toString()).to.equal(psB.getPeers()[0].toString())
247247
expect(changedSubs).to.have.lengthOf(1)
248248
expect(changedSubs[0].topic).to.equal(topic)
249249
expect(changedSubs[0].subscribe).to.equal(true)
@@ -252,7 +252,7 @@ export default (common: TestSetup<PubSub, PubSubArgs>): void => {
252252
const { peerId: changedPeerId, subscriptions: changedSubs } = evt.detail
253253
expect(psB.getPeers()).to.have.lengthOf(1)
254254
expect(psB.getTopics()).to.be.empty()
255-
expect(changedPeerId).to.deep.equal(psB.getPeers()[0])
255+
expect(changedPeerId.toString()).to.equal(psB.getPeers()[0].toString())
256256
expect(changedSubs).to.have.lengthOf(1)
257257
expect(changedSubs[0].topic).to.equal(topic)
258258
expect(changedSubs[0].subscribe).to.equal(false)

packages/libp2p/.aegir.js

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export default {
1818
const { plaintext } = await import('@libp2p/plaintext')
1919
const { circuitRelayServer, circuitRelayTransport } = await import('@libp2p/circuit-relay-v2')
2020
const { identify } = await import('@libp2p/identify')
21+
const { echo, ECHO_PROTOCOL } = await import('./dist/test/fixtures/echo-service.js')
2122

2223
const peerId = await createEd25519PeerId()
2324
const libp2p = await createLibp2p({
@@ -49,14 +50,10 @@ export default {
4950
reservations: {
5051
maxReservations: Infinity
5152
}
52-
})
53+
}),
54+
echo: echo()
5355
}
5456
})
55-
// Add the echo protocol
56-
await libp2p.handle('/echo/1.0.0', ({ stream }) => {
57-
pipe(stream, stream)
58-
.catch() // sometimes connections are closed before multistream-select finishes which causes an error
59-
})
6057

6158
return {
6259
libp2p,

packages/libp2p/src/connection-manager/dial-queue.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,7 @@ export class DialQueue {
461461
// internal peer dial queue - only one dial per peer at a time
462462
const peerDialQueue = new PQueue({ concurrency: 1 })
463463
peerDialQueue.on('error', (err) => {
464-
this.log.error('error dialing [%s] %o', pendingDial.multiaddrs, err)
464+
this.log.error('error dialing %s %o', pendingDial.multiaddrs, err)
465465
})
466466

467467
const conn = await Promise.any(pendingDial.multiaddrs.map(async (addr, i) => {

packages/libp2p/src/upgrader.ts

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -117,14 +117,12 @@ export class DefaultUpgrader implements Upgrader {
117117
private readonly muxers: Map<string, StreamMuxerFactory>
118118
private readonly inboundUpgradeTimeout: number
119119
private readonly events: TypedEventTarget<Libp2pEvents>
120-
private readonly logger: ComponentLogger
121120
private readonly log: Logger
122121

123122
constructor (components: DefaultUpgraderComponents, init: UpgraderInit) {
124123
this.components = components
125124
this.connectionEncryption = new Map()
126125
this.log = components.logger.forComponent('libp2p:upgrader')
127-
this.logger = components.logger
128126

129127
init.connectionEncryption.forEach(encrypter => {
130128
this.connectionEncryption.set(encrypter.protocol, encrypter)
@@ -415,6 +413,21 @@ export class DefaultUpgrader implements Upgrader {
415413
muxedStream.sink = stream.sink
416414
muxedStream.protocol = protocol
417415

416+
// allow closing the write end of a not-yet-negotiated stream
417+
if (stream.closeWrite != null) {
418+
muxedStream.closeWrite = stream.closeWrite
419+
}
420+
421+
// allow closing the read end of a not-yet-negotiated stream
422+
if (stream.closeRead != null) {
423+
muxedStream.closeRead = stream.closeRead
424+
}
425+
426+
// make sure we don't try to negotiate a stream we are closing
427+
if (stream.close != null) {
428+
muxedStream.close = stream.close
429+
}
430+
418431
// If a protocol stream has been successfully negotiated and is to be passed to the application,
419432
// the peerstore should ensure that the peer is registered with that protocol
420433
await this.components.peerStore.merge(remotePeer, {
@@ -426,7 +439,7 @@ export class DefaultUpgrader implements Upgrader {
426439
this._onStream({ connection, stream: muxedStream, protocol })
427440
})
428441
.catch(async err => {
429-
this.log.error('error handling incoming stream id %d', muxedStream.id, err.message, err.code, err.stack)
442+
this.log.error('error handling incoming stream id %s', muxedStream.id, err.message, err.code, err.stack)
430443

431444
if (muxedStream.timeline.close == null) {
432445
await muxedStream.close()
@@ -440,13 +453,13 @@ export class DefaultUpgrader implements Upgrader {
440453
throw new CodeError('Stream is not multiplexed', codes.ERR_MUXER_UNAVAILABLE)
441454
}
442455

443-
connection.log('starting new stream for protocols [%s]', protocols)
456+
connection.log('starting new stream for protocols %s', protocols)
444457
const muxedStream = await muxer.newStream()
445-
connection.log.trace('starting new stream %s for protocols [%s]', muxedStream.id, protocols)
458+
connection.log.trace('started new stream %s for protocols %s', muxedStream.id, protocols)
446459

447460
try {
448461
if (options.signal == null) {
449-
this.log('No abort signal was passed while trying to negotiate protocols [%s] falling back to default timeout', protocols)
462+
this.log('No abort signal was passed while trying to negotiate protocols %s falling back to default timeout', protocols)
450463

451464
const signal = AbortSignal.timeout(DEFAULT_PROTOCOL_SELECT_TIMEOUT)
452465
setMaxListeners(Infinity, signal)
@@ -457,13 +470,18 @@ export class DefaultUpgrader implements Upgrader {
457470
}
458471
}
459472

460-
const { stream, protocol } = await mss.select(muxedStream, protocols, {
473+
muxedStream.log.trace('selecting protocol from protocols %s', protocols)
474+
475+
const {
476+
stream,
477+
protocol
478+
} = await mss.select(muxedStream, protocols, {
461479
...options,
462480
log: muxedStream.log,
463-
yieldBytes: false
481+
yieldBytes: true
464482
})
465483

466-
connection.log('negotiated protocol stream %s with id %s', protocol, muxedStream.id)
484+
muxedStream.log('selected protocol %s', protocol)
467485

468486
const outgoingLimit = findOutgoingStreamLimit(protocol, this.components.registrar, options)
469487
const streamCount = countStreams(protocol, 'outbound', connection)
@@ -487,6 +505,21 @@ export class DefaultUpgrader implements Upgrader {
487505
muxedStream.sink = stream.sink
488506
muxedStream.protocol = protocol
489507

508+
// allow closing the write end of a not-yet-negotiated stream
509+
if (stream.closeWrite != null) {
510+
muxedStream.closeWrite = stream.closeWrite
511+
}
512+
513+
// allow closing the read end of a not-yet-negotiated stream
514+
if (stream.closeRead != null) {
515+
muxedStream.closeRead = stream.closeRead
516+
}
517+
518+
// make sure we don't try to negotiate a stream we are closing
519+
if (stream.close != null) {
520+
muxedStream.close = stream.close
521+
}
522+
490523
this.components.metrics?.trackProtocolStream(muxedStream, connection)
491524

492525
return muxedStream
@@ -637,16 +670,23 @@ export class DefaultUpgrader implements Upgrader {
637670
this.log('selecting outbound crypto protocol', protocols)
638671

639672
try {
640-
const { stream, protocol } = await mss.select(connection, protocols, {
641-
log: this.logger.forComponent('libp2p:mss:select')
673+
connection.log.trace('selecting encrypter from %s', protocols)
674+
675+
const {
676+
stream,
677+
protocol
678+
} = await mss.select(connection, protocols, {
679+
log: connection.log,
680+
yieldBytes: true
642681
})
682+
643683
const encrypter = this.connectionEncryption.get(protocol)
644684

645685
if (encrypter == null) {
646686
throw new Error(`no crypto module found for ${protocol}`)
647687
}
648688

649-
this.log('encrypting outbound connection to %p', remotePeerId)
689+
connection.log('encrypting outbound connection to %p using %p', remotePeerId)
650690

651691
return {
652692
...await encrypter.secureOutbound(this.components.peerId, stream, remotePeerId),
@@ -665,15 +705,22 @@ export class DefaultUpgrader implements Upgrader {
665705
const protocols = Array.from(muxers.keys())
666706
this.log('outbound selecting muxer %s', protocols)
667707
try {
668-
const { stream, protocol } = await mss.select(connection, protocols, {
669-
log: this.logger.forComponent('libp2p:mss:select')
708+
connection.log.trace('selecting stream muxer from %s', protocols)
709+
710+
const {
711+
stream,
712+
protocol
713+
} = await mss.select(connection, protocols, {
714+
log: connection.log,
715+
yieldBytes: true
670716
})
671-
this.log('%s selected as muxer protocol', protocol)
717+
718+
connection.log('selected %s as muxer protocol', protocol)
672719
const muxerFactory = muxers.get(protocol)
673720

674721
return { stream, muxerFactory }
675722
} catch (err: any) {
676-
this.log.error('error multiplexing outbound stream', err)
723+
connection.log.error('error multiplexing outbound stream', err)
677724
throw new CodeError(String(err), codes.ERR_MUXER_UNAVAILABLE)
678725
}
679726
}

packages/libp2p/test/connection-manager/direct.node.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { DefaultConnectionManager } from '../../src/connection-manager/index.js'
3333
import { codes as ErrorCodes } from '../../src/errors.js'
3434
import { createLibp2pNode, type Libp2pNode } from '../../src/libp2p.js'
3535
import { DefaultTransportManager } from '../../src/transport-manager.js'
36+
import { ECHO_PROTOCOL, echo } from '../fixtures/echo-service.js'
3637
import type { PeerId } from '@libp2p/interface/peer-id'
3738
import type { TransportManager } from '@libp2p/interface-internal/transport-manager'
3839
import type { Multiaddr } from '@multiformats/multiaddr'
@@ -303,10 +304,10 @@ describe('libp2p.dialer (direct, TCP)', () => {
303304
],
304305
connectionEncryption: [
305306
plaintext()
306-
]
307-
})
308-
await remoteLibp2p.handle('/echo/1.0.0', ({ stream }) => {
309-
void pipe(stream, stream)
307+
],
308+
services: {
309+
echo: echo()
310+
}
310311
})
311312

312313
await remoteLibp2p.start()
@@ -348,9 +349,9 @@ describe('libp2p.dialer (direct, TCP)', () => {
348349

349350
const connection = await libp2p.dial(remotePeerId)
350351
expect(connection).to.exist()
351-
const stream = await connection.newStream('/echo/1.0.0')
352+
const stream = await connection.newStream(ECHO_PROTOCOL)
352353
expect(stream).to.exist()
353-
expect(stream).to.have.property('protocol', '/echo/1.0.0')
354+
expect(stream).to.have.property('protocol', ECHO_PROTOCOL)
354355
await connection.close()
355356
})
356357

@@ -388,7 +389,7 @@ describe('libp2p.dialer (direct, TCP)', () => {
388389
const connection = await libp2p.dial(remoteLibp2p.getMultiaddrs())
389390

390391
// Create local to remote streams
391-
const stream = await connection.newStream('/echo/1.0.0')
392+
const stream = await connection.newStream([ECHO_PROTOCOL, '/other/1.0.0'])
392393
await connection.newStream('/stream-count/3')
393394
await libp2p.dialProtocol(remoteLibp2p.peerId, '/stream-count/4')
394395

@@ -398,8 +399,8 @@ describe('libp2p.dialer (direct, TCP)', () => {
398399
source.push(uint8ArrayFromString('hello'))
399400

400401
// Create remote to local streams
401-
await remoteLibp2p.dialProtocol(libp2p.peerId, '/stream-count/1')
402-
await remoteLibp2p.dialProtocol(libp2p.peerId, '/stream-count/2')
402+
await remoteLibp2p.dialProtocol(libp2p.peerId, ['/stream-count/1', '/other/1.0.0'])
403+
await remoteLibp2p.dialProtocol(libp2p.peerId, ['/stream-count/2', '/other/1.0.0'])
403404

404405
// Verify stream count
405406
const remoteConn = remoteLibp2p.getConnections(libp2p.peerId)
@@ -497,9 +498,9 @@ describe('libp2p.dialer (direct, TCP)', () => {
497498

498499
const connection = await libp2p.dial(remoteAddr)
499500
expect(connection).to.exist()
500-
const stream = await connection.newStream('/echo/1.0.0')
501+
const stream = await connection.newStream(ECHO_PROTOCOL)
501502
expect(stream).to.exist()
502-
expect(stream).to.have.property('protocol', '/echo/1.0.0')
503+
expect(stream).to.have.property('protocol', ECHO_PROTOCOL)
503504
await connection.close()
504505
expect(protectorProtectSpy.callCount).to.equal(1)
505506
})

packages/libp2p/test/connection-manager/index.node.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { start } from '@libp2p/interface/startable'
55
import { mockConnection, mockDuplex, mockMultiaddrConnection } from '@libp2p/interface-compliance-tests/mocks'
66
import { expect } from 'aegir/chai'
77
import delay from 'delay'
8+
import all from 'it-all'
9+
import { pipe } from 'it-pipe'
810
import pWaitFor from 'p-wait-for'
911
import sinon from 'sinon'
1012
import { stubInterface } from 'sinon-ts'
@@ -13,6 +15,7 @@ import { DefaultConnectionManager } from '../../src/connection-manager/index.js'
1315
import { codes } from '../../src/errors.js'
1416
import { createBaseOptions } from '../fixtures/base-options.browser.js'
1517
import { createNode, createPeerId } from '../fixtures/creators/peer.js'
18+
import { ECHO_PROTOCOL, echo } from '../fixtures/echo-service.js'
1619
import type { Libp2p } from '../../src/index.js'
1720
import type { Libp2pNode } from '../../src/libp2p.js'
1821
import type { ConnectionGater } from '@libp2p/interface/connection-gater'
@@ -401,6 +404,9 @@ describe('libp2p.connections', () => {
401404
peerId: peerIds[1],
402405
addresses: {
403406
listen: ['/ip4/127.0.0.1/tcp/0/ws']
407+
},
408+
services: {
409+
echo: echo()
404410
}
405411
})
406412
})
@@ -591,16 +597,23 @@ describe('libp2p.connections', () => {
591597
},
592598
connectionGater: {
593599
denyInboundUpgradedConnection
600+
},
601+
services: {
602+
echo: echo()
594603
}
595604
})
596605
})
597606
await remoteLibp2p.peerStore.patch(libp2p.peerId, {
598607
multiaddrs: libp2p.getMultiaddrs()
599608
})
600-
await remoteLibp2p.dial(libp2p.peerId)
609+
const connection = await remoteLibp2p.dial(libp2p.peerId)
610+
const stream = await connection.newStream(ECHO_PROTOCOL)
611+
const input = [Uint8Array.from([0])]
612+
const output = await pipe(input, stream, async (source) => all(source))
601613

602614
expect(denyInboundUpgradedConnection.called).to.be.true()
603615
expect(denyInboundUpgradedConnection.getCall(0)).to.have.nested.property('args[0].multihash.digest').that.equalBytes(remoteLibp2p.peerId.multihash.digest)
616+
expect(output.map(b => b.subarray())).to.deep.equal(input)
604617
})
605618

606619
it('intercept outbound upgraded', async () => {
@@ -620,10 +633,14 @@ describe('libp2p.connections', () => {
620633
await libp2p.peerStore.patch(remoteLibp2p.peerId, {
621634
multiaddrs: remoteLibp2p.getMultiaddrs()
622635
})
623-
await libp2p.dial(remoteLibp2p.peerId)
636+
const connection = await libp2p.dial(remoteLibp2p.peerId)
637+
const stream = await connection.newStream(ECHO_PROTOCOL)
638+
const input = [Uint8Array.from([0])]
639+
const output = await pipe(input, stream, async (source) => all(source))
624640

625641
expect(denyOutboundUpgradedConnection.called).to.be.true()
626642
expect(denyOutboundUpgradedConnection.getCall(0)).to.have.nested.property('args[0].multihash.digest').that.equalBytes(remoteLibp2p.peerId.multihash.digest)
643+
expect(output.map(b => b.subarray())).to.deep.equal(input)
627644
})
628645
})
629646
})
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export const subsystemMulticodecs = [
2-
'/ipfs/lan/kad/1.0.0'
2+
'/ipfs/lan/kad/1.0.0',
3+
'/other/1.0.0'
34
]

0 commit comments

Comments
 (0)