From 8128b7cc93d6478fce63e19ebfc9aeeb24fcb03b Mon Sep 17 00:00:00 2001 From: Kevin Hanna Date: Thu, 19 Mar 2026 23:17:58 +0000 Subject: [PATCH 01/14] 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 fdf7e5d3b778bbacc34cb5391134d4215d7bfd7e Mon Sep 17 00:00:00 2001 From: Kevin Hanna Date: Thu, 19 Mar 2026 23:18:03 +0000 Subject: [PATCH 02/14] 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 | 1 + src/interfaces/rtc_peer_connection.cc | 18 +++++++++++------- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/interfaces/rtc_data_channel.cc b/src/interfaces/rtc_data_channel.cc index 65485a1bf..086e5009b 100644 --- a/src/interfaces/rtc_data_channel.cc +++ b/src/interfaces/rtc_data_channel.cc @@ -106,6 +106,7 @@ void RTCDataChannel::CleanupInternals() { void RTCDataChannel::OnPeerConnectionClosed() { if (_jingleDataChannel != nullptr) { + 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 00d8bc1ddfd53a8b9b27633f8924135c9c0b564d Mon Sep 17 00:00:00 2001 From: Kevin Hanna Date: Thu, 19 Mar 2026 23:18:05 +0000 Subject: [PATCH 03/14] 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 05bfaee2f214bc7b44a798bb68e74ce54e0b043c Mon Sep 17 00:00:00 2001 From: Kevin Hanna Date: Thu, 19 Mar 2026 23:18:07 +0000 Subject: [PATCH 04/14] 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 03d1f474f58df6727749922ea7f7c16c6951f5b7 Mon Sep 17 00:00:00 2001 From: Kevin Hanna Date: Thu, 19 Mar 2026 23:18:09 +0000 Subject: [PATCH 05/14] 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 ad368ff270b3df42446794bd7ae0c5a08effa754 Mon Sep 17 00:00:00 2001 From: Kevin Hanna Date: Thu, 19 Mar 2026 23:18:11 +0000 Subject: [PATCH 06/14] 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 00a964a7384c16b07734f44c364f2dc80aba4f5f Mon Sep 17 00:00:00 2001 From: Kevin Hanna Date: Fri, 20 Mar 2026 12:32:36 +0000 Subject: [PATCH 07/14] 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 086e5009b..662815226 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 3375b3caf49549ea62ed083ac42693acd88a1132 Mon Sep 17 00:00:00 2001 From: Kevin Hanna Date: Thu, 19 Mar 2026 23:18:13 +0000 Subject: [PATCH 08/14] 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 60c389597c145766498175bd73184b74d0c00dd8 Mon Sep 17 00:00:00 2001 From: Kevin Hanna Date: Thu, 19 Mar 2026 23:18:18 +0000 Subject: [PATCH 09/14] 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"; } } From 82b13357847f9777dc3f935d8699f949af6d1f70 Mon Sep 17 00:00:00 2001 From: Kevin Hanna Date: Tue, 24 Mar 2026 13:41:45 +0000 Subject: [PATCH 10/14] fix: expose NetworkThread() accessor on PeerConnectionFactory WebRTC's SctpTransport is constructed on the network thread and asserts owner_thread_->IsCurrent() on most methods (see the RTC_DCHECK_RUN_ON in the WebRTC source at pc/sctp_transport.cc lines 59, 66, 105). The node-webrtc code was dispatching to WorkerThread instead, which is the wrong thread. Expose NetworkThread so callers can dispatch correctly. --- src/interfaces/rtc_peer_connection/peer_connection_factory.hh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/interfaces/rtc_peer_connection/peer_connection_factory.hh b/src/interfaces/rtc_peer_connection/peer_connection_factory.hh index f477076ba..d3b9f0094 100644 --- a/src/interfaces/rtc_peer_connection/peer_connection_factory.hh +++ b/src/interfaces/rtc_peer_connection/peer_connection_factory.hh @@ -47,6 +47,8 @@ public: return _factory; } + std::unique_ptr &NetworkThread() { return _networkThread; } + std::unique_ptr &SignalingThread() { return _signalingThread; } std::unique_ptr &WorkerThread() { return _workerThread; } From 0285d357b1a4a3f6072455c8005704e6fb5480be Mon Sep 17 00:00:00 2001 From: Kevin Hanna Date: Tue, 24 Mar 2026 17:30:27 +0000 Subject: [PATCH 11/14] fix: dispatch transport calls to network thread instead of worker thread WebRTC's SctpTransport, DtlsTransport, and IceTransport are all constructed on the network thread and assert owner_thread_->IsCurrent() (or creator_thread_->IsCurrent()) on methods like dtls_transport(), RegisterObserver(), UnregisterObserver(), and internal(). See the RTC_DCHECK_RUN_ON assertions in the WebRTC source: - pc/sctp_transport.cc lines 59, 66, 105 - pc/dtls_transport.cc lines 55, 61 - pc/ice_transport.cc line 27 The node-webrtc code was dispatching these calls to WorkerThread, which is the wrong thread and causes a crash in debug builds: Fatal error in: pc/sctp_transport.cc, line 105 Check failed: (owner_thread_)->IsCurrent() Additionally, PeerConnection::GetSctpTransport() requires the network thread (pc/peer_connection.cc line 1817). Change all call sites to use NetworkThread(). For Stop() methods that may be called from network-thread callbacks (e.g. OnStateChange), use IsCurrent() to avoid a redundant BlockingCall. --- src/interfaces/rtc_data_channel.cc | 3 +++ src/interfaces/rtc_dtls_transport.cc | 9 +++++++-- src/interfaces/rtc_ice_transport.cc | 2 +- src/interfaces/rtc_peer_connection.cc | 13 +++++++------ src/interfaces/rtc_sctp_transport.cc | 9 +++++++-- 5 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/interfaces/rtc_data_channel.cc b/src/interfaces/rtc_data_channel.cc index 662815226..30ea319de 100644 --- a/src/interfaces/rtc_data_channel.cc +++ b/src/interfaces/rtc_data_channel.cc @@ -125,6 +125,9 @@ void RTCDataChannel::OnPeerConnectionClosed() { } void RTCDataChannel::OnStateChange() { + if (_jingleDataChannel == nullptr) { + return; + } auto state = _jingleDataChannel->state(); if (state == webrtc::DataChannelInterface::kClosed) { CleanupInternals(); diff --git a/src/interfaces/rtc_dtls_transport.cc b/src/interfaces/rtc_dtls_transport.cc index 38c2b6867..033740d22 100644 --- a/src/interfaces/rtc_dtls_transport.cc +++ b/src/interfaces/rtc_dtls_transport.cc @@ -76,7 +76,7 @@ RTCDtlsTransport::RTCDtlsTransport(const Napi::CallbackInfo &info) // NOTE(mroberts): Ensure we create this. _transport_wrap.GetOrCreate(_factory, _transport->ice_transport()); - _factory->WorkerThread()->BlockingCall([this]() { + _factory->NetworkThread()->BlockingCall([this]() { _transport->RegisterObserver(this); auto information = _transport->Information(); _state = information.state(); @@ -94,7 +94,12 @@ RTCDtlsTransport::~RTCDtlsTransport() { } void RTCDtlsTransport::Stop() { - _transport->UnregisterObserver(); + if (_factory->NetworkThread()->IsCurrent()) { + _transport->UnregisterObserver(); + } else { + _factory->NetworkThread()->BlockingCall( + [this]() { _transport->UnregisterObserver(); }); + } auto ice_transport = _transport_wrap.Get(_transport->ice_transport()); if (ice_transport) { ice_transport->OnRTCDtlsTransportStopped(); diff --git a/src/interfaces/rtc_ice_transport.cc b/src/interfaces/rtc_ice_transport.cc index 8d7c5e656..cf8f13666 100644 --- a/src/interfaces/rtc_ice_transport.cc +++ b/src/interfaces/rtc_ice_transport.cc @@ -38,7 +38,7 @@ RTCIceTransport::RTCIceTransport(const Napi::CallbackInfo &info) _transport = std::move(transport); - _factory->WorkerThread()->BlockingCall([this]() { + _factory->NetworkThread()->BlockingCall([this]() { auto internal = _transport->internal(); if (internal) { internal->SignalIceTransportStateChanged.connect( diff --git a/src/interfaces/rtc_peer_connection.cc b/src/interfaces/rtc_peer_connection.cc index a3c576413..9a2a28588 100644 --- a/src/interfaces/rtc_peer_connection.cc +++ b/src/interfaces/rtc_peer_connection.cc @@ -938,12 +938,13 @@ RTCPeerConnection::GetPendingRemoteDescription(const Napi::CallbackInfo &info) { } Napi::Value RTCPeerConnection::GetSctp(const Napi::CallbackInfo &info) { - return _jinglePeerConnection && _jinglePeerConnection->GetSctpTransport() - ? _transport_wrap - .GetOrCreate(_factory, - _jinglePeerConnection->GetSctpTransport()) - ->Value() - : info.Env().Null(); + auto transport = _jinglePeerConnection + ? _factory->NetworkThread()->BlockingCall([this]() { + return _jinglePeerConnection->GetSctpTransport(); + }) + : nullptr; + return transport ? _transport_wrap.GetOrCreate(_factory, transport)->Value() + : info.Env().Null(); } Napi::Value diff --git a/src/interfaces/rtc_sctp_transport.cc b/src/interfaces/rtc_sctp_transport.cc index ff0b2b0d3..87ad6d7cd 100644 --- a/src/interfaces/rtc_sctp_transport.cc +++ b/src/interfaces/rtc_sctp_transport.cc @@ -38,7 +38,7 @@ RTCSctpTransport::RTCSctpTransport(const Napi::CallbackInfo &info) _transport = std::move(transport); - _factory->WorkerThread()->BlockingCall([this]() { + _factory->NetworkThread()->BlockingCall([this]() { _dtls_transport = _transport->dtls_transport(); _transport->RegisterObserver(this); }); @@ -54,7 +54,12 @@ RTCSctpTransport::RTCSctpTransport(const Napi::CallbackInfo &info) RTCSctpTransport::~RTCSctpTransport() { wrap()->Release(this); } void RTCSctpTransport::Stop() { - _transport->UnregisterObserver(); + if (_factory->NetworkThread()->IsCurrent()) { + _transport->UnregisterObserver(); + } else { + _factory->NetworkThread()->BlockingCall( + [this]() { _transport->UnregisterObserver(); }); + } AsyncObjectWrapWithLoop::Stop(); } From 77ea81b7cb71d2c7e992cb1be75e1a9e3250d6a3 Mon Sep 17 00:00:00 2001 From: Kevin Hanna Date: Thu, 26 Mar 2026 17:57:50 +0000 Subject: [PATCH 12/14] fix(build): reorder link libraries to fix undefined symbol on Linux Move libwebrtc after the video codec factory libraries so the GNU linker sees their unresolved symbols before processing libwebrtc. Fixes undefined symbol _ZN6webrtc18SupportedVP9CodecsEb on Linux (macOS is unaffected due to its two-pass linker). --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index da95a7529..d9bcd4a04 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -416,11 +416,11 @@ target_include_directories(${MODULE} PRIVATE target_link_libraries(${MODULE} PRIVATE ${CMAKE_THREAD_LIBS_INIT} libpeerconnection - libwebrtc libbuiltin_video_encoder_factory libbuiltin_video_decoder_factory librtc_internal_video_codecs librtc_simulcast_encoder_adapter + libwebrtc ${CMAKE_JS_LIB} ) From 002d713679f00c10f46a87e8c1a7e7a5aab47816 Mon Sep 17 00:00:00 2001 From: Kevin Hanna Date: Thu, 26 Mar 2026 17:57:43 +0000 Subject: [PATCH 13/14] fix(build): add curl and git to Nix shell dependencies These are needed inside the Nix FHS environment for depot_tools bootstrapping (curl) and CMake ExternalProject downloads (git). --- shell.nix | 2 ++ 1 file changed, 2 insertions(+) diff --git a/shell.nix b/shell.nix index 4fe17fa70..da0dca9ed 100644 --- a/shell.nix +++ b/shell.nix @@ -23,6 +23,8 @@ let nativeBuildInputs = (with pkgs; [ cmake + curl + git ninja nodejs_24 pkg-config From 6ba1e69462fae83e8bab195d4db38523bf1c567d Mon Sep 17 00:00:00 2001 From: Kevin Hanna Date: Fri, 27 Mar 2026 13:58:39 +0000 Subject: [PATCH 14/14] fix(test): force process exit after all tests finish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In debug builds, a Node.js-internal referenced async handle keeps the event loop alive after all tests complete. This is not caused by the native addon — all addon handles are verified stopped via per-object create/stop tracking. This does not occur in release builds. Use tape's onFinish callback to exit with the appropriate code. --- test/all.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/all.js b/test/all.js index f72aa3ffa..b40608959 100644 --- a/test/all.js +++ b/test/all.js @@ -1,7 +1,15 @@ +/* eslint no-process-exit:0 */ "use strict"; // require('child_process').spawnSync('pause', {shell: true, stdio: 'inherit'}); // + +// In debug builds, a Node.js-internal referenced async handle keeps the event +// loop alive after all tests complete (not caused by the native addon — all +// addon handles are verified stopped). This does not occur in release builds. +var test = require("tape"); +test.onFinish(() => process.exit(test._exitCode)); + require("./addicecandidate"); require("./closing-data-channel"); require("./closing-peer-connection");