From 8128b7cc93d6478fce63e19ebfc9aeeb24fcb03b Mon Sep 17 00:00:00 2001 From: Kevin Hanna Date: Thu, 19 Mar 2026 23:17:58 +0000 Subject: [PATCH 1/9] fix(test): update msid count expectation for M114 stream ID deduplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since M111 (commit e04c3970994b), RtpSenderBase::set_stream_ids() deduplicates stream IDs before they reach SDP serialization. This aligns with the W3C WebRTC spec: "For each stream in streams, add stream.id to [[AssociatedMediaStreamIds]] if it's not already there." The test creates stream2 and stream3 with the same id "testStreamId", so M114 correctly deduplicates: 2 unique stream IDs × 2 tracks = 4 msid lines, not 6. See: https://webrtc-review.googlesource.com/c/src/+/288701 See: webrtc bug 14769 --- test/rtcrtpsender.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/rtcrtpsender.js b/test/rtcrtpsender.js index 1745ffa6f..7eefd5bd7 100644 --- a/test/rtcrtpsender.js +++ b/test/rtcrtpsender.js @@ -140,9 +140,9 @@ tape( return pc.createOffer().then(function (offer) { t.equal( (offer.sdp.match(/a=msid:/g) || []).length, - 6, - "even duplicates get added", - ); // 3 streams per track and 2 tracks (audio + video) = 6 msid lines + 4, + "duplicates are deduplicated", + ); // 2 unique stream IDs per track × 2 tracks (audio + video) = 4 msid lines pc.close(); t.end(); }); From 33ceabffd98c504169988480cc8d13aff7b00056 Mon Sep 17 00:00:00 2001 From: Kevin Hanna Date: Thu, 19 Mar 2026 23:18:03 +0000 Subject: [PATCH 2/9] fix: dispatch DataChannel close events before PeerConnection::Close() Dispatch close events proactively during RTCPeerConnection.close(), before calling the underlying PeerConnection::Close(). This matters because PeerConnection::Close() calls PrepareForShutdown() (data_channel_controller.cc:173) which deactivates the SafeTask safety flag, silently cancelling any pending OnStateChange callbacks from the network thread. By dispatching close events first, they reach JS regardless of the C++ shutdown sequence. --- src/interfaces/rtc_data_channel.cc | 2 ++ src/interfaces/rtc_peer_connection.cc | 18 +++++++++++------- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/interfaces/rtc_data_channel.cc b/src/interfaces/rtc_data_channel.cc index 65485a1bf..b658b6ead 100644 --- a/src/interfaces/rtc_data_channel.cc +++ b/src/interfaces/rtc_data_channel.cc @@ -106,6 +106,8 @@ void RTCDataChannel::CleanupInternals() { void RTCDataChannel::OnPeerConnectionClosed() { if (_jingleDataChannel != nullptr) { + CleanupInternals(); + HandleStateChange(*this, webrtc::DataChannelInterface::kClosed); Stop(); } } diff --git a/src/interfaces/rtc_peer_connection.cc b/src/interfaces/rtc_peer_connection.cc index 628af31c8..a3c576413 100644 --- a/src/interfaces/rtc_peer_connection.cc +++ b/src/interfaces/rtc_peer_connection.cc @@ -777,10 +777,16 @@ Napi::Value RTCPeerConnection::Close(const Napi::CallbackInfo &info) { if (_jinglePeerConnection) { _cached_configuration = ExtendedRTCConfiguration( _jinglePeerConnection->GetConfiguration(), _port_range); - _jinglePeerConnection->Close(); - // NOTE(mroberts): Perhaps another way to do this is to just register all - // remote MediaStreamTracks against this RTCPeerConnection, not unlike what - // we do with RTCDataChannels. + + // Fire all JS close events proactively, matching Chrome/Blink's approach. + // Blink dispatches events synchronously during RTCPeerConnection.close() + // rather than relying on the C++ observer callback path. This is necessary + // because PeerConnection::Close() calls PrepareForShutdown() which + // deactivates the SafeTask safety flag, silently cancelling any pending + // async callbacks from the network thread. + for (auto channel : _channels) { + channel->OnPeerConnectionClosed(); + } if (_jinglePeerConnection->GetConfiguration().sdp_semantics == webrtc::SdpSemantics::kUnifiedPlan) { for (const auto &transceiver : _jinglePeerConnection->GetTransceivers()) { @@ -789,9 +795,7 @@ Napi::Value RTCPeerConnection::Close(const Napi::CallbackInfo &info) { track->OnPeerConnectionClosed(); } } - for (auto channel : _channels) { - channel->OnPeerConnectionClosed(); - } + _jinglePeerConnection->Close(); } // Clear the wrap caches before releasing the WebRTC peer connection. From 4d1e8e182e32789294445473a16c3715b0924da8 Mon Sep 17 00:00:00 2001 From: Kevin Hanna Date: Thu, 19 Mar 2026 23:18:05 +0000 Subject: [PATCH 3/9] fix(build): patch libwebrtc for macOS 26 SDK compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS 26 (Tahoe) SDK removed the CG_AVAILABLE_BUT_DEPRECATED macro from CoreGraphics. This macro was used in M114's modules/desktop_capture/mac/screen_capturer_mac.mm to provide correct deprecation annotations for CGDisplayStream wrapper functions (a workaround for incorrect annotations in the 13.3 SDK, see https://crbug.com/1431897). The patch adds a fallback #define that maps CG_AVAILABLE_BUT_DEPRECATED to API_DEPRECATED when the macro is not provided by the SDK, preserving the deprecation annotations on older SDKs while allowing compilation on macOS 26+. This only affects macOS builds — the file is not compiled on Linux or Windows. Also adds a general-purpose patch script (scripts/patch-webrtc.sh) and PATCH_COMMAND to the libwebrtc ExternalProject in CMakeLists.txt, so patches are applied automatically and idempotently during the build. --- CMakeLists.txt | 1 + patches/webrtc-macos26-sdk.patch | 14 ++++++++++++++ scripts/patch-webrtc.sh | 17 +++++++++++++++++ 3 files changed, 32 insertions(+) create mode 100644 patches/webrtc-macos26-sdk.patch create mode 100755 scripts/patch-webrtc.sh diff --git a/CMakeLists.txt b/CMakeLists.txt index 15b221f16..63b34065e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -277,6 +277,7 @@ ExternalProject_Add( BUILD_BYPRODUCTS ${byproducts} DOWNLOAD_COMMAND ${CMAKE_COMMAND} -E env DEPOT_TOOLS=${depot_tools_install_dir} PLATFORM=${PLATFORM} WEBRTC_REVISION=${WEBRTC_REVISION} ${CMAKE_SOURCE_DIR}/scripts/download-webrtc.${suffix} + PATCH_COMMAND ${CMAKE_SOURCE_DIR}/scripts/patch-webrtc.sh ${CMAKE_SOURCE_DIR}/patches CONFIGURE_COMMAND ${CMAKE_COMMAND} -E env BINARY_DIR= DEPOT_TOOLS=${depot_tools_install_dir} GN_GEN_ARGS=${GN_GEN_ARGS} SOURCE_DIR= ${CMAKE_SOURCE_DIR}/scripts/configure-webrtc.${suffix} BUILD_COMMAND ${CMAKE_COMMAND} -E env CMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} DEPOT_TOOLS=${depot_tools_install_dir} ${CMAKE_SOURCE_DIR}/scripts/build-webrtc.${suffix} INSTALL_COMMAND "" diff --git a/patches/webrtc-macos26-sdk.patch b/patches/webrtc-macos26-sdk.patch new file mode 100644 index 000000000..c67f9fbe1 --- /dev/null +++ b/patches/webrtc-macos26-sdk.patch @@ -0,0 +1,14 @@ +--- a/modules/desktop_capture/mac/screen_capturer_mac.mm ++++ b/modules/desktop_capture/mac/screen_capturer_mac.mm +@@ -24,6 +24,11 @@ + // These have the correct annotation. See https://crbug.com/1431897. + // TODO(thakis): Remove this once FB12109479 is fixed and we updated to an SDK + // with the fix. ++// NOTE: CG_AVAILABLE_BUT_DEPRECATED was removed in macOS 26 SDK. ++#ifndef CG_AVAILABLE_BUT_DEPRECATED ++#define CG_AVAILABLE_BUT_DEPRECATED(from, to, ...) \ ++ API_DEPRECATED(__VA_ARGS__, macos(from, to)) ++#endif + + static CGDisplayStreamRef __nullable + wrapCGDisplayStreamCreate(CGDirectDisplayID display, diff --git a/scripts/patch-webrtc.sh b/scripts/patch-webrtc.sh new file mode 100755 index 000000000..833d9cf57 --- /dev/null +++ b/scripts/patch-webrtc.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# Apply patches to the downloaded WebRTC source. +# Skips patches that have already been applied (idempotent). +set -e + +SOURCE_DIR="$1" +PATCHES_DIR="$2" + +for patch in "$PATCHES_DIR"/webrtc-*.patch; do + [ -f "$patch" ] || continue + if git -C "$SOURCE_DIR" apply --check "$patch" 2>/dev/null; then + echo "Applying patch: $(basename "$patch")" + git -C "$SOURCE_DIR" apply "$patch" + else + echo "Skipping already-applied patch: $(basename "$patch")" + fi +done From 8ebbaa93eea2f9aae2311c787b5500e187496f8f Mon Sep 17 00:00:00 2001 From: Kevin Hanna Date: Thu, 19 Mar 2026 23:18:07 +0000 Subject: [PATCH 4/9] refactor(test): rewrite multiconnect and custom-settings without simple-peer Replace the simple-peer dependency with direct RTCPeerConnection usage. simple-peer is unmaintained (last release 2021) and was only used in these two test files. --- package.json | 1 - test/custom-settings.js | 169 +++++++++++++++++--------------- test/multiconnect.js | 206 +++++++++++++++++++--------------------- 3 files changed, 190 insertions(+), 186 deletions(-) diff --git a/package.json b/package.json index 0f2b7f3ad..37eb9e719 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,6 @@ "patch-package": "^8.0.0", "prettier": "^3.4.2", "recursive-copy": "^2.0.14", - "simple-peer": "~9.7.0", "tape": "^5.6.1", "temp": "^0.9.4" }, diff --git a/test/custom-settings.js b/test/custom-settings.js index 45d8b3534..668c3bf44 100644 --- a/test/custom-settings.js +++ b/test/custom-settings.js @@ -1,92 +1,107 @@ -/* eslint no-console:0 */ "use strict"; -var tape = require("tape"); -var SimplePeer = require("simple-peer"); -var wrtc = require(".."); +const tape = require("tape"); +const wrtc = require(".."); -tape("custom ports connect once", function (t) { - t.plan(1); - connectClientServer({ min: 9000, max: 9010 }, function (err) { - t.error(err, "connectClientServer callback"); - }); -}); +const RTCPeerConnection = wrtc.RTCPeerConnection; -tape("custom ports connect concurrently", function (t) { - const n = 2; +async function connectWithConfig(config) { + let resolve; + let reject; + const done = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); - t.plan(n); - const portRange = { min: 9000, max: 9010 }; + const pc1 = new RTCPeerConnection({ iceServers: [] }); + const pc2 = new RTCPeerConnection(Object.assign({ iceServers: [] }, config)); - function callback(err) { - t.error(err, "connectClientServer callback"); - } + pc1.onicecandidate = function (e) { + if (e.candidate) pc2.addIceCandidate(e.candidate); + }; - for (let i = 0; i < n; i++) { - connectClientServer(portRange, callback); - } -}); + pc2.onicecandidate = function (e) { + if (e.candidate) { + if (config.portRange) { + const { min, max } = config.portRange; + const port = parsePort(e.candidate.candidate); + if (port < min || port > max) { + pc1.close(); + pc2.close(); + reject( + new Error( + `candidate port ${port} outside range ${min} - ${max}: ${e.candidate.candidate}`, + ), + ); + return; + } + } + pc1.addIceCandidate(e.candidate); + } + }; -function connectClientServer(portRange, callback) { - const client = new SimplePeer({ - wrtc: wrtc, - initiator: true, - }); + const dc = pc1.createDataChannel("test"); + pc2.ondatachannel = function (evt) { + evt.channel.onmessage = function () { + pc1.close(); + pc2.close(); + resolve(); + }; + }; + dc.onopen = function () { + dc.send("hello"); + }; - const server = new SimplePeer({ - wrtc: wrtc, - initiator: false, - config: { - portRange: portRange, - }, - }); + try { + const offer = await pc1.createOffer(); + await pc1.setLocalDescription(offer); + await pc2.setRemoteDescription(pc1.localDescription); + const answer = await pc2.createAnswer(); + await pc2.setLocalDescription(answer); + await pc1.setRemoteDescription(pc2.localDescription); + } catch (err) { + pc1.close(); + pc2.close(); + reject(err); + } - client.on("signal", function (data) { - server.signal(data); - }); - server.on("signal", function (data) { - if ( - data.candidate && - !isValidCandidate( - data.candidate.candidate, - portRange || { min: 0, max: 65535 }, - true, - ) - ) { - callback( - `candidate must follow port range (${portRange}): ${data.candidate.candidate}`, - ); - } - client.signal(data); - }); - server.on("connect", function () { - server.send("xyz"); - }); - client.on("data", function () { - callback(); - server.destroy(); - client.destroy(); - }); - client.on("error", function (e) { - callback(e); - server.destroy(); - client.destroy(); - }); - server.on("error", function (e) { - callback(e); - client.destroy(); - server.destroy(); - }); + return done; } -function isValidCandidate(candidate, portRange) { - const port = candidate.replace( - /candidate:([^\s]+)\s([^\s]+)\s([^\s]+)\s([^\s]+)\s([^\s]+)\s([0-9]+)\styp.*/, - "$6", +function parsePort(candidate) { + const match = candidate.match( + /candidate:\S+\s\d+\s\S+\s\d+\s\S+\s(\d+)\styp/, ); + return match ? parseInt(match[1], 10) : -1; +} - const minPort = portRange.min; - const maxPort = portRange.max; +tape("custom ports connect once", async function (t) { + t.plan(1); + try { + await connectWithConfig({ portRange: { min: 9000, max: 9010 } }); - return minPort <= parseInt(port) && maxPort >= parseInt(port); -} + t.pass("ConnectClientServer pass"); + } catch (err) { + t.error(err, "connectClientServer callback"); + } +}); + +tape("custom ports connect concurrently", async function (t) { + const n = 2; + t.plan(n); + let promises = []; + + for (let i = 0; i < n; i++) { + promises.push( + connectWithConfig({ portRange: { min: 9000, max: 9010 } }) + .then(function () { + t.pass("connectClientServer pass"); + }) + .catch(function (err) { + t.error(err, "connectClientServer error"); + }), + ); + } + + await Promise.all(promises); +}); diff --git a/test/multiconnect.js b/test/multiconnect.js index 2e3cbda80..27b90a157 100644 --- a/test/multiconnect.js +++ b/test/multiconnect.js @@ -1,129 +1,119 @@ -/* eslint no-console:0, no-process-env:0 */ "use strict"; -var tape = require("tape"); -var SimplePeer = require("simple-peer"); -var wrtc = require(".."); +const tape = require("tape"); +const wrtc = require(".."); -var log = process.env.LOG ? console.log : function () {}; +const RTCPeerConnection = wrtc.RTCPeerConnection; -tape("connect once", function (t) { - t.plan(1); - log("###########################\n"); - connect(function (err) { - t.error(err, "connect once callback"); +async function connect() { + let resolve; + let reject; + const done = new Promise((res, rej) => { + resolve = res; + reject = rej; }); -}); + const pc1 = new RTCPeerConnection({ iceServers: [] }); + const pc2 = new RTCPeerConnection({ iceServers: [] }); + + pc1.onicecandidate = function (e) { + if (e.candidate) pc2.addIceCandidate(e.candidate); + }; + pc2.onicecandidate = function (e) { + if (e.candidate) pc1.addIceCandidate(e.candidate); + }; + + const dc = pc1.createDataChannel("test"); + + pc2.ondatachannel = function (evt) { + evt.channel.onmessage = function (msg) { + pc1.close(); + pc2.close(); + resolve(msg.data); + }; + }; + + dc.onopen = function () { + dc.send("hello"); + }; + + try { + const offer = await pc1.createOffer(); + await pc1.setLocalDescription(offer); + await pc2.setRemoteDescription(pc1.localDescription); + const answer = await pc2.createAnswer(); + await pc2.setLocalDescription(answer); + await pc1.setRemoteDescription(pc2.localDescription); + } catch (err) { + pc1.close(); + pc2.close(); + reject(err); + } -tape("connect loop", function (t) { - t.plan(1); - log("###########################\n"); - connectLoop(10, function (err) { - t.error(err, "connect loop callback"); - }); -}); + return done; +} -tape("connect concurrent", function (t) { - var n = 10; - t.plan(n); - log("###########################\n"); - for (var i = 0; i < n; i += 1) { - connect(callback); +async function connectLoop(count) { + for (let i = 0; i < count; i++) { + await connect(); } +} - function callback(err) { - t.error(err, "connect concurrent callback"); +tape("connect once", async function (t) { + t.plan(1); + try { + await connect(); + t.pass("connect once pass"); + } catch (err) { + t.error(err, "connect once callback"); } }); -tape("connect loop concurrent", function (t) { - var n = 10; - t.plan(n); - log("###########################\n"); - for (var i = 0; i < n; i += 1) { - connectLoop(10, callback); - } - - function callback(err) { - t.error(err, "connect loop concurrent callback"); +tape("connect loop", async function (t) { + t.plan(1); + try { + await connectLoop(10); + t.pass("connect loop completed"); + } catch (err) { + t.error(err, "connect loop callback"); } }); -var connIdGen = 1; - -function connect(callback) { - var connId = connIdGen; - var connName = "CONNECTION-" + connId; - connIdGen += 1; - log(connName, "starting"); - - // setup two peers with simple-peer - var peer1 = new SimplePeer({ - wrtc: wrtc, - }); - var peer2 = new SimplePeer({ - wrtc: wrtc, - initiator: true, - }); +tape("connect concurrent", async function (t) { + const n = 10; + t.plan(n); - function cleanup() { - if (peer1) { - peer1.destroy(); - peer1 = null; - } - if (peer2) { - peer2.destroy(); - peer2 = null; - } + let promises = []; + for (let i = 0; i < n; i++) { + promises.push( + connect() + .then(function () { + t.pass("connect concurrent pass"); + }) + .catch(function (err) { + t.error(err, "connect concurrent error"); + }), + ); } - // when peer1 has signaling data, give it to peer2, and vice versa - peer1.on("signal", function (data) { - log(connName, "signal peer1 -> peer2:"); - log(" ", data); - peer2.signal(data); - }); - peer2.on("signal", function (data) { - log(connName, "signal peer2 -> peer1:"); - log(" ", data); - peer1.signal(data); - }); - - peer1.on("error", function (err) { - log(connName, "peer1 error", err); - cleanup(); - callback(err); - }); - peer2.on("error", function (err) { - log(connName, "peer2 error", err); - cleanup(); - callback(err); - }); + await Promise.all(promises); +}); - // wait for 'connect' event - peer1.on("connect", function () { - log(connName, "sending message"); - peer1.send("peers are for kids"); - }); - peer2.on("data", function () { - log(connName, "completed"); - cleanup(); - callback(); - }); -} +tape("connect loop concurrent", async function (t) { + const n = 10; + t.plan(n); -function connectLoop(count, callback) { - if (count <= 0) { - log("connect loop completed"); - callback(); - } else { - log("connect loop remain", count); - connect(function (err) { - if (err) { - callback(err); - } else { - connectLoop(count - 1, callback); - } - }); + let promises = []; + for (let i = 0; i < n; i++) { + promises.push( + connectLoop(n) + .then(function () { + t.pass("connectLoop concurrent pass"); + }) + .catch(function (err) { + t.error(err, "connectLoop concurrent error"); + }), + ); } -} + + await Promise.all(promises); +}); From ae0fa0d47e0c50456afdc4bbe718fce2b44dd957 Mon Sep 17 00:00:00 2001 From: Kevin Hanna Date: Thu, 19 Mar 2026 23:18:09 +0000 Subject: [PATCH 5/9] fix(test): replace time-based audio sink assertions with event-count-based The timing approach was flakey due to event loop timing jitter. --- test/rtcaudiosink.js | 106 +++++++++++++++++++++++++++++-------------- 1 file changed, 72 insertions(+), 34 deletions(-) diff --git a/test/rtcaudiosink.js b/test/rtcaudiosink.js index 05fc24618..c777c7db0 100644 --- a/test/rtcaudiosink.js +++ b/test/rtcaudiosink.js @@ -68,13 +68,9 @@ test("RTCAudioSink should send even ondata when ondata is defined in ontrack eve // console.log('pcB: onicegatheringstatechange:', e.target.iceGatheringState); // }; - pcB.ontrack = (e) => - setTimeout(() => { - sink = new RTCAudioSink(e.track); - sink.addEventListener("data", () => { - ondataDidFired += 1; - }); - }, 1); + var TARGET = 10; + var received = 0; + var interval; setupPerfectNegotiation(pcA, pcB, true); setupPerfectNegotiation(pcB, pcA, false); @@ -83,29 +79,51 @@ test("RTCAudioSink should send even ondata when ondata is defined in ontrack eve const track = source.createTrack(); pcA.addTrack(track); + function done() { + clearInterval(interval); + if (sink) sink.stop(); + track.stop(); + pcA.close(); + pcB.close(); + } + + pcB.ontrack = (e) => + setTimeout(() => { + sink = new RTCAudioSink(e.track); + sink.addEventListener("data", () => { + ondataDidFired += 1; + if (ondataDidFired >= TARGET && received === 0) { + received = ondataDidFired; + done(); + t.ok( + received >= TARGET, + "RTCAudioSink fired at least " + TARGET + " times", + ); + t.end(); + } + }); + }, 1); + const sampleRate = 8000; const samples = new Int16Array(sampleRate / 100); for (let n = 0; n < samples.length; n++) { samples[n] = Math.random() * 0xffff; } - const interval = setInterval(() => { + interval = setInterval(() => { source.onData({ samples, sampleRate }); }, 10); setTimeout(() => { - clearInterval(interval); - // yes > 9 and not 10 because some random thing in eventloop and setinterval/timeout result in values to be 9||10||11 - t.ok( - ondataDidFired >= 9, - "RTCAudioSink should have fired 10 time in 100ms" - ); - sink.stop(); - track.stop(); - pcA.close(); - pcB.close(); - t.end(); - }, 105); + if (received === 0) { + // Prevent a late data event (where ondataDidFired reaches TARGET + // after this timeout) from calling done()/t.end() a second time. + received = -1; + done(); + t.fail("RTCAudioSink only fired " + ondataDidFired + " times in 2s"); + t.end(); + } + }, 2000); }); test("RTCAudioSink should send ondata events when defined outside ontrack", (t) => { @@ -150,32 +168,52 @@ test("RTCAudioSink should send ondata events when defined outside ontrack", (t) pcA.addTrack(track); const sink = new RTCAudioSink(track); - sink.addEventListener("data", () => { - ondataDidFired += 1; - }); const sampleRate = 8000; const samples = new Int16Array(sampleRate / 100); for (let n = 0; n < samples.length; n++) { samples[n] = Math.random() * 0xffff; } - const interval = setInterval(() => { - source.onData({ samples, sampleRate }); - }, 10); - setTimeout(() => { + var TARGET = 10; + var received = 0; + var interval; + + function done() { clearInterval(interval); - // TODO(jack): yes >= 9 and not 10 because some random thing in eventloop and setinterval/timeout result in values to be 9||10||11 - t.ok( - ondataDidFired >= 9, - "RTCAudioSink should have fired 10 time in 100ms" - ); sink.stop(); track.stop(); pcA.close(); pcB.close(); - t.end(); - }, 105); + } + + sink.addEventListener("data", () => { + ondataDidFired += 1; + if (ondataDidFired >= TARGET && received === 0) { + received = ondataDidFired; + done(); + t.ok( + received >= TARGET, + "RTCAudioSink fired at least " + TARGET + " times", + ); + t.end(); + } + }); + + interval = setInterval(() => { + source.onData({ samples, sampleRate }); + }, 10); + + setTimeout(() => { + if (received === 0) { + // Prevent a late data event (where ondataDidFired reaches TARGET + // after this timeout) from calling done()/t.end() a second time. + received = -1; + done(); + t.fail("RTCAudioSink only fired " + ondataDidFired + " times in 2s"); + t.end(); + } + }, 2000); }); /** From 3dbb545a2b9a565b67f72c2665f673a00ce23e00 Mon Sep 17 00:00:00 2001 From: Kevin Hanna Date: Thu, 19 Mar 2026 23:18:11 +0000 Subject: [PATCH 6/9] fix(build): link SimulcastEncoderAdapter for video encoding support BuiltinVideoEncoderFactory::CreateVideoEncoder() constructs a SimulcastEncoderAdapter (builtin_video_encoder_factory.cc:45), but librtc_simulcast_encoder_adapter.a was never linked into the final binary. The constructor symbol resolved to 0x0, causing a segfault on the EncoderQueue when sending video frames through a negotiated peer connection. Found via `nm -g wrtc.node | grep "U "` which showed a single undefined webrtc symbol: SimulcastEncoderAdapterC1. --- CMakeLists.txt | 6 ++++++ scripts/build-webrtc.sh | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 63b34065e..38375580c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -261,6 +261,7 @@ else() ${libwebrtc_binary_dir}/obj/api/video_codecs/libbuiltin_video_encoder_factory.a ${libwebrtc_binary_dir}/obj/api/video_codecs/libbuiltin_video_decoder_factory.a ${libwebrtc_binary_dir}/obj/media/librtc_internal_video_codecs.a + ${libwebrtc_binary_dir}/obj/media/librtc_simulcast_encoder_adapter.a ) endif() @@ -322,6 +323,10 @@ add_library(librtc_internal_video_codecs STATIC IMPORTED) add_dependencies(librtc_internal_video_codecs project_libwebrtc) set_property(TARGET librtc_internal_video_codecs PROPERTY IMPORTED_LOCATION "${libwebrtc_binary_dir}/obj/media/librtc_internal_video_codecs.a") +add_library(librtc_simulcast_encoder_adapter STATIC IMPORTED) +add_dependencies(librtc_simulcast_encoder_adapter project_libwebrtc) +set_property(TARGET librtc_simulcast_encoder_adapter PROPERTY IMPORTED_LOCATION "${libwebrtc_binary_dir}/obj/media/librtc_simulcast_encoder_adapter.a") + set(libc++_include_dir "${libwebrtc_source_dir}/src/buildtools/third_party/libc++/trunk/include" "${libwebrtc_source_dir}/src/buildtools/third_party/libc++" @@ -404,6 +409,7 @@ target_link_libraries(${MODULE} PRIVATE libbuiltin_video_encoder_factory libbuiltin_video_decoder_factory librtc_internal_video_codecs + librtc_simulcast_encoder_adapter ${CMAKE_JS_LIB} ) diff --git a/scripts/build-webrtc.sh b/scripts/build-webrtc.sh index b00f1f052..f3002e3c7 100755 --- a/scripts/build-webrtc.sh +++ b/scripts/build-webrtc.sh @@ -6,6 +6,6 @@ set -v # We want to use system ninja, _NOT_ depot_tools ninja, actually export PATH="${DEPOT_TOOLS}/python-bin:${PATH}:${DEPOT_TOOLS}" -export TARGETS="webrtc libjingle_peerconnection libc++ libc++abi builtin_video_encoder_factory builtin_video_decoder_factory rtc_internal_video_codecs" +export TARGETS="webrtc libjingle_peerconnection libc++ libc++abi builtin_video_encoder_factory builtin_video_decoder_factory rtc_internal_video_codecs rtc_simulcast_encoder_adapter" ninja $TARGETS From dee565ca3a9226aac27e502ae87d82390cd358f6 Mon Sep 17 00:00:00 2001 From: Kevin Hanna Date: Fri, 20 Mar 2026 12:32:36 +0000 Subject: [PATCH 7/9] fix: avoid double RegisterObserver on DataChannel to prevent message loss In M114, SctpDataChannel uses an ObserverAdapter that relays callbacks through the signaling thread via SafeTask. When RegisterObserver was called twice (first from DataChannelObserver's constructor, then from RTCDataChannel's constructor), the adapter's SetDelegate call could race with in-flight message delivery tasks on the signaling thread, causing messages sent immediately after dc.onopen to be silently lost. The fix removes the initial RegisterObserver from DataChannelObserver, so registration only happens once in RTCDataChannel's constructor. The SCTP layer queues any messages that arrive before an observer is registered, and DeliverQueuedReceivedData() delivers them when RegisterObserver is called. Also dispatches the "open" state change event if the channel has already transitioned to kOpen before the RTCDataChannel wrapper is constructed, ensuring JS onopen handlers fire. --- src/interfaces/rtc_data_channel.cc | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/interfaces/rtc_data_channel.cc b/src/interfaces/rtc_data_channel.cc index b658b6ead..6d43dc563 100644 --- a/src/interfaces/rtc_data_channel.cc +++ b/src/interfaces/rtc_data_channel.cc @@ -30,7 +30,9 @@ DataChannelObserver::DataChannelObserver( PeerConnectionFactory *factory, rtc::scoped_refptr jingleDataChannel) : _factory(factory), _jingleDataChannel(std::move(jingleDataChannel)) { - _jingleDataChannel->RegisterObserver(this); + // Don't register here. Registration happens in RTCDataChannel's constructor, + // which avoids a double RegisterObserver that causes message loss through + // M114's ObserverAdapter signaling thread relay. } void DataChannelObserver::OnStateChange() { @@ -72,9 +74,20 @@ RTCDataChannel::RTCDataChannel(const Napi::CallbackInfo &info) _jingleDataChannel = observer->_jingleDataChannel; _jingleDataChannel->RegisterObserver(this); - // Re-queue cached observer events + // Re-queue any cached observer events (from the window between + // OnDataChannel and this constructor). requeue(*observer, *this); + // If the channel already transitioned to open before we registered, + // dispatch the open event so JS onopen handlers fire. + auto state = _jingleDataChannel->state(); + if (state == webrtc::DataChannelInterface::kOpen) { + Dispatch( + Callback1::Create([state](RTCDataChannel &channel) { + RTCDataChannel::HandleStateChange(channel, state); + })); + } + delete observer; // NOTE(mroberts): These doesn't actually matter yet. From a764718f0a8cee616eb63a433461fbbf67ecad0d Mon Sep 17 00:00:00 2001 From: Kevin Hanna Date: Thu, 19 Mar 2026 23:18:13 +0000 Subject: [PATCH 8/9] fix: use separate network thread in PeerConnectionFactory Previously, the same thread was passed for both network_thread and worker_thread to CreatePeerConnectionFactory. In M114, many operations moved to the network thread, and PeerConnection::Close() does sequential BlockingCalls to network then worker threads (pc/peer_connection.cc:1909-1929). Sharing a single thread caused cleanup contention that limited sequential connection throughput. This matches Chrome's architecture (pc/connection_context.cc:85-98) where network, worker, and signaling threads are all separate. The network thread gets a socket server for ICE port management, matching the CreateWithSocketServer() pattern Chrome uses. --- .../peer_connection_factory.cc | 24 +++++++++++++++---- .../peer_connection_factory.hh | 1 + 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/interfaces/rtc_peer_connection/peer_connection_factory.cc b/src/interfaces/rtc_peer_connection/peer_connection_factory.cc index e3b4c3046..5bb0e71a5 100644 --- a/src/interfaces/rtc_peer_connection/peer_connection_factory.cc +++ b/src/interfaces/rtc_peer_connection/peer_connection_factory.cc @@ -52,10 +52,22 @@ PeerConnectionFactory::PeerConnectionFactory(const Napi::CallbackInfo &info) // TODO(mroberts): Read `audioLayer` from some PeerConnectionFactoryOptions? auto audioLayer = MakeNothing(); - _workerThread = rtc::Thread::CreateWithSocketServer(); - assert(_workerThread); + _networkThread = rtc::Thread::CreateWithSocketServer(); + assert(_networkThread); bool result = + _networkThread->SetName("PeerConnectionFactory:networkThread", nullptr); + assert(result); + (void)result; + + result = _networkThread->Start(); + assert(result); + (void)result; + + _workerThread = rtc::Thread::Create(); + assert(_workerThread); + + result = _workerThread->SetName("PeerConnectionFactory:workerThread", nullptr); assert(result); (void)result; @@ -93,7 +105,7 @@ PeerConnectionFactory::PeerConnectionFactory(const Napi::CallbackInfo &info) (void)result; _factory = webrtc::CreatePeerConnectionFactory( - _workerThread.get(), _workerThread.get(), _signalingThread.get(), + _networkThread.get(), _workerThread.get(), _signalingThread.get(), _audioDeviceModule, webrtc::CreateBuiltinAudioEncoderFactory(), webrtc::CreateBuiltinAudioDecoderFactory(), webrtc::CreateBuiltinVideoEncoderFactory(), @@ -105,11 +117,11 @@ PeerConnectionFactory::PeerConnectionFactory(const Napi::CallbackInfo &info) _factory->SetOptions(options); _networkManager = std::unique_ptr( - new rtc::BasicNetworkManager(_workerThread->socketserver())); + new rtc::BasicNetworkManager(_networkThread->socketserver())); assert(_networkManager != nullptr); _socketFactory = std::unique_ptr( - new rtc::BasicPacketSocketFactory(_workerThread->socketserver())); + new rtc::BasicPacketSocketFactory(_networkThread->socketserver())); assert(_socketFactory != nullptr); } @@ -118,9 +130,11 @@ PeerConnectionFactory::~PeerConnectionFactory() { _workerThread->BlockingCall([this]() { this->_audioDeviceModule = nullptr; }); + _networkThread->Stop(); _workerThread->Stop(); _signalingThread->Stop(); + _networkThread = nullptr; _workerThread = nullptr; _signalingThread = nullptr; diff --git a/src/interfaces/rtc_peer_connection/peer_connection_factory.hh b/src/interfaces/rtc_peer_connection/peer_connection_factory.hh index bb6257007..f477076ba 100644 --- a/src/interfaces/rtc_peer_connection/peer_connection_factory.hh +++ b/src/interfaces/rtc_peer_connection/peer_connection_factory.hh @@ -62,6 +62,7 @@ public: static void Dispose(); private: + std::unique_ptr _networkThread; std::unique_ptr _signalingThread; std::unique_ptr _workerThread; From ab7942a00b9b9a40738ddd6a5c7074412bf68260 Mon Sep 17 00:00:00 2001 From: Kevin Hanna Date: Thu, 19 Mar 2026 23:18:18 +0000 Subject: [PATCH 9/9] chore: add nix.gni fallback for non-Nix builds and fix compiler warnings CMakeLists.txt previously required a nix.gni file (generated by the Nix shell hook) containing platform-specific GN args. Builds outside Nix failed with "file STRINGS file nix.gni cannot be read". Now falls back to sensible defaults (is_clang=true, use_lld=false, clang_use_chrome_plugins=false) when the file doesn't exist. Also add a default UknownError path for ErrorFactory::DOMExceptionNameToString --- CMakeLists.txt | 13 ++++++++++++- src/node/error_factory.cc | 2 ++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 38375580c..da95a7529 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -185,7 +185,18 @@ set_target_properties(libc++abi PROPERTIES IMPORTED_OBJECTS_DEBUG "${libc++abi_o # M114: branch-heads/5735 set(WEBRTC_REVISION branch-heads/5735) -file(STRINGS nix.gni NIX_GN_GEN_ARGS) +# nix.gni is generated by the Nix shell hook (shell.nix) with platform-specific +# GN args like clang_base_path and mac_sdk_path. For non-Nix builds, create an +# empty file or one with: is_clang=true\nuse_lld=false\nclang_use_chrome_plugins=false +if(EXISTS "${CMAKE_SOURCE_DIR}/nix.gni") + file(STRINGS nix.gni NIX_GN_GEN_ARGS) +else() + set(NIX_GN_GEN_ARGS + "is_clang=true" + "use_lld=false" + "clang_use_chrome_plugins=false" + ) +endif() list(APPEND GN_GEN_ARGS rtc_build_examples=false rtc_use_x11=false diff --git a/src/node/error_factory.cc b/src/node/error_factory.cc index 35aafe0f3..e5ec72e72 100644 --- a/src/node/error_factory.cc +++ b/src/node/error_factory.cc @@ -94,6 +94,8 @@ node_webrtc::ErrorFactory::DOMExceptionNameToString(DOMExceptionName name) { return "NetworkError"; case kOperationError: return "OperationError"; + default: + return "UnknownError"; } }