diff --git a/src/frontend/src/features/diagnostics/checks/selectedCandidate.ts b/src/frontend/src/features/diagnostics/checks/selectedCandidate.ts new file mode 100644 index 00000000..7ca26699 --- /dev/null +++ b/src/frontend/src/features/diagnostics/checks/selectedCandidate.ts @@ -0,0 +1,257 @@ +import { Checker, Track, type CheckInfo } from 'livekit-client' + +/** + * Addresses are useful to a network administrator (they identify the egress IP + * and the SFU endpoint actually reached) but they also land in a downloadable + * report. Flip this to false to keep only the candidate types and protocols. + */ +const INCLUDE_CANDIDATE_ADDRESSES = true + +/** Beyond this, the log becomes noise rather than evidence. */ +const MAX_LOGGED_PAIRS = 8 + +export type IceCandidateInfo = { + /** host, srflx, prflx or relay. */ + type?: string + /** Transport to the first hop: udp or tcp. */ + protocol?: string + /** Transport used by the relay itself (udp, tcp, tls). Local relay only. */ + relayProtocol?: string + /** Chrome reports an mDNS `.local` name here for host candidates. */ + address?: string + port?: number + /** Local candidates only, and not reported by every browser. */ + networkType?: string +} + +export type IceCandidatePair = { + /** The pair the browser is actually sending media on. */ + selected: boolean + nominated?: boolean + local: IceCandidateInfo + remote: IceCandidateInfo + /** Round trip time in milliseconds. */ + rttMs?: number + availableOutgoingBitrate?: number + bytesSent?: number +} + +export type IceCandidateReport = { + selected: IceCandidatePair | null + /** Every pair that completed its connectivity checks, selected one first. */ + working: IceCandidatePair[] +} + +const PROBE_WIDTH = 320 +const PROBE_HEIGHT = 180 +const PROBE_FPS = 15 +const SETTLE_DELAY_MS = 3000 + +type Stats = Record & { type?: string } + +const readCandidate = (stats?: Stats): IceCandidateInfo => { + if (!stats) return {} + + return { + type: stats.candidateType as string | undefined, + protocol: stats.protocol as string | undefined, + relayProtocol: stats.relayProtocol as string | undefined, + networkType: stats.networkType as string | undefined, + ...(INCLUDE_CANDIDATE_ADDRESSES + ? { + address: stats.address as string | undefined, + port: stats.port as number | undefined, + } + : {}), + } +} + +const describeCandidate = (candidate: IceCandidateInfo) => { + const transport = candidate.relayProtocol ?? candidate.protocol ?? 'unknown' + const endpoint = + candidate.address === undefined + ? '' + : ` ${candidate.address}:${candidate.port ?? '?'}` + return `${candidate.type ?? 'unknown'} ${transport}${endpoint}` +} + +const parseCandidates = (report: RTCStatsReport): IceCandidateReport => { + let selectedId: string | undefined + + report.forEach((stats: Stats) => { + if (stats.type === 'transport' && stats.selectedCandidatePairId) { + selectedId = stats.selectedCandidatePairId as string + } + }) + + const working: IceCandidatePair[] = [] + + report.forEach((stats: Stats) => { + // `succeeded` means the pair completed its connectivity checks; failed, + // waiting and in-progress pairs are not evidence of anything working. + if (stats.type !== 'candidate-pair' || stats.state !== 'succeeded') return + + const rtt = stats.currentRoundTripTime as number | undefined + + working.push({ + selected: selectedId !== undefined && stats.id === selectedId, + nominated: stats.nominated as boolean | undefined, + local: readCandidate(report.get(stats.localCandidateId as string)), + remote: readCandidate(report.get(stats.remoteCandidateId as string)), + rttMs: rtt === undefined ? undefined : Math.round(rtt * 1000), + availableOutgoingBitrate: stats.availableOutgoingBitrate as + | number + | undefined, + bytesSent: stats.bytesSent as number | undefined, + }) + }) + + // Firefox does not report transport.selectedCandidatePairId: fall back to the + // nominated pair, then to the one that actually carried bytes. + let selected = working.find((pair) => pair.selected) ?? null + if (!selected) { + selected = + working.find((pair) => pair.nominated) ?? + working + .slice() + .sort((a, b) => (b.bytesSent ?? 0) - (a.bytesSent ?? 0))[0] ?? + null + if (selected) selected.selected = true + } + + working.sort((a, b) => Number(b.selected) - Number(a.selected)) + + return { selected, working } +} + +/** + * A synthetic track avoids asking for camera or microphone permission: this + * check must work for someone who denied both. + */ +const createProbeTrack = () => { + const canvas = document.createElement('canvas') + canvas.width = PROBE_WIDTH + canvas.height = PROBE_HEIGHT + + const context = canvas.getContext('2d') + if (!context) throw new Error('Could not get canvas context') + + let frame = 0 + let rafId = 0 + const draw = () => { + frame = (frame + 4) % 360 + context.fillStyle = `hsl(${frame}, 100%, 50%)` + context.fillRect(0, 0, canvas.width, canvas.height) + rafId = requestAnimationFrame(draw) + } + draw() + + const track = canvas.captureStream(PROBE_FPS).getVideoTracks()[0] + + return { + track, + stop: () => { + cancelAnimationFrame(rafId) + track.stop() + }, + } +} + +export class SelectedCandidateCheck extends Checker { + private result: IceCandidateReport | null = null + + get description() { + const selected = this.result?.selected + if (!selected) return 'Selected ICE candidate pair' + + const transport = + selected.local.relayProtocol ?? selected.local.protocol ?? 'unknown' + const rtt = + selected.rttMs === undefined ? '' : ` · RTT ${selected.rttMs} ms` + return `${selected.local.type ?? 'unknown'} over ${transport}${rtt}` + } + + protected async perform() { + await this.connect() + + const probe = createProbeTrack() + try { + let publication + try { + publication = await this.room.localParticipant.publishTrack( + probe.track, + { + // The token restricts `can_publish_sources`, so a raw + // MediaStreamTrack published as `unknown` is rejected server side. + source: Track.Source.Camera, + simulcast: false, + videoEncoding: { maxBitrate: 300_000, maxFramerate: PROBE_FPS }, + } + ) + } catch (error) { + // A server-side grant problem is not a diagnosis of the user's network. + this.appendWarning( + `Could not publish the probe track: ${ + error instanceof Error ? error.message : 'unknown error' + }` + ) + this.skip() + return + } + + // ICE keeps promoting pairs for a moment after the track goes up. + await new Promise((resolve) => setTimeout(resolve, SETTLE_DELAY_MS)) + + // Stats come from the publisher peer connection: in an empty test room + // there is no subscriber transport to inspect. + const report = await publication.track?.getRTCStatsReport() + this.result = report ? parseCandidates(report) : null + } finally { + probe.stop() + } + + const selected = this.result?.selected + const working = this.result?.working ?? [] + + if (!selected) { + this.appendWarning('No working candidate pair reported by the browser') + return + } + + this.appendMessage(`selected: ${describeCandidate(selected.local)}`) + this.appendMessage(`server: ${describeCandidate(selected.remote)}`) + if (selected.rttMs !== undefined) { + this.appendMessage(`round trip time: ${selected.rttMs} ms`) + } + + this.appendMessage(`working candidate pairs: ${working.length}`) + for (const pair of working.slice(0, MAX_LOGGED_PAIRS)) { + const rtt = pair.rttMs === undefined ? '' : ` · ${pair.rttMs} ms` + this.appendMessage( + `${pair.selected ? '→' : ' '} ${describeCandidate(pair.local)} → ${describeCandidate(pair.remote)}${rtt}` + ) + } + if (working.length > MAX_LOGGED_PAIRS) { + this.appendMessage( + `… and ${working.length - MAX_LOGGED_PAIRS} more, see the report` + ) + } + + if (selected.local.type === 'relay') { + this.appendWarning( + 'Media is relayed through TURN. Direct connections are likely blocked by a firewall.' + ) + } + if ((selected.local.relayProtocol ?? selected.local.protocol) !== 'udp') { + this.appendWarning( + 'Media is not using UDP, which usually means degraded quality under load.' + ) + } + } + + getInfo(): CheckInfo { + const info = super.getInfo() + info.data = this.result ?? undefined + return info + } +} diff --git a/src/frontend/src/features/diagnostics/hooks/useConnectionTestRunner.ts b/src/frontend/src/features/diagnostics/hooks/useConnectionTestRunner.ts index 12e5179c..e0c736ed 100644 --- a/src/frontend/src/features/diagnostics/hooks/useConnectionTestRunner.ts +++ b/src/frontend/src/features/diagnostics/hooks/useConnectionTestRunner.ts @@ -8,6 +8,7 @@ import { type CheckInfo, } from 'livekit-client' import { fetchConnectionTestDetails } from '../api/fetchConnectionTestDetails' +import { SelectedCandidateCheck } from '../checks/selectedCandidate' import { createInitialSteps, type ConnectionTestLog, @@ -22,6 +23,7 @@ const LIVEKIT_STEP_IDS: ConnectionTestStepId[] = [ 'webrtc', 'turn', 'reconnect', + 'selectedCandidate', 'publishAudio', 'publishVideo', ] @@ -246,6 +248,9 @@ export const useConnectionTestRunner = () => { await runStep('reconnect', signal, async () => fromCheckInfo(await checker.checkReconnect()) ) + await runStep('selectedCandidate', signal, async () => + fromCheckInfo(await checker.createAndRunCheck(SelectedCandidateCheck)) + ) if (microphone.state !== 'success') { skipSteps(['publishAudio'], 'Microphone permission required') diff --git a/src/frontend/src/features/diagnostics/types.ts b/src/frontend/src/features/diagnostics/types.ts index 18d38ca3..817cb2a1 100644 --- a/src/frontend/src/features/diagnostics/types.ts +++ b/src/frontend/src/features/diagnostics/types.ts @@ -7,6 +7,7 @@ export type ConnectionTestStepId = | 'webrtc' | 'turn' | 'reconnect' + | 'selectedCandidate' | 'publishAudio' | 'publishVideo' @@ -45,6 +46,7 @@ export const CONNECTION_TEST_GROUPS: ReadonlyArray<{ 'webrtc', 'turn', 'reconnect', + 'selectedCandidate', 'publishAudio', 'publishVideo', ], diff --git a/src/frontend/src/locales/en/connectionTest.json b/src/frontend/src/locales/en/connectionTest.json index 1adf26ec..8c6868a5 100644 --- a/src/frontend/src/locales/en/connectionTest.json +++ b/src/frontend/src/locales/en/connectionTest.json @@ -21,6 +21,7 @@ "webrtc": "WebRTC", "turn": "TURN", "reconnect": "Reconnect", + "selectedCandidate": "Selected route", "publishAudio": "Audio publishing", "publishVideo": "Video publishing" }, diff --git a/src/frontend/src/locales/fr/connectionTest.json b/src/frontend/src/locales/fr/connectionTest.json index 5dad62dd..6390122a 100644 --- a/src/frontend/src/locales/fr/connectionTest.json +++ b/src/frontend/src/locales/fr/connectionTest.json @@ -21,6 +21,7 @@ "webrtc": "WebRTC", "turn": "TURN", "reconnect": "Reconnexion", + "selectedCandidate": "Route sélectionnée", "publishAudio": "Publication audio", "publishVideo": "Publication vidéo" },