Skip to content

Commit b927d59

Browse files
paschal533tabcatpeerbit-org
authored
fix(transport-webrtc): fix EOF race in readCandidatesUntilConnected (#3460)
* fix(transport-webrtc): fix UnexpectedEOFError race in readCandidatesUntilConnected A race condition caused intermittent failures in the WebRTC 'simple' transport compliance test: the listener closes its signaling stream just before the initiator's connectionstatechange fires for the 'connected' state. The initiator then receives EOF on the signaling stream and threw an UnexpectedEOFError, discarding a perfectly good peer connection. Root cause: connectedPromise was declared inside the try block, making it inaccessible in the catch handler for stream read errors. Fix: move connectedPromise outside the try block. In the catch handler, if the PC is not yet 'connected', await connectedPromise — if it resolves we ignore the stream error (the connection succeeded); if it rejects we re-throw the original stream error. * fix(transport-webrtc): bound signaling recovery by the dial signal Race the wait for the connection state with the dial signal and surface the abort reason, reject early if the peer connection has already failed or closed, and move the tests to their own file. Co-authored-by: Peerbit Bot <273107789+peerbit-org@users.noreply.github.com> --------- Co-authored-by: tabcat <tabcat00@proton.me> Co-authored-by: Peerbit Bot <273107789+peerbit-org@users.noreply.github.com>
1 parent aa5ba42 commit b927d59

2 files changed

Lines changed: 137 additions & 6 deletions

File tree

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

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ConnectionFailedError, InvalidMessageError, InvalidMultiaddrError } from '@libp2p/interface'
22
import { peerIdFromString } from '@libp2p/peer-id'
33
import { CustomProgressEvent } from 'progress-events'
4+
import { raceSignal } from 'race-signal'
45
import { RTCIceCandidate } from '../webrtc/index.ts'
56
import { Message } from './pb/message.ts'
67
import type { WebRTCDialEvents } from './transport.ts'
@@ -16,10 +17,10 @@ export interface ReadCandidatesOptions extends AbortOptions, LoggerOptions, Prog
1617
}
1718

