mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-19 06:46:37 +00:00
⚗️(frontend) add candidate pair diagnostic to WebRTC checks
Add a custom diagnostic step that reports which ICE candidate pair was selected on the WebRTC connection, as well as all working pairs observed during the check. Experimental and vibe-coded for now; the output is meant to help debugging and will likely be revisited.
This commit is contained in:
committed by
aleb_the_flash
parent
d756825fd7
commit
bc003f928e
@@ -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<string, unknown> & { 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type CheckInfo,
|
type CheckInfo,
|
||||||
} from 'livekit-client'
|
} from 'livekit-client'
|
||||||
import { fetchConnectionTestDetails } from '../api/fetchConnectionTestDetails'
|
import { fetchConnectionTestDetails } from '../api/fetchConnectionTestDetails'
|
||||||
|
import { SelectedCandidateCheck } from '../checks/selectedCandidate'
|
||||||
import {
|
import {
|
||||||
createInitialSteps,
|
createInitialSteps,
|
||||||
type ConnectionTestLog,
|
type ConnectionTestLog,
|
||||||
@@ -22,6 +23,7 @@ const LIVEKIT_STEP_IDS: ConnectionTestStepId[] = [
|
|||||||
'webrtc',
|
'webrtc',
|
||||||
'turn',
|
'turn',
|
||||||
'reconnect',
|
'reconnect',
|
||||||
|
'selectedCandidate',
|
||||||
'publishAudio',
|
'publishAudio',
|
||||||
'publishVideo',
|
'publishVideo',
|
||||||
]
|
]
|
||||||
@@ -246,6 +248,9 @@ export const useConnectionTestRunner = () => {
|
|||||||
await runStep('reconnect', signal, async () =>
|
await runStep('reconnect', signal, async () =>
|
||||||
fromCheckInfo(await checker.checkReconnect())
|
fromCheckInfo(await checker.checkReconnect())
|
||||||
)
|
)
|
||||||
|
await runStep('selectedCandidate', signal, async () =>
|
||||||
|
fromCheckInfo(await checker.createAndRunCheck(SelectedCandidateCheck))
|
||||||
|
)
|
||||||
|
|
||||||
if (microphone.state !== 'success') {
|
if (microphone.state !== 'success') {
|
||||||
skipSteps(['publishAudio'], 'Microphone permission required')
|
skipSteps(['publishAudio'], 'Microphone permission required')
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type ConnectionTestStepId =
|
|||||||
| 'webrtc'
|
| 'webrtc'
|
||||||
| 'turn'
|
| 'turn'
|
||||||
| 'reconnect'
|
| 'reconnect'
|
||||||
|
| 'selectedCandidate'
|
||||||
| 'publishAudio'
|
| 'publishAudio'
|
||||||
| 'publishVideo'
|
| 'publishVideo'
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ export const CONNECTION_TEST_GROUPS: ReadonlyArray<{
|
|||||||
'webrtc',
|
'webrtc',
|
||||||
'turn',
|
'turn',
|
||||||
'reconnect',
|
'reconnect',
|
||||||
|
'selectedCandidate',
|
||||||
'publishAudio',
|
'publishAudio',
|
||||||
'publishVideo',
|
'publishVideo',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"webrtc": "WebRTC",
|
"webrtc": "WebRTC",
|
||||||
"turn": "TURN",
|
"turn": "TURN",
|
||||||
"reconnect": "Reconnect",
|
"reconnect": "Reconnect",
|
||||||
|
"selectedCandidate": "Selected route",
|
||||||
"publishAudio": "Audio publishing",
|
"publishAudio": "Audio publishing",
|
||||||
"publishVideo": "Video publishing"
|
"publishVideo": "Video publishing"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"webrtc": "WebRTC",
|
"webrtc": "WebRTC",
|
||||||
"turn": "TURN",
|
"turn": "TURN",
|
||||||
"reconnect": "Reconnexion",
|
"reconnect": "Reconnexion",
|
||||||
|
"selectedCandidate": "Route sélectionnée",
|
||||||
"publishAudio": "Publication audio",
|
"publishAudio": "Publication audio",
|
||||||
"publishVideo": "Publication vidéo"
|
"publishVideo": "Publication vidéo"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user