Skip to content

Commit 64a915a

Browse files
achingbrainmaschad
andauthoredDec 5, 2023
fix: WebRTC transport unhandled promise rejection during connect (#2299)
Fixes a crash in node where the abort signal passed into a WebRTC private to private dial would cause an unhandled promise rejection: ``` file:///Users/alex/Documents/Workspaces/ipfs/helia-http-gateway/node_modules/@libp2p/webrtc/dist/src/private-to-private/initiate-connection.js:42 connectedPromise.reject(new CodeError('SDP handshake aborted', 'ERR_SDP_HANDSHAKE_ABORTED')); ^ CodeError: SDP handshake aborted at EventTarget.sdpAbortedListener (file:///Users/alex/Documents/Workspaces/ipfs/helia-http-gateway/node_modules/@libp2p/webrtc/dist/src/private-to-private/initiate-connection.js:42:37) at [nodejs.internal.kHybridDispatch] (node:internal/event_target:807:20) at EventTarget.dispatchEvent (node:internal/event_target:742:26) at abortSignal (node:internal/abort_controller:369:10) at AbortController.abort (node:internal/abort_controller:391:5) at EventTarget.onAbort (file:///Users/alex/Documents/Workspaces/ipfs/helia-http-gateway/node_modules/any-signal/dist/src/index.js:8:20) at [nodejs.internal.kHybridDispatch] (node:internal/event_target:807:20) at EventTarget.dispatchEvent (node:internal/event_target:742:26) at abortSignal (node:internal/abort_controller:369:10) at AbortController.abort (node:internal/abort_controller:391:5) { code: 'ERR_SDP_HANDSHAKE_ABORTED', props: {} } Node.js v20.8.0 ``` Simplifies the connection logic to just use the abort signal to abort the dial instead of the abort signal and multiple deferred promises. --------- Co-authored-by: Chad Nehemiah <chad.nehemiah94@gmail.com>
1 parent 57944fa commit 64a915a

File tree

6 files changed

+81
-78
lines changed

6 files changed

+81
-78
lines changed
 

‎packages/transport-webrtc/package.json

-1
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@
5858
"@multiformats/mafmt": "^12.1.6",
5959
"@multiformats/multiaddr": "^12.1.10",
6060
"@multiformats/multiaddr-matcher": "^1.1.0",
61-
"any-signal": "^4.1.1",
6261
"detect-browser": "^5.3.0",
6362
"it-length-prefixed": "^9.0.3",
6463
"it-pipe": "^3.0.1",

‎packages/transport-webrtc/src/private-to-private/initiate-connection.ts

+3-15
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { CodeError } from '@libp2p/interface'
22
import { peerIdFromString } from '@libp2p/peer-id'
33
import { pbStream } from 'it-protobuf-stream'
4-
import pDefer, { type DeferredPromise } from 'p-defer'
54
import { type RTCPeerConnection, RTCSessionDescription } from '../webrtc/index.js'
65
import { Message } from './pb/message.js'
76
import { SIGNALING_PROTO_ID, splitAddr, type WebRTCTransportMetrics } from './transport.js'
8-
import { readCandidatesUntilConnected, resolveOnConnected } from './util.js'
7+
import { readCandidatesUntilConnected } from './util.js'
98
import type { DataChannelOptions } from '../index.js'
109
import type { LoggerOptions, Connection } from '@libp2p/interface'
1110
import type { ConnectionManager, IncomingStreamData, TransportManager } from '@libp2p/interface-internal'
@@ -65,17 +64,8 @@ export async function initiateConnection ({ peerConnection, signal, metrics, mul
6564
})
6665

6766
const messageStream = pbStream(stream).pb(Message)
68-
const connectedPromise: DeferredPromise<void> = pDefer()
69-
const sdpAbortedListener = (): void => {
70-
connectedPromise.reject(new CodeError('SDP handshake aborted', 'ERR_SDP_HANDSHAKE_ABORTED'))
71-
}
7267

7368
try {
74-
resolveOnConnected(peerConnection, connectedPromise)
75-
76-
// reject the connectedPromise if the signal aborts
77-
signal?.addEventListener('abort', sdpAbortedListener)
78-
7969
// we create the channel so that the RTCPeerConnection has a component for
8070
// which to collect candidates. The label is not relevant to connection
8171
// initiation but can be useful for debugging
@@ -102,7 +92,7 @@ export async function initiateConnection ({ peerConnection, signal, metrics, mul
10292
})
10393
}
10494
peerConnection.onicecandidateerror = (event) => {
105-
log('initiator ICE candidate error', event)
95+
log.error('initiator ICE candidate error', event)
10696
}
10797

10898
// create an offer
@@ -140,7 +130,7 @@ export async function initiateConnection ({ peerConnection, signal, metrics, mul
140130

141131
log.trace('initiator read candidates until connected')
142132

143-
await readCandidatesUntilConnected(connectedPromise, peerConnection, messageStream, {
133+
await readCandidatesUntilConnected(peerConnection, messageStream, {
144134
direction: 'initiator',
145135
signal,
146136
log
@@ -164,8 +154,6 @@ export async function initiateConnection ({ peerConnection, signal, metrics, mul
164154
stream.abort(err)
165155
throw err
166156
} finally {
167-
// remove event listeners
168-
signal?.removeEventListener('abort', sdpAbortedListener)
169157
peerConnection.onicecandidate = null
170158
peerConnection.onicecandidateerror = null
171159
}

‎packages/transport-webrtc/src/private-to-private/signaling-stream-handler.ts

+23-33
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { pbStream } from 'it-protobuf-stream'
44
import pDefer, { type DeferredPromise } from 'p-defer'
55
import { type RTCPeerConnection, RTCSessionDescription } from '../webrtc/index.js'
66
import { Message } from './pb/message.js'
7-
import { readCandidatesUntilConnected, resolveOnConnected } from './util.js'
7+
import { readCandidatesUntilConnected } from './util.js'
88
import type { Logger } from '@libp2p/interface'
99
import type { IncomingStreamData } from '@libp2p/interface-internal'
1010

@@ -20,41 +20,29 @@ export async function handleIncomingStream ({ peerConnection, stream, signal, co
2020
const messageStream = pbStream(stream).pb(Message)
2121

2222
try {
23-
const connectedPromise: DeferredPromise<void> = pDefer()
2423
const answerSentPromise: DeferredPromise<void> = pDefer()
2524

26-
signal.onabort = () => {
27-
connectedPromise.reject(new CodeError('Timed out while trying to connect', 'ERR_TIMEOUT'))
28-
}
29-
3025
// candidate callbacks
3126
peerConnection.onicecandidate = ({ candidate }) => {
32-
answerSentPromise.promise.then(
33-
async () => {
34-
// a null candidate means end-of-candidates, an empty string candidate
35-
// means end-of-candidates for this generation, otherwise this should
36-
// be a valid candidate object
37-
// see - https://www.w3.org/TR/webrtc/#rtcpeerconnectioniceevent
38-
const data = JSON.stringify(candidate?.toJSON() ?? null)
39-
40-
log.trace('recipient sending ICE candidate %s', data)
41-
42-
await messageStream.write({
43-
type: Message.Type.ICE_CANDIDATE,
44-
data
45-
}, {
46-
signal
47-
})
48-
},
49-
(err) => {
50-
log.error('cannot set candidate since sending answer failed', err)
51-
connectedPromise.reject(err)
52-
}
53-
)
27+
// a null candidate means end-of-candidates, an empty string candidate
28+
// means end-of-candidates for this generation, otherwise this should
29+
// be a valid candidate object
30+
// see - https://www.w3.org/TR/webrtc/#rtcpeerconnectioniceevent
31+
const data = JSON.stringify(candidate?.toJSON() ?? null)
32+
33+
log.trace('recipient sending ICE candidate %s', data)
34+
35+
messageStream.write({
36+
type: Message.Type.ICE_CANDIDATE,
37+
data
38+
}, {
39+
signal
40+
})
41+
.catch(err => {
42+
log.error('error sending ICE candidate', err)
43+
})
5444
}
5545

56-
resolveOnConnected(peerConnection, connectedPromise)
57-
5846
// read an SDP offer
5947
const pbOffer = await messageStream.read({
6048
signal
@@ -90,18 +78,20 @@ export async function handleIncomingStream ({ peerConnection, stream, signal, co
9078
signal
9179
})
9280

93-
await peerConnection.setLocalDescription(answer).catch(err => {
81+
peerConnection.setLocalDescription(answer).then(() => {
82+
answerSentPromise.resolve()
83+
}, err => {
9484
log.error('could not execute setLocalDescription', err)
9585
answerSentPromise.reject(err)
9686
throw new CodeError('Failed to set localDescription', 'ERR_SDP_HANDSHAKE_FAILED')
9787
})
9888

99-
answerSentPromise.resolve()
89+
await answerSentPromise.promise
10090

10191
log.trace('recipient read candidates until connected')
10292

10393
// wait until candidates are connected
104-
await readCandidatesUntilConnected(connectedPromise, peerConnection, messageStream, {
94+
await readCandidatesUntilConnected(peerConnection, messageStream, {
10595
direction: 'recipient',
10696
signal,
10797
log

‎packages/transport-webrtc/src/private-to-private/transport.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { CodeError } from '@libp2p/interface'
1+
import { CodeError, setMaxListeners } from '@libp2p/interface'
22
import { type CreateListenerOptions, type DialOptions, transportSymbol, type Transport, type Listener, type Upgrader, type ComponentLogger, type Logger, type Connection, type PeerId, type CounterGroup, type Metrics, type Startable } from '@libp2p/interface'
33
import { peerIdFromString } from '@libp2p/peer-id'
44
import { multiaddr, type Multiaddr } from '@multiformats/multiaddr'
@@ -56,6 +56,7 @@ export class WebRTCTransport implements Transport, Startable {
5656
) {
5757
this.log = components.logger.forComponent('libp2p:webrtc')
5858
this.shutdownController = new AbortController()
59+
setMaxListeners(Infinity, this.shutdownController.signal)
5960

6061
if (components.metrics != null) {
6162
this.metrics = {

‎packages/transport-webrtc/src/private-to-private/util.ts

+19-28
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { CodeError } from '@libp2p/interface'
2-
import { closeSource } from '@libp2p/utils/close-source'
3-
import { anySignal } from 'any-signal'
2+
import pDefer from 'p-defer'
43
import { isFirefox } from '../util.js'
54
import { RTCIceCandidate } from '../webrtc/index.js'
65
import { Message } from './pb/message.js'
@@ -12,32 +11,19 @@ export interface ReadCandidatesOptions extends AbortOptions, LoggerOptions {
1211
direction: string
1312
}
1413

15-
export const readCandidatesUntilConnected = async (connectedPromise: DeferredPromise<void>, pc: RTCPeerConnection, stream: MessageStream<Message, Stream>, options: ReadCandidatesOptions): Promise<void> => {
16-
// if we connect, stop trying to read from the stream
17-
const controller = new AbortController()
18-
connectedPromise.promise.then(() => {
19-
controller.abort()
20-
}, () => {
21-
controller.abort()
22-
})
23-
24-
const signal = anySignal([
25-
controller.signal,
26-
options.signal
27-
])
28-
29-
const abortListener = (): void => {
30-
closeSource(stream.unwrap().unwrap().source, options.log)
31-
}
32-
33-
signal.addEventListener('abort', abortListener)
34-
14+
export const readCandidatesUntilConnected = async (pc: RTCPeerConnection, stream: MessageStream<Message, Stream>, options: ReadCandidatesOptions): Promise<void> => {
3515
try {
16+
const connectedPromise: DeferredPromise<void> = pDefer()
17+
resolveOnConnected(pc, connectedPromise)
18+
3619
// read candidates until we are connected or we reach the end of the stream
3720
while (true) {
21+
// if we connect, stop trying to read from the stream
3822
const message = await Promise.race([
3923
connectedPromise.promise,
40-
stream.read()
24+
stream.read({
25+
signal: options.signal
26+
})
4127
])
4228

4329
// stream ended or we became connected
@@ -72,15 +58,20 @@ export const readCandidatesUntilConnected = async (connectedPromise: DeferredPro
7258
}
7359
} catch (err) {
7460
options.log.error('%s error parsing ICE candidate', options.direction, err)
75-
} finally {
76-
signal.removeEventListener('abort', abortListener)
77-
signal.clear()
61+
62+
if (options.signal?.aborted === true) {
63+
throw err
64+
}
7865
}
7966
}
8067

81-
export function resolveOnConnected (pc: RTCPeerConnection, promise: DeferredPromise<void>): void {
68+
function getConnectionState (pc: RTCPeerConnection): string {
69+
return isFirefox ? pc.iceConnectionState : pc.connectionState
70+
}
71+
72+
function resolveOnConnected (pc: RTCPeerConnection, promise: DeferredPromise<void>): void {
8273
pc[isFirefox ? 'oniceconnectionstatechange' : 'onconnectionstatechange'] = (_) => {
83-
switch (isFirefox ? pc.iceConnectionState : pc.connectionState) {
74+
switch (getConnectionState(pc)) {
8475
case 'connected':
8576
promise.resolve()
8677
break

‎packages/transport-webrtc/test/peer.spec.ts

+34
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { defaultLogger, logger } from '@libp2p/logger'
33
import { createEd25519PeerId } from '@libp2p/peer-id-factory'
44
import { multiaddr, type Multiaddr } from '@multiformats/multiaddr'
55
import { expect } from 'aegir/chai'
6+
import delay from 'delay'
67
import { detect } from 'detect-browser'
78
import { duplexPair } from 'it-pair/duplex'
89
import { pbStream } from 'it-protobuf-stream'
@@ -117,6 +118,39 @@ describe('webrtc basic', () => {
117118
initiator.peerConnection.close()
118119
recipient.peerConnection.close()
119120
})
121+
122+
it('should survive aborting during connection', async () => {
123+
const abortController = new AbortController()
124+
const { initiator, recipient } = await getComponents()
125+
126+
// no existing connection
127+
initiator.connectionManager.getConnections.returns([])
128+
129+
// transport manager dials recipient
130+
initiator.transportManager.dial.resolves(initiator.connection)
131+
132+
const createOffer = initiator.peerConnection.setRemoteDescription.bind(initiator.peerConnection)
133+
134+
initiator.peerConnection.setRemoteDescription = async (name) => {
135+
// the dial is aborted
136+
abortController.abort(new Error('Oh noes!'))
137+
// setting the description takes some time
138+
await delay(100)
139+
return createOffer(name)
140+
}
141+
142+
// signalling stream opens successfully
143+
initiator.connection.newStream.withArgs(SIGNALING_PROTO_ID).resolves(initiator.stream)
144+
145+
await expect(Promise.all([
146+
initiateConnection({
147+
...initiator,
148+
signal: abortController.signal
149+
}),
150+
handleIncomingStream(recipient)
151+
]))
152+
.to.eventually.be.rejected.with.property('message', 'Oh noes!')
153+
})
120154
})
121155

122156
describe('webrtc receiver', () => {

0 commit comments

Comments
 (0)
Please sign in to comment.