1819
export const readCandidatesUntilConnected = async (pc: RTCPeerConnection, stream: MessageStream<Message, Stream>, options: ReadCandidatesOptions): Promise<void> => {
19-
try {
20-
const connectedPromise = Promise.withResolvers<void>()
21-
resolveOnConnected(pc, connectedPromise)
20+
const connectedPromise = Promise.withResolvers<void>()
21+
resolveOnConnected(pc, connectedPromise)
2222

23+
try {
2324
// read candidates until we are connected or we reach the end of the stream
2425
while (true) {
2526
// if we connect, stop trying to read from the stream
@@ -66,10 +67,16 @@ export const readCandidatesUntilConnected = async (pc: RTCPeerConnection, stream
6667
}
6768
}
6869
} catch (err) {
69-
options.log.error('%s error parsing ICE candidate - %e', options.direction, err)
70+
options.log.error('%s error reading ICE candidates - %e', options.direction, err)
7071

71-
if (options.signal?.aborted === true && pc.connectionState !== 'connected') {
72-
throw err
72+
// the signaling stream can end just before the peer connection connects
73+
if (pc.connectionState !== 'connected') {
74+
try {
75+
await raceSignal(connectedPromise.promise, options.signal)
76+
} catch {
77+
options.signal?.throwIfAborted()
78+
throw err
79+
}
7380
}
7481
}
7582
}
@@ -80,6 +87,11 @@ function resolveOnConnected (pc: RTCPeerConnection, promise: DeferredPromise<voi
8087
return
8188
}
8289

90+
if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
91+
promise.reject(new ConnectionFailedError(`RTCPeerConnection connection state was "${pc.connectionState}"`))
92+
return
93+
}
94+
8395
pc.onconnectionstatechange = (_) => {
8496
switch (pc.connectionState) {
8597
case 'connected':
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { ConnectionFailedError } from '@libp2p/interface'
2+
import { defaultLogger } from '@libp2p/logger'
3+
import { pbStream, streamPair } from '@libp2p/utils'
4+
import { expect } from 'aegir/chai'
5+
import { Message } from '../src/private-to-private/pb/message.ts'
6+
import { readCandidatesUntilConnected } from '../src/private-to-private/util.ts'
7+
import type { Stream } from '@libp2p/interface'
8+
9+
interface StubPeerConnection {
10+
connectionState: RTCPeerConnectionState
11+
onconnectionstatechange: ((evt: Event) => void) | null
12+
}
13+
14+
function createPeerConnection (connectionState: RTCPeerConnectionState): StubPeerConnection {
15+
return {
16+
connectionState,
17+
onconnectionstatechange: null
18+
}
19+
}
20+
21+
function setState (pc: StubPeerConnection, connectionState: RTCPeerConnectionState, ms: number): void {
22+
setTimeout(() => {
23+
pc.connectionState = connectionState
24+
pc.onconnectionstatechange?.(new Event('connectionstatechange'))
25+
}, ms)
26+
}
27+
28+
async function readCandidates (pc: StubPeerConnection, stream: Stream, signal: AbortSignal = AbortSignal.timeout(1_000)): Promise<void> {
29+
await readCandidatesUntilConnected(pc as any, pbStream(stream).pb(Message), {
30+
direction: 'initiator',
31+
signal,
32+
log: defaultLogger().forComponent('test:webrtc')
33+
})
34+
}
35+
36+
describe('readCandidatesUntilConnected', () => {
37+
let localStream: Stream
38+
let remoteStream: Stream
39+
40+
beforeEach(async () => {
41+
[localStream, remoteStream] = await streamPair()
42+
})
43+
44+
afterEach(() => {
45+
localStream.abort(new Error('test finished'))
46+
remoteStream.abort(new Error('test finished'))
47+
})
48+
49+
it('should resolve when the peer connection connects', async () => {
50+
const pc = createPeerConnection('connecting')
51+
setState(pc, 'connected', 50)
52+
53+
await expect(readCandidates(pc, localStream)).to.eventually.be.undefined()
54+
})
55+
56+
it('should resolve when the signaling stream ends before the peer connection connects', async () => {
57+
const pc = createPeerConnection('connecting')
58+
await remoteStream.close()
59+
setState(pc, 'connected', 50)
60+
61+
await expect(readCandidates(pc, localStream)).to.eventually.be.undefined()
62+
expect(pc.connectionState).to.equal('connected')
63+
})
64+
65+
it('should ignore a transient disconnected state', async () => {
66+
const pc = createPeerConnection('connecting')
67+
setState(pc, 'disconnected', 30)
68+
setState(pc, 'connected', 60)
69+
70+
await expect(readCandidates(pc, localStream)).to.eventually.be.undefined()
71+
})
72+
73+
it('should reject when the peer connection fails', async () => {
74+
const pc = createPeerConnection('connecting')
75+
setState(pc, 'failed', 50)
76+
77+
await expect(readCandidates(pc, localStream)).to.eventually.be.rejectedWith(ConnectionFailedError)
78+
})
79+
80+
it('should reject when the peer connection closes', async () => {
81+
const pc = createPeerConnection('connecting')
82+
setState(pc, 'closed', 50)
83+
84+
await expect(readCandidates(pc, localStream)).to.eventually.be.rejectedWith(ConnectionFailedError)
85+
})
86+
87+
it('should reject when the peer connection has already failed', async () => {
88+
const pc = createPeerConnection('failed')
89+
90+
await expect(readCandidates(pc, localStream)).to.eventually.be.rejectedWith(ConnectionFailedError)
91+
})
92+
93+
it('should reject when the signaling stream ends and the peer connection fails', async () => {
94+
const pc = createPeerConnection('connecting')
95+
await remoteStream.close()
96+
setState(pc, 'failed', 50)
97+
98+
await expect(readCandidates(pc, localStream)).to.eventually.be.rejected()
99+
})
100+
101+
it('should reject with the abort reason when aborted while the peer connection is connecting', async () => {
102+
const pc = createPeerConnection('connecting')
103+
const controller = new AbortController()
104+
const reason = new Error('dial cancelled')
105+
setTimeout(() => { controller.abort(reason) }, 50)
106+
107+
await expect(readCandidates(pc, localStream, controller.signal)).to.eventually.be.rejectedWith(reason)
108+
})
109+
110+
it('should reject with the abort reason when aborted after the signaling stream ends', async () => {
111+
const pc = createPeerConnection('connecting')
112+
await remoteStream.close()
113+
const controller = new AbortController()
114+
const reason = new Error('dial cancelled')
115+
setTimeout(() => { controller.abort(reason) }, 50)
116+
117+
await expect(readCandidates(pc, localStream, controller.signal)).to.eventually.be.rejectedWith(reason)
118+
})
119+
})

0 commit comments

Comments
 (0)