Vendor LiveKit WebRTC and add WebRTC transport#3
Conversation
ca949d8 to
1151105
Compare
18472b9 to
198d472
Compare
## Summary - Renames the package/workspace to `@expo/serve-sim` for the Expo fork
152c36f to
ad92736
Compare
198d472 to
222f286
Compare
32c5cf5 to
64ebdfd
Compare
| try { | ||
| const body = await readRequestBody(req); | ||
| const offer = JSON.parse(body.toString("utf8")) as unknown; | ||
| const answer = this.capture.handleWebRTCOffer(offer); |
There was a problem hiding this comment.
This WebRTC offer path is synchronous all the way into native code. handleWebRTCOffer() calls this.capture.handleWebRTCOffer(offer) on the Node request path, and the native implementation blocks on a semaphore while WebRTC creates the answer and gathers ICE. That means one /webrtc/offer request can stall the whole server event loop for seconds, including unrelated HTTP requests, WS input, and stream fan-out. This should be exposed as an async native method, similar to the AX async methods, so signaling work does not block Node.
There was a problem hiding this comment.
Addressed in 4de4d70. SimCapture.handleWebRTCOffer is now an async Node method and the JS NativeCapture/DeviceSession path awaits the returned Promise, so /webrtc/offer signaling no longer blocks the Node request path while native creates the answer / gathers ICE.
| } | ||
|
|
||
| export function mjpegStreamUrlFrom(config: NonNullable<Window["__SIM_PREVIEW__"]>): string { | ||
| return `${config.url.replace(/\/+$/, "")}/stream.mjpeg`; |
There was a problem hiding this comment.
This derives the MJPEG URL from config.url, but in embedded/non-proxy mode config.url is only the helper origin while config.streamUrl contains the required /helper/<udid>/stream.mjpeg path. That regresses MJPEG fallback for the Expo CLI integration path: fallback will request /stream.mjpeg at the root and get a 404/blank video. We should keep using config.streamUrl for MJPEG, or derive from streamUrl rather than url, and add a test using the inProcessServeSimState() shape.
There was a problem hiding this comment.
Addressed in 76f77db. mjpegStreamUrlFrom() now derives from config.streamUrl and swaps the terminal stream endpoint to /stream.mjpeg, preserving embedded /helper/<udid>/... paths. Added a regression test using inProcessServeSimState().
| case "/stream.avcc": session.handleAvcc(req, res); return true; | ||
| case "/config": session.handleConfig(req, res); return true; | ||
| case "/health": session.handleHealth(req, res); return true; | ||
| case "/webrtc/offer": void session.handleWebRTCOffer(req, res); return true; |
There was a problem hiding this comment.
/webrtc/offer needs an explicit CORS preflight path. The browser sends POST with Content-Type: application/json, so cross-origin helper usage will issue an OPTIONS preflight first. Today this route falls through to handleWebRTCOffer(), which tries to parse the empty OPTIONS body as an offer and returns an error instead of a 204/200 with CORS headers. This will make WebRTC fail before signaling in direct helper mode.
There was a problem hiding this comment.
Addressed in 76f77db. /webrtc/offer now handles OPTIONS with a 204 and CORS headers before reading/parsing a body or starting the helper session. Added a focused CORS header test.
| setDataChannelOpen(false); | ||
| } | ||
| }; | ||
| pc.ontrack = (event) => { |
There was a problem hiding this comment.
Receiving ontrack clears the failed codec state before we know any frames are actually decodable. If negotiation succeeds but the browser cannot decode the produced bitstream, the UI can end up permanently black: the connection may be connected, ontrack fires, but no frame ever renders and the codec ladder never advances. The AVCC path has a startup/no-frame watchdog; WebRTC needs a similar first-frame watchdog using requestVideoFrameCallback, loadeddata, or decoded-frame stats.
There was a problem hiding this comment.
Addressed in 8930a8c. ontrack now starts a first-frame watchdog instead of clearing codec failure immediately. SimulatorView marks success only after requestVideoFrameCallback or loadeddata, and the hook fails the current codec if no decoded frame arrives.
| // drop to MJPEG, which every helper serves. See avcc-fallback.ts. | ||
| const avcc = useAvccStream(); | ||
| const streamSettings = config.streamSettings; | ||
| const useWebRtcVideo = streamSettings?.transport === "webrtc"; |
There was a problem hiding this comment.
Once transport === "webrtc", the UI stays pinned to the WebRTC path even after the codec fallback ladder is exhausted. If VP8/VP9/H264 all fail, nextWebRtcFallbackCodec() returns null, but we never drop to MJPEG as the terminal safety net. That leaves users with a dead/black stream instead of the existing reliable fallback.
I don't know if this is something we care about or overcomplication. Maybe this is too much overthinking? Maybe we should explicitly fail? I think I prefer explicit failure.
There was a problem hiding this comment.
Addressed in 8930a8c. I kept the transport pinned to WebRTC and made exhaustion explicit: once the codec ladder is out of attempts, the simulator view shows a WebRTC failure instead of silently dropping to MJPEG.
|
|
||
| private let deviceUDID: String | ||
| private let onFrame: SimFrameCallback | ||
| private let webRTCPublisher = WebRTCPublisher() |
There was a problem hiding this comment.
nit: This eagerly constructs WebRTCPublisher for every capture engine, including pure HTTP/MJPEG sessions. That constructor builds WebRTC factory state and probes H.264 support, so the HTTP path pays WebRTC startup cost even when WebRTC is never requested. Can we lazy-create this only when the first WebRTC offer arrives?
I'm not sure if we care about it here tbh
There was a problem hiding this comment.
Addressed in 4de4d70. CaptureEngine now lazy-creates WebRTCPublisher on the first WebRTC offer, so HTTP/MJPEG-only sessions do not pay the WebRTC factory/probe cost during capture startup.
| } | ||
|
|
||
| let h264Request = reserveH264EncodeIfNeeded() | ||
| let shouldSendWebRTC = webRTCPublisher.isActive |
There was a problem hiding this comment.
This checks webRTCPublisher.isActive on every captured frame, and isActive synchronously hops onto the publisher queue. That adds a cross-thread sync to the hot frame path even when the selected transport is HTTP/MJPEG. A cached atomic/state flag, or gating this by configured transport, would avoid the per-frame synchronization.
There was a problem hiding this comment.
Addressed in 4de4d70. The frame hot path now checks a cached webRTCActive flag updated by WebRTCPublisher.onActiveChanged; it no longer calls webRTCPublisher.isActive and synchronously hops onto the publisher queue for every frame.
| private var encodedCount: Int64 = 0 | ||
| private let stateQueue = DispatchQueue(label: "serve-sim.webrtc.h264.encoder") | ||
|
|
||
| init(codecInfo: LKRTCVideoCodecInfo) { |
There was a problem hiding this comment.
The software H.264 encoder logs the negotiated codec parameters, but the actual VideoToolbox encoder still hardcodes High profile. If the browser answers constrained-baseline/profile-level-id parameters, we may emit SPS/PPS that do not match the negotiated profile, especially on the VM path where this encoder matters most. We should either honor the negotiated profile-level-id or avoid accepting profiles the encoder will not produce.
There was a problem hiding this comment.
Addressed in 4de4d70. The custom software H.264 encoder factory now filters/rejects H.264 capabilities unless profile-level-id is High profile (64...), so baseline/constrained-baseline answers are not negotiated against the VideoToolbox encoder that emits High-profile SPS/PPS.
szdziedzic
left a comment
There was a problem hiding this comment.
Nice work 👏
I have 2 cleanup/nit comments but it LGTM overall
| } | ||
| } | ||
|
|
||
| func handleOffer(_ request: WebRTCOfferPayload) throws -> WebRTCAnswerPayload { |
There was a problem hiding this comment.
The new async handleOffer(...) path appears to be the only remaining call site, so can we remove this old synchronous overload? Keeping the semaphore-based version around makes it easier for a future call site to accidentally reintroduce the JS-thread blocking behavior this update just fixed.
There was a problem hiding this comment.
Addressed in 63f3ed3. Removed the old semaphore-based synchronous handleOffer(...) overload; the async path is now the only offer handling API.
| return try result!.get() | ||
| } | ||
|
|
||
| func handleOffer(_ request: WebRTCOfferPayload) async throws -> WebRTCAnswerPayload { |
There was a problem hiding this comment.
nit: The async native path fixes the Node event-loop blocking issue, but this continuation still depends on libwebrtc eventually invoking one of the signaling callbacks. ICE gathering has its own timeout later, but setRemoteDescription, answer, and setLocalDescription do not appear to have a native-side timeout. If one of those callbacks is dropped or stalls, the /webrtc/offer request can remain pending indefinitely. Could we add a signaling timeout/cleanup here, or otherwise guarantee these callbacks always resolve?
There was a problem hiding this comment.
Addressed in 63f3ed3. Added a native-side 10s signaling timeout guarded by a one-shot completion helper, so stalled/dropped setRemoteDescription / answer / setLocalDescription callbacks reject the offer and close the active session instead of leaving /webrtc/offer pending indefinitely.
Summary
This is the first grouped WebRTC PR in the new stack.
Commits:
Notes:
LiveKitWebRTC.frameworksource vendoring flow.Validation
./node_modules/.bin/tsc --noEmitnpm exec --yes --package bun@latest -- bun run packages/serve-sim/build.ts