From a0facea495aa57f695002497c029e34d0dfa5c64 Mon Sep 17 00:00:00 2001 From: "Ilya (Marshal)" Date: Thu, 15 Oct 2026 19:21:51 +0200 Subject: [PATCH 1/5] Drop unused audio processing and video render smoothing --- .../cpp/src/interfaces/peer_connection_factory.cpp | 9 +++------ python-webrtc/cpp/src/interfaces/rtc_data_channel.cpp | 2 +- .../rtc_peer_connection/rtc_peer_connection.cpp | 2 ++ python-webrtc/cpp/src/interfaces/rtc_rtp_sender.cpp | 2 +- python-webrtc/python/webrtc/exceptions.py | 2 +- python-webrtc/python/webrtc/models/rtp_parameters.py | 2 +- 6 files changed, 9 insertions(+), 10 deletions(-) diff --git a/python-webrtc/cpp/src/interfaces/peer_connection_factory.cpp b/python-webrtc/cpp/src/interfaces/peer_connection_factory.cpp index 2924d3f..bb94767 100644 --- a/python-webrtc/cpp/src/interfaces/peer_connection_factory.cpp +++ b/python-webrtc/cpp/src/interfaces/peer_connection_factory.cpp @@ -17,7 +17,6 @@ #include "../utils/port_allocator.h" #include "../utils/task_queue_factory.h" -#include #include #include #include @@ -84,7 +83,7 @@ namespace python_webrtc { } } // namespace - // webrtc::CreatePeerConnectionFactory without the RTC event log, which keeps every packet's buffer in memory + // webrtc::CreatePeerConnectionFactory without the RTC event log (holds every packet) or audio processing (no capture) webrtc::PeerConnectionFactoryDependencies PeerConnectionFactory::Dependencies() { webrtc::PeerConnectionFactoryDependencies dependencies; dependencies.network_thread = _workerThread.get(); @@ -95,7 +94,6 @@ namespace python_webrtc { dependencies.adm = _audioDeviceModule; dependencies.audio_encoder_factory = webrtc::CreateBuiltinAudioEncoderFactory(); dependencies.audio_decoder_factory = webrtc::CreateBuiltinAudioDecoderFactory(); - dependencies.audio_processing_builder = std::make_unique(); dependencies.video_encoder_factory = std::make_unique(); dependencies.video_decoder_factory = std::make_unique(); webrtc::EnableMedia(dependencies); @@ -117,9 +115,8 @@ namespace python_webrtc { PeerConnectionFactory::PeerConnectionFactory() : _fieldTrials(_trials), _networkIgnoreMask(_loopbackAllowed ? 0 : webrtc::kDefaultNetworkIgnoreMask) { - _workerThread = Started(std::make_unique(std::make_unique()), - "PeerConnectionFactory:workerThread"); - _signalingThread = Started(webrtc::Thread::Create(), "PeerConnectionFactory:signalingThread"); + _workerThread = Started(std::make_unique(std::make_unique()), "wrtc-network"); + _signalingThread = Started(webrtc::Thread::Create(), "wrtc-signaling"); // sanitizer builds check that these threads never enter Python _workerThread->PostTask(&TagNativeThread); _signalingThread->PostTask(&TagNativeThread); diff --git a/python-webrtc/cpp/src/interfaces/rtc_data_channel.cpp b/python-webrtc/cpp/src/interfaces/rtc_data_channel.cpp index 2a66038..719a7d2 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_data_channel.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_data_channel.cpp @@ -204,7 +204,7 @@ namespace python_webrtc { } webrtc::Priority RTCDataChannel::GetPriority() { - // the ranges Chromium maps priority values to + // splits at the midpoints between the DCEP priority values of W3C webrtc-priority (128, 256, 512, 1024) constexpr int veryLowMax = 192; constexpr int lowMax = 384; constexpr int mediumMax = 768; diff --git a/python-webrtc/cpp/src/interfaces/rtc_peer_connection/rtc_peer_connection.cpp b/python-webrtc/cpp/src/interfaces/rtc_peer_connection/rtc_peer_connection.cpp index 39e33ce..03cf622 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_peer_connection/rtc_peer_connection.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_peer_connection/rtc_peer_connection.cpp @@ -32,6 +32,8 @@ namespace python_webrtc { auto configuration = webrtc::PeerConnectionInterface::RTCConfiguration(); configuration.sdp_semantics = webrtc::SdpSemantics::kUnifiedPlan; configuration = _configuration.Apply(configuration); + // renders each decoded frame at once, without a smoothing queue per video receiver + configuration.set_prerenderer_smoothing(false); webrtc::PeerConnectionDependencies dependencies(this); dependencies.allocator = _factory->CreatePortAllocator(configuration); diff --git a/python-webrtc/cpp/src/interfaces/rtc_rtp_sender.cpp b/python-webrtc/cpp/src/interfaces/rtc_rtp_sender.cpp index 4805562..34d9cf7 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_rtp_sender.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_rtp_sender.cpp @@ -22,7 +22,7 @@ namespace python_webrtc { namespace { - // only what setParameters may change, as in Chromium + // only the encoding members setParameters may change, the rest keep their current values void applySettable(const webrtc::RtpEncodingParameters &requested, webrtc::RtpEncodingParameters ¤t) { current.active = requested.active; current.request_key_frame = requested.request_key_frame; diff --git a/python-webrtc/python/webrtc/exceptions.py b/python-webrtc/python/webrtc/exceptions.py index 481b5a0..6e1ceea 100644 --- a/python-webrtc/python/webrtc/exceptions.py +++ b/python-webrtc/python/webrtc/exceptions.py @@ -212,7 +212,7 @@ def __init__(self, init: RTCErrorInit, message: str = '') -> None: _BY_RTC_ERROR_TYPE = { - # the same mapping as Chromium's + # the W3C error names for libwebrtc's RTCErrorType values 'UNSUPPORTED_OPERATION': OperationError, 'UNSUPPORTED_PARAMETER': InvalidAccessError, 'INVALID_PARAMETER': InvalidAccessError, diff --git a/python-webrtc/python/webrtc/models/rtp_parameters.py b/python-webrtc/python/webrtc/models/rtp_parameters.py index 3b29d4a..e7be6cd 100644 --- a/python-webrtc/python/webrtc/models/rtp_parameters.py +++ b/python-webrtc/python/webrtc/models/rtp_parameters.py @@ -31,7 +31,7 @@ _NativeCodecT = TypeVar('_NativeCodecT', bound='wrtc.RtpCodec') -# the bitrate priorities of libwebrtc for RTCRtpEncodingParameters.priority, as Chromium maps them +# libwebrtc's bitrate priorities for RTCRtpEncodingParameters.priority, doubling per level from low (the default) at 1.0 _BITRATE_PRIORITY = { RTCPriorityType.very_low: 0.5, RTCPriorityType.low: 1.0, From 5a972c50643037866c72544575ff8539cb61fed3 Mon Sep 17 00:00:00 2001 From: "Ilya (Marshal)" Date: Thu, 15 Oct 2026 20:04:01 +0200 Subject: [PATCH 2/5] Fix data channel error events and bufferedAmount, copy messages once --- python-webrtc/cpp/NATIVE.md | 2 +- .../cpp/src/interfaces/rtc_data_channel.cpp | 40 +++--- .../cpp/src/interfaces/rtc_data_channel.h | 9 +- python-webrtc/cpp/src/utils/mailbox.h | 11 +- .../webrtc/interfaces/rtc_data_channel.py | 6 +- python-webrtc/python/webrtc/utils/strings.py | 8 ++ stubs/wrtc/__init__.pyi | 2 +- tests/test_data_channel.py | 119 ++++++++++++++++++ 8 files changed, 170 insertions(+), 27 deletions(-) diff --git a/python-webrtc/cpp/NATIVE.md b/python-webrtc/cpp/NATIVE.md index 5fb5370..50ea603 100644 --- a/python-webrtc/cpp/NATIVE.md +++ b/python-webrtc/cpp/NATIVE.md @@ -102,7 +102,7 @@ One direction: Python → C++ → libwebrtc. unregistered is ended by `Disarm()` at the start of the destructor, which then waits for the callback's thread once. - Raw pointers never own: they point to libwebrtc observers and callbacks, or into C APIs. A raw pointer libwebrtc hands over to own (`OnSuccess(SessionDescriptionInterface *)`) goes into a `std::unique_ptr` right away. -- An event's arguments are native values copied at `Emit`; they're converted to Python on the loop thread. +- An event's arguments are native values copied or moved at `Emit`; they're converted to Python on the loop thread. - Registries and the Dispatcher are leaked on purpose (`static auto *x = new ...`, `ForkLocal`): native threads can outlive static destructors at exit. diff --git a/python-webrtc/cpp/src/interfaces/rtc_data_channel.cpp b/python-webrtc/cpp/src/interfaces/rtc_data_channel.cpp index 719a7d2..2362506 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_data_channel.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_data_channel.cpp @@ -14,6 +14,7 @@ #include "../exceptions.h" #include "../utils/gil.h" #include "../utils/libwebrtc_thread.h" +#include "../utils/parking.h" namespace python_webrtc { @@ -61,12 +62,13 @@ namespace python_webrtc { void RTCDataChannel::Init(pybind11::module &m) { pybind11::class_(m, "DataChannelMessage") .def_property_readonly("data", [](const DataChannelMessage &message) -> pybind11::object { + const auto *data = message.data.cdata(); if (message.binary) { - return pybind11::bytes(message.data); + return pybind11::bytes(data, message.data.size()); } // the upper layers verify UTF-8, as libwebrtc doesn't return pybind11::reinterpret_steal( - PyUnicode_DecodeUTF8(message.data.data(), static_cast(message.data.size()), "replace")); + PyUnicode_DecodeUTF8(data, static_cast(message.data.size()), "replace")); }); DefineBinding(pybind11::class_>(m, "RTCDataChannel")) @@ -84,7 +86,15 @@ namespace python_webrtc { .def_property("bufferedAmountLowThreshold", nogil_fn(&RTCDataChannel::GetBufferedAmountLowThreshold), nogil_fn(&RTCDataChannel::SetBufferedAmountLowThreshold)) .def_property("binaryType", nogil_fn(&RTCDataChannel::GetBinaryType), nogil_fn(&RTCDataChannel::SetBinaryType)) - .def("send", &RTCDataChannel::Send, nogil(), pybind11::arg("data"), pybind11::arg("binary")) + .def( + "send", + // only bytes: the view must outlive the call without the GIL, which a bytearray's resize breaks + [](RTCDataChannel &self, const pybind11::bytes &data, bool binary) { + const std::string_view view = data; + const gil_release release; + return self.Send(view, binary); + }, + pybind11::arg("data"), pybind11::arg("binary")) .def("close", &RTCDataChannel::Close, nogil()) .def("_surfaceState", &RTCDataChannel::SurfaceState, nogil(), pybind11::arg("state")) .def("_decreaseBufferedAmount", &RTCDataChannel::DecreaseBufferedAmount, nogil(), pybind11::arg("sent")); @@ -116,17 +126,22 @@ namespace python_webrtc { Emit("open", state); break; case DataState::kClosing: + Parking::At("datachannel.closing"); Emit("closing", state); break; case DataState::kClosed: { auto error = _channel->error(); - // closing locally isn't an error, even if queued messages couldn't be sent bool closedLocally = false; { const std::scoped_lock lock(_closeMutex); closedLocally = _closeRequested; } - if (!error.ok() && !closedLocally) { + const bool detailed = error.error_detail() != webrtc::RTCErrorDetailType::NONE; + // closing locally isn't an error + if (!error.ok() && (detailed || !closedLocally)) { + if (!detailed) { + error.set_error_detail(webrtc::RTCErrorDetailType::DATA_CHANNEL_FAILURE); + } Emit("error", RTCCallbackException(std::move(error))); } Emit("close", state); @@ -141,8 +156,7 @@ namespace python_webrtc { } void RTCDataChannel::OnMessage(const webrtc::DataBuffer &buffer) { - Emit("message", - DataChannelMessage{.data = std::string(buffer.data.cdata(), buffer.size()), .binary = buffer.binary}); + Emit("message", DataChannelMessage{.data = buffer.data, .binary = buffer.binary}); } void RTCDataChannel::OnBufferedAmountChange(uint64_t sentDataSize) { @@ -277,7 +291,7 @@ namespace python_webrtc { } // namespace - bool RTCDataChannel::Send(const std::string &data, bool binary) { + bool RTCDataChannel::Send(std::string_view data, bool binary) { if (GetReadyState() != DataState::kOpen) { throw RTCException(webrtc::RTCErrorType::INVALID_STATE, "RTCDataChannel.readyState is not 'open'"); } @@ -307,14 +321,8 @@ namespace python_webrtc { } void RTCDataChannel::SendNow(webrtc::DataBuffer buffer) { - auto size = buffer.size(); - // posted to the network thread, in call order; a failed send closes the channel - _channel->SendAsync(std::move(buffer), [weak = weak_from_this(), size](const webrtc::RTCError &error) { - auto self = weak.lock(); - if (!error.ok() && self) { - self->DecreaseBufferedAmount(size); - } - }); + // posted in call order; libwebrtc reports a failed send's bytes through OnBufferedAmountChange too + _channel->SendAsync(std::move(buffer), nullptr); } void RTCDataChannel::SetStateLocked(DataState state) { diff --git a/python-webrtc/cpp/src/interfaces/rtc_data_channel.h b/python-webrtc/cpp/src/interfaces/rtc_data_channel.h index bd8e647..7d0b95b 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_data_channel.h +++ b/python-webrtc/cpp/src/interfaces/rtc_data_channel.h @@ -15,8 +15,10 @@ #include #include #include +#include #include +#include #include @@ -32,7 +34,8 @@ namespace python_webrtc { // A received message, converted to str or bytes by Python struct DataChannelMessage { - std::string data; + // refcounted and only read, so it's shared across threads without a copy + webrtc::CopyOnWriteBuffer data; bool binary; }; @@ -113,8 +116,8 @@ namespace python_webrtc { // whether bufferedAmount dropped to the threshold bool DecreaseBufferedAmount(uint64_t sent); - // whether the message is the only one queued: the channel's activity changes only then - bool Send(const std::string &data, bool binary); + // whether the message is the only one queued: the channel's activity changes only then; data is copied + bool Send(std::string_view data, bool binary); void Close(); diff --git a/python-webrtc/cpp/src/utils/mailbox.h b/python-webrtc/cpp/src/utils/mailbox.h index f506514..4698c68 100644 --- a/python-webrtc/cpp/src/utils/mailbox.h +++ b/python-webrtc/cpp/src/utils/mailbox.h @@ -130,8 +130,9 @@ namespace python_webrtc { template class Emitter : public Binding { protected: + // an rvalue argument is moved into the record, and from it into Python template - void Emit(const char *name, const Args &...args) { + void Emit(const char *name, Args &&...args) { CheckNativeThreadDetached("Emitter::Emit"); if (!IsBound()) { return; @@ -141,8 +142,12 @@ namespace python_webrtc { if (!self) { return; } - Post(Record{.source = self, .target = self->Id(), .name = name, .args = [self, args...]() { - return pybind11::make_tuple(self, args...); + // Take converts a record once + Post(Record{.source = self, + .target = self->Id(), + .name = name, + .args = [self, ... args = std::forward(args)]() mutable { + return pybind11::make_tuple(self, std::move(args)...); }}); } diff --git a/python-webrtc/python/webrtc/interfaces/rtc_data_channel.py b/python-webrtc/python/webrtc/interfaces/rtc_data_channel.py index 1552ed3..c8c5cf5 100644 --- a/python-webrtc/python/webrtc/interfaces/rtc_data_channel.py +++ b/python-webrtc/python/webrtc/interfaces/rtc_data_channel.py @@ -23,7 +23,7 @@ from webrtc.models.events import Event, MessageEvent, RTCErrorEvent, RTCErrorEventInit from webrtc.utils.events import AnyHandler, EventTarget, HandlerDecorator from webrtc.utils.names import Alias, alias -from webrtc.utils.strings import usv_string +from webrtc.utils.strings import usv_utf8 if TYPE_CHECKING: import webrtc @@ -43,7 +43,7 @@ def check_utf8_length(name: str, value: str) -> None: Raises: TypeError: If it's longer. """ - if len(usv_string(value).encode()) > MAX_UNSIGNED_SHORT: + if len(usv_utf8(value)) > MAX_UNSIGNED_SHORT: msg = f'{name} is longer than {MAX_UNSIGNED_SHORT} bytes' raise TypeError(msg) @@ -399,7 +399,7 @@ def send(self, data: str | bytes | bytearray | memoryview | Blob) -> None: webrtc.OperationError: If the send queue is full. """ if isinstance(data, str): - message, binary = usv_string(data).encode(), False + message, binary = usv_utf8(data), False elif isinstance(data, (bytes, bytearray, memoryview, Blob)): message, binary = bytes(data), True else: diff --git a/python-webrtc/python/webrtc/utils/strings.py b/python-webrtc/python/webrtc/utils/strings.py index 6842b2b..122e1cb 100644 --- a/python-webrtc/python/webrtc/utils/strings.py +++ b/python-webrtc/python/webrtc/utils/strings.py @@ -17,3 +17,11 @@ def usv_string(value: str) -> str: except UnicodeEncodeError: return value.encode('utf-16-le', 'surrogatepass').decode('utf-16-le', 'replace') return value + + +def usv_utf8(value: str) -> bytes: + """Encodes a USVString in UTF-8, encoding only once unless there are lone surrogates.""" + try: + return value.encode() + except UnicodeEncodeError: + return usv_string(value).encode() diff --git a/stubs/wrtc/__init__.pyi b/stubs/wrtc/__init__.pyi index 5216641..1b0c1ea 100644 --- a/stubs/wrtc/__init__.pyi +++ b/stubs/wrtc/__init__.pyi @@ -644,7 +644,7 @@ class RTCDataChannel(Binding): ... def close(self) -> None: ... - def send(self, data: str | bytes, binary: bool) -> bool: + def send(self, data: bytes, binary: bool) -> bool: ... @property def bufferedAmount(self) -> int: diff --git a/tests/test_data_channel.py b/tests/test_data_channel.py index 68c605a..7037787 100644 --- a/tests/test_data_channel.py +++ b/tests/test_data_channel.py @@ -12,6 +12,7 @@ import asyncio import math import re +import sys from typing import cast import pytest @@ -166,6 +167,124 @@ async def test_remote_connection_closed_fires_error( assert channel.ready_state == webrtc.RTCDataChannelState.closed +def send_racing_remote_close( + channel: webrtc.RTCDataChannel, remote: webrtc.RTCDataChannel, messages: list[str | bytes] +) -> None: + """Sends once libwebrtc has the channel closing by the remote end, before its closing event is emitted.""" + wrtc._testing.park('datachannel.closing') + try: + remote.close() + assert wrtc._testing.parked('datachannel.closing', seconds(10)) + # still open for Python, so libwebrtc fails these sends + for message in messages: + channel.send(message) + finally: + wrtc._testing.release('datachannel.closing') + + +@pytest.mark.asyncio +async def test_send_racing_a_remote_close_fires_error( + caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection, monkeypatch: pytest.MonkeyPatch +) -> None: + """A send libwebrtc fails as the remote end closes fires error with data-channel-failure, then close.""" + channel, remote = await open_pair(caller, callee) + unraisable: list[sys.UnraisableHookArgs] = [] + monkeypatch.setattr(sys, 'unraisablehook', unraisable.append) + loop = asyncio.get_running_loop() + reported: list[dict[str, object]] = [] + loop.set_exception_handler(lambda _loop, context: reported.append(context)) + try: + events: list[webrtc.Event] = [] + channel.on('error', events.append) + closed = wait_for_event(channel, 'close') + + send_racing_remote_close(channel, remote, ['late']) + events.append(await closed) + finally: + loop.set_exception_handler(None) + + error_event, close_event = events + assert isinstance(error_event, webrtc.RTCErrorEvent) + assert isinstance(error_event.error, webrtc.RTCError) + assert error_event.error.error_detail == webrtc.RTCErrorDetailType.data_channel_failure + assert close_event.type == 'close' + assert channel.ready_state == webrtc.RTCDataChannelState.closed + assert reported == [] + assert unraisable == [] + + +@pytest.mark.asyncio +async def test_buffered_amount_when_sends_race_a_remote_close( + caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection +) -> None: + """The bytes of sends libwebrtc fails stay in bufferedAmount, which libwebrtc never reports sent.""" + channel, remote = await open_pair(caller, callee) + channel.send(b'drained') + await wait_for_event(channel, 'bufferedamountlow') + assert channel.buffered_amount == 0 + + observed: list[tuple[str, int]] = [] + + def observe(event: webrtc.Event) -> None: + observed.append((event.type, channel.buffered_amount)) + + for name in ('bufferedamountlow', 'closing', 'error'): + channel.on(name, observe) + closed = wait_for_event(channel, 'close') + messages: list[str | bytes] = ['late', bytes(1000), 'é'] + pending = len('late') + 1000 + len('é'.encode()) + + send_racing_remote_close(channel, remote, messages) + assert channel.buffered_amount == pending + observe(await closed) + + assert observed == [('closing', pending), ('error', pending), ('close', pending)] + + +@pytest.mark.asyncio +async def test_native_send_takes_only_bytes(caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection) -> None: + """The native send reads its data without the GIL, so it refuses a bytearray that could be resized meanwhile.""" + channel, _ = await open_pair(caller, callee) + native = channel._native_obj + for data in (bytearray(b'data'), 'data'): + with pytest.raises(TypeError): + native.send(data, binary=True) # pyrefly: ignore[bad-argument-type] + + +@pytest.mark.asyncio +async def test_messages_round_trip(caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection) -> None: + """Text and binary messages arrive unchanged, empty and as large as the SCTP transport takes.""" + channel, remote = await open_pair(caller, callee) + assert caller.sctp is not None + large = 256 * 1024 + max_message_size = caller.sctp.max_message_size + assert max_message_size is not None + assert max_message_size >= large + messages: list[str | bytes] = [ + '', + b'', + 'héllo, мир, 世界 🎉', + bytes(range(256)) * (large // 256), + 'é' * (large // 2), + '\x00text', + ] + received: list[str | bytes | webrtc.Blob] = [] + got_all = asyncio.get_running_loop().create_future() + + @remote.on('message') + def on_message(event: webrtc.MessageEvent) -> None: + received.append(event.data) + if len(received) == len(messages) + 1 and not got_all.done(): + got_all.set_result(None) + + for message in messages: + channel.send(message) + # a lone surrogate becomes U+FFFD + channel.send('a\ud800b') + await asyncio.wait_for(got_all, seconds(10)) + assert received == [*messages, 'a\ufffdb'] + + @pytest.mark.parametrize( ('init', 'error'), [ From 77eca3ced1961d41a4112440a5c1c24aca3a6199 Mon Sep 17 00:00:00 2001 From: "Ilya (Marshal)" Date: Thu, 15 Oct 2026 21:18:33 +0200 Subject: [PATCH 3/5] Fix tidy and Windows CI for data channel race tests --- python-webrtc/cpp/src/utils/mailbox.h | 1 + tests/test_data_channel.py | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/python-webrtc/cpp/src/utils/mailbox.h b/python-webrtc/cpp/src/utils/mailbox.h index 4698c68..d10d3b5 100644 --- a/python-webrtc/cpp/src/utils/mailbox.h +++ b/python-webrtc/cpp/src/utils/mailbox.h @@ -146,6 +146,7 @@ namespace python_webrtc { Post(Record{.source = self, .target = self->Id(), .name = name, + // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks): the analyzer loses a moved shared_ptr .args = [self, ... args = std::forward(args)]() mutable { return pybind11::make_tuple(self, std::move(args)...); }}); diff --git a/tests/test_data_channel.py b/tests/test_data_channel.py index 7037787..e724e0d 100644 --- a/tests/test_data_channel.py +++ b/tests/test_data_channel.py @@ -167,6 +167,11 @@ async def test_remote_connection_closed_fires_error( assert channel.ready_state == webrtc.RTCDataChannelState.closed +races_remote_close = pytest.mark.skipif( + sys.platform == 'win32', reason="libwebrtc's Windows socket server runs posted tasks after pending socket events" +) + + def send_racing_remote_close( channel: webrtc.RTCDataChannel, remote: webrtc.RTCDataChannel, messages: list[str | bytes] ) -> None: @@ -182,6 +187,7 @@ def send_racing_remote_close( wrtc._testing.release('datachannel.closing') +@races_remote_close @pytest.mark.asyncio async def test_send_racing_a_remote_close_fires_error( caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection, monkeypatch: pytest.MonkeyPatch @@ -213,6 +219,7 @@ async def test_send_racing_a_remote_close_fires_error( assert unraisable == [] +@races_remote_close @pytest.mark.asyncio async def test_buffered_amount_when_sends_race_a_remote_close( caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection From e82b26dfe03e97130affb9826ec559edde4d9fc8 Mon Sep 17 00:00:00 2001 From: "Ilya (Marshal)" Date: Thu, 15 Oct 2026 20:51:07 +0200 Subject: [PATCH 4/5] Fix DTMF minimums, standalone ICE gathering, end-of-candidates reuse and audio capture time --- .../src/interfaces/peer_connection_factory.h | 5 ++ .../src/interfaces/rtc_audio_track_source.cpp | 8 ++- .../src/interfaces/rtc_audio_track_source.h | 4 +- .../cpp/src/interfaces/rtc_dtmf_sender.cpp | 8 +-- .../cpp/src/interfaces/rtc_ice_transport.cpp | 43 +++++++----- .../rtc_peer_connection/descriptions.cpp | 6 ++ .../cpp/src/media/track_generator.cpp | 8 ++- tests/test_ice_transport.py | 70 ++++++++++++++++++- tests/test_media_e2e.py | 60 ++++++++++++++++ tests/test_rtp_sender_receiver.py | 35 ++++++++++ 10 files changed, 218 insertions(+), 29 deletions(-) diff --git a/python-webrtc/cpp/src/interfaces/peer_connection_factory.h b/python-webrtc/cpp/src/interfaces/peer_connection_factory.h index ee79c92..9cc9ad0 100644 --- a/python-webrtc/cpp/src/interfaces/peer_connection_factory.h +++ b/python-webrtc/cpp/src/interfaces/peer_connection_factory.h @@ -66,6 +66,11 @@ namespace python_webrtc { std::unique_ptr CreatePortAllocator(const webrtc::PeerConnectionInterface::RTCConfiguration &configuration); + [[nodiscard]] std::shared_ptr networkManager() const { return _networkManager; } + [[nodiscard]] std::shared_ptr packetSocketFactory() const { + return _packetSocketFactory; + } + private: webrtc::PeerConnectionFactoryDependencies Dependencies(); diff --git a/python-webrtc/cpp/src/interfaces/rtc_audio_track_source.cpp b/python-webrtc/cpp/src/interfaces/rtc_audio_track_source.cpp index 62da5b8..a690885 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_audio_track_source.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_audio_track_source.cpp @@ -12,6 +12,8 @@ #include #include +#include + namespace python_webrtc { webrtc::MediaSourceInterface::SourceState RTCAudioTrackSource::state() const { @@ -42,10 +44,10 @@ namespace python_webrtc { } void RTCAudioTrackSource::PushSamples(const void *samples, int bitsPerSample, int sampleRate, size_t channels, - size_t frames) { + size_t frames, int64_t captureTimeMs) { const std::scoped_lock lock(_sinkMutex); for (auto *sink : _sinks) { - sink->OnData(samples, bitsPerSample, sampleRate, channels, frames, std::nullopt); + sink->OnData(samples, bitsPerSample, sampleRate, channels, frames, captureTimeMs); } } @@ -65,7 +67,7 @@ namespace python_webrtc { seed = (seed * multiplier) + increment; sample = static_cast((static_cast(seed >> seedShift) % (2 * amplitude)) - amplitude); } - PushSamples(samples.data(), bitsPerSample, sampleRate, 1, frames); + PushSamples(samples.data(), bitsPerSample, sampleRate, 1, frames, webrtc::TimeMillis()); }; _control = std::make_shared(); _control->microphone = true; diff --git a/python-webrtc/cpp/src/interfaces/rtc_audio_track_source.h b/python-webrtc/cpp/src/interfaces/rtc_audio_track_source.h index f9b5a95..5fcbe80 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_audio_track_source.h +++ b/python-webrtc/cpp/src/interfaces/rtc_audio_track_source.h @@ -9,6 +9,7 @@ #define PYTHON_WEBRTC_INTERFACES_RTC_AUDIO_TRACK_SOURCE_H_ #include +#include #include #include #include @@ -35,7 +36,8 @@ namespace python_webrtc { bool remote() const override; - void PushSamples(const void *samples, int bitsPerSample, int sampleRate, size_t channels, size_t frames); + void PushSamples(const void *samples, int bitsPerSample, int sampleRate, size_t channels, size_t frames, + int64_t captureTimeMs); // ends the tracks of the source; must be called on the signaling thread, where they observe it void End(); diff --git a/python-webrtc/cpp/src/interfaces/rtc_dtmf_sender.cpp b/python-webrtc/cpp/src/interfaces/rtc_dtmf_sender.cpp index 377f739..bd7df74 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_dtmf_sender.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_dtmf_sender.cpp @@ -7,8 +7,6 @@ #include "rtc_dtmf_sender.h" -#include - #include "../exceptions.h" #include "../utils/gil.h" #include "../utils/libwebrtc_thread.h" @@ -93,11 +91,7 @@ namespace python_webrtc { const std::scoped_lock lock(_bufferMutex); ++_insertions; } - // libwebrtc takes longer tones and gaps than the specification - constexpr int minDuration = 70; - constexpr int minInterToneGap = 50; - if (!_dtmf->InsertDtmf(tones, std::max(duration, minDuration), std::max(interToneGap, minInterToneGap)) && - !tones.empty()) { + if (!_dtmf->InsertDtmf(tones, duration, interToneGap) && !tones.empty()) { throw RTCException(webrtc::RTCErrorType::INVALID_STATE, "The DTMF sender can't send tones yet"); } const std::scoped_lock lock(_bufferMutex); diff --git a/python-webrtc/cpp/src/interfaces/rtc_ice_transport.cpp b/python-webrtc/cpp/src/interfaces/rtc_ice_transport.cpp index b0c487a..3c5c3af 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_ice_transport.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_ice_transport.cpp @@ -8,36 +8,49 @@ #include "rtc_ice_transport.h" #include +#include #include -#include #include #include -#include +#include #include #include #include -#include #include #include "../exceptions.h" #include "../utils/gil.h" #include "../utils/libwebrtc_thread.h" +#include "../utils/port_allocator.h" namespace python_webrtc { - // Owned by the transport, created and destroyed on the network thread, after the ICE transport that uses it - struct StandaloneIce { - StandaloneIce(webrtc::Thread *networkThread, const PeerConnectionFactory &factory) - : env(webrtc::CreateEnvironment(factory.fieldTrials().CreateCopy())), - networkManager(std::make_unique(env, networkThread->socketserver())), - socketFactory(std::make_unique(networkThread->socketserver())), - allocator(std::make_unique(env, networkManager.get(), socketFactory.get())) { + namespace { + // what libwebrtc gives a connection's allocator, on the network thread + std::unique_ptr CreateStandalonePortAllocator(const webrtc::Environment &env, + PeerConnectionFactory &factory) { + auto allocator = CreatePortAllocator(env, factory.networkManager(), factory.packetSocketFactory()); allocator->Initialize(); - // like the connections of the factory + uint32_t flags = + allocator->flags() | webrtc::PORTALLOCATOR_ENABLE_SHARED_SOCKET | webrtc::PORTALLOCATOR_ENABLE_IPV6_ON_WIFI; + if (!env.field_trials().IsDisabled("WebRTC-IPv6Default")) { + flags |= webrtc::PORTALLOCATOR_ENABLE_IPV6; + } + allocator->set_flags(flags); + // like our connections: no waits between gathering phases + allocator->set_step_delay(0); allocator->SetNetworkIgnoreMask(factory.networkIgnoreMask()); + return allocator; } + } // namespace + + // Owned by the transport, created and destroyed on the network thread, after the ICE transport that uses it + struct StandaloneIce { + explicit StandaloneIce(PeerConnectionFactory &factory) + : env(webrtc::CreateEnvironment(factory.fieldTrials().CreateCopy())), + allocator(CreateStandalonePortAllocator(env, factory)) {} ~StandaloneIce() { if (transport) { @@ -50,9 +63,7 @@ namespace python_webrtc { StandaloneIce &operator=(const StandaloneIce &) = delete; webrtc::Environment env; - std::unique_ptr networkManager; - std::unique_ptr socketFactory; - std::unique_ptr allocator; + std::unique_ptr allocator; std::unique_ptr channel; webrtc::scoped_refptr transport; }; @@ -83,7 +94,7 @@ namespace python_webrtc { _subscribed->RemoveGatheringStateCallback(this); } if (_standalone) { - // on the network thread, before what it uses + // on the network thread, before the factory whose network manager it shares _transport = nullptr; _standalone = nullptr; } @@ -422,7 +433,7 @@ namespace python_webrtc { std::pair parameters(webrtc::CreateRandomString(webrtc::ICE_UFRAG_LENGTH), webrtc::CreateRandomString(webrtc::ICE_PWD_LENGTH)); BlockingCallOn(factory->workerThread(), [&]() { - standalone = std::make_shared(factory->workerThread(), *factory); + standalone = std::make_shared(*factory); webrtc::IceTransportInit init(standalone->env); init.set_port_allocator(standalone->allocator.get()); standalone->channel = webrtc::P2PTransportChannel::Create("", 1, std::move(init)); diff --git a/python-webrtc/cpp/src/interfaces/rtc_peer_connection/descriptions.cpp b/python-webrtc/cpp/src/interfaces/rtc_peer_connection/descriptions.cpp index 7e1da69..58d7117 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_peer_connection/descriptions.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_peer_connection/descriptions.cpp @@ -280,6 +280,12 @@ namespace python_webrtc { if (!self || self->IsClosed()) { completion->Fail(RTCCallbackException(closedError(remote ? "setRemoteDescription" : "setLocalDescription"))); } else if (error.ok()) { + if (remote) { + // a new remote description may reuse the address of the one the end of candidates was for + const std::scoped_lock lock(self->_remoteEndOfCandidatesMutex); + self->_remoteEndOfCandidates.clear(); + self->_remoteEndOfCandidatesDescription = nullptr; + } // the descriptions as the operation left them, before candidates gathered later self->_completionSnapshot = self->SnapshotDescriptions(); auto created = self->WrapTransports(); diff --git a/python-webrtc/cpp/src/media/track_generator.cpp b/python-webrtc/cpp/src/media/track_generator.cpp index 400ac61..d690389 100644 --- a/python-webrtc/cpp/src/media/track_generator.cpp +++ b/python-webrtc/cpp/src/media/track_generator.cpp @@ -8,6 +8,7 @@ #include "track_generator.h" #include +#include #include #include #include @@ -16,6 +17,7 @@ #include #include #include +#include #include #include "../exceptions.h" @@ -28,6 +30,7 @@ namespace python_webrtc { // libwebrtc takes audio in 10 ms frames constexpr int kAudioFramesPerSecond = 100; + constexpr int kAudioFrameMs = 1000 / kAudioFramesPerSecond; constexpr int kBitsPerSample = sizeof(int16_t) * CHAR_BIT; // libwebrtc's native rates: its resampler crashes on much lower ones constexpr int kMinSampleRate = 8000; @@ -135,9 +138,12 @@ namespace python_webrtc { const auto chunkFrames = static_cast(sampleRate / kAudioFramesPerSecond); const size_t chunk = chunkFrames * channels; size_t sent = 0; + int64_t captureTimeMs = webrtc::TimeMillis(); while (chunk > 0 && _pending.size() - sent >= chunk) { - _audioSource->PushSamples(_pending.data() + sent, kBitsPerSample, sampleRate, channels, chunkFrames); + _audioSource->PushSamples(_pending.data() + sent, kBitsPerSample, sampleRate, channels, chunkFrames, + captureTimeMs); sent += chunk; + captureTimeMs += kAudioFrameMs; } _pending.erase(_pending.begin(), _pending.begin() + static_cast(sent)); } diff --git a/tests/test_ice_transport.py b/tests/test_ice_transport.py index 08c0ad0..e3f3138 100644 --- a/tests/test_ice_transport.py +++ b/tests/test_ice_transport.py @@ -13,12 +13,16 @@ from __future__ import annotations import asyncio +import ipaddress import re +import socket +import time import pytest import webrtc -from tests.helpers import connect, wait_for_event, wait_until +from tests.helpers import connect, exchange_answer, exchange_offer, wait_for_event, wait_until +from tests.timing import seconds def local_parameters(transport: webrtc.RTCIceTransport) -> webrtc.RTCIceParameters: @@ -220,6 +224,32 @@ async def test_candidate_rejected_with_operation_error( assert ice.get_remote_candidates() == [] +@pytest.mark.asyncio +async def test_end_of_candidates_is_for_one_remote_description( + caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection +) -> None: + """Remote descriptions after an ICE restart don't show the end of candidates of an earlier one.""" + caller.add_transceiver(webrtc.MediaType.audio) + await exchange_offer(caller, callee) + await callee.add_ice_candidate() + await exchange_answer(caller, callee) + # the answer makes the offer current, which keeps it + current = callee.current_remote_description + assert current is not None + assert 'a=end-of-candidates' in current.sdp + # later descriptions may take the address of a freed one + for _ in range(20): + caller.restart_ice() + await exchange_offer(caller, callee) + pending = callee.pending_remote_description + assert pending is not None + assert 'a=end-of-candidates' not in pending.sdp + await exchange_answer(caller, callee) + current = callee.current_remote_description + assert current is not None + assert 'a=end-of-candidates' not in current.sdp + + @pytest.mark.asyncio async def test_candidate_without_media_section_is_a_type_error(pc: webrtc.RTCPeerConnection) -> None: """Checked before the state.""" @@ -293,6 +323,44 @@ def test_gather_once_and_with_a_known_role() -> None: transport.stop() +def has_usable_ipv6() -> bool: + """A route to the global IPv6 internet from an address libwebrtc gathers on (not link-local nor MAC-based).""" + try: + with socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) as probe: + # a documentation address: picks the source of the default route, sends nothing + probe.connect(('2001:db8::1', 9)) + host: str = probe.getsockname()[0] + address = ipaddress.IPv6Address(host.split('%', maxsplit=1)[0]) + except (OSError, ValueError): + return False + # RFC 4291 appendix A: an interface ID from a MAC has ff:fe in its middle + mac_based = address.packed[11:13] == b'\xff\xfe' + return not (address.is_link_local or address.is_loopback or address.is_unspecified or mac_based) + + +@pytest.mark.asyncio +async def test_gathers_like_a_connection() -> None: + """Without the 1 s waits between phases or passive TCP candidates, with IPv6 where the host has it.""" + transport = webrtc.RTCIceTransport() + + def complete(_: webrtc.Event) -> bool: + return transport.gathering_state == webrtc.RTCIceGathererState.complete + + gathered = wait_for_event(transport, 'gatheringstatechange', predicate=complete) + start = time.monotonic() + transport.gather() + await gathered + elapsed = time.monotonic() - start + candidates = transport.get_local_candidates() + transport.stop() + + assert elapsed < seconds(1), elapsed + assert any(c.protocol == webrtc.RTCIceProtocol.udp for c in candidates), candidates + assert all(c.tcp_type in {None, webrtc.RTCIceTcpCandidateType.active} for c in candidates), candidates + if has_usable_ipv6(): + assert any(c.address is not None and ':' in c.address for c in candidates), candidates + + def test_remote_candidate_from_a_dictionary() -> None: transport = webrtc.RTCIceTransport() transport.add_remote_candidate(webrtc.RTCIceCandidateInit(CANDIDATE, sdp_mid='', username_fragment='abcd')) diff --git a/tests/test_media_e2e.py b/tests/test_media_e2e.py index f61a796..08ea32f 100644 --- a/tests/test_media_e2e.py +++ b/tests/test_media_e2e.py @@ -24,6 +24,7 @@ available_outgoing_bitrate, connect, connect_track, + stats_of_type, wait_for_event, wait_until, write_video, @@ -34,6 +35,8 @@ TIMEOUT = seconds(20) #: no frame for this long means frames stopped; exact, as it defines the assertion QUIET = 0.5 +#: a send pause, seconds +PAUSE = 1.5 WIDTH, HEIGHT = 320, 240 @@ -261,6 +264,63 @@ async def test_audio_through_a_connection(caller: webrtc.RTCPeerConnection, call assert abs(dominant_frequency(samples, 48000) - 440) < 5 +async def inbound_rtp(receiver: webrtc.RTCRtpReceiver) -> webrtc.RTCInboundRtpStreamStats | None: + stats = stats_of_type(await receiver.get_stats(), 'inbound-rtp') + if len(stats) == 0: + return None + assert isinstance(stats[0], webrtc.RTCInboundRtpStreamStats) + return stats[0] + + +async def packets_received(receiver: webrtc.RTCRtpReceiver) -> int: + stats = await inbound_rtp(receiver) + return 0 if stats is None or stats.packets_received is None else stats.packets_received + + +async def set_active(sender: webrtc.RTCRtpSender, *, active: bool) -> None: + parameters = sender.get_parameters() + parameters.encodings[0].active = active + await sender.set_parameters(parameters) + + +@pytest.mark.asyncio +async def test_audio_resumes_after_a_send_pause( + caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection +) -> None: + """After a pause the RTP timestamps skip ahead with the capture time, so the jitter doesn't jump.""" + generator = webrtc.MediaStreamTrackGenerator('audio') + async with writing(write_sine, generator, 440): + await connect_track(caller, callee, generator, timeout=TIMEOUT) + (sender,) = caller.get_senders() + (receiver,) = callee.get_receivers() + + async def arriving() -> bool: + return await packets_received(receiver) > 0 + + await wait_until(arriving, 'packets to arrive', TIMEOUT) + # stops and restarts the send stream, unlike replacing the track + await set_active(sender, active=False) + await asyncio.sleep(PAUSE) + paused = await packets_received(receiver) + await set_active(sender, active=True) + + # RFC 3550 jitter takes 1/16 of a transit jump: PAUSE / 16 without the skip, before it decays + jitters: list[float] = [] + + async def resumed() -> bool: + stats = await inbound_rtp(receiver) + assert stats is not None + assert stats.jitter is not None + assert stats.packets_received is not None + jitters.append(stats.jitter) + # half a second of 20 ms packets + return stats.packets_received > paused + 25 + + await wait_until(resumed, 'packets after the pause', TIMEOUT) + assert max(jitters) < PAUSE / 16 / 2, jitters + generator.stop() + + @pytest.mark.asyncio async def test_remote_track_end_closes_the_processor( caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection diff --git a/tests/test_rtp_sender_receiver.py b/tests/test_rtp_sender_receiver.py index 266d08d..247e8fb 100644 --- a/tests/test_rtp_sender_receiver.py +++ b/tests/test_rtp_sender_receiver.py @@ -10,6 +10,7 @@ from __future__ import annotations import asyncio +import statistics import time from typing import TYPE_CHECKING @@ -467,6 +468,40 @@ def is_last(event: webrtc.Event) -> bool: assert tones == ['1', 'A', '#', ''] +async def tone_spacing(dtmf: webrtc.RTCDTMFSender, duration: int, inter_tone_gap: int) -> float: + """The median time between tonechange events, robust to a late one.""" + times: list[float] = [] + + def on_tone_change(_: webrtc.RTCDTMFToneChangeEvent) -> None: + times.append(time.monotonic()) + + def is_last(event: webrtc.Event) -> bool: + assert isinstance(event, webrtc.RTCDTMFToneChangeEvent) + return event.tone == '' + + dtmf.on('tonechange', on_tone_change) + done = wait_for_event(dtmf, 'tonechange', timeout=seconds(5), predicate=is_last) + dtmf.insert_dtmf('1234567890', duration=duration, inter_tone_gap=inter_tone_gap) + await done + dtmf.off('tonechange', on_tone_change) + return statistics.median(b - a for a, b in zip(times, times[1:])) + + +@pytest.mark.asyncio +async def test_dtmf_shortest_tones_and_gaps( + caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection, audio_stream: webrtc.MediaStream +) -> None: + """Tones of 40 ms with gaps of 30 ms, the spec's minimums, are 50 ms shorter than 70/50 ones.""" + sender = caller.add_track(audio_stream.get_tracks()[0], audio_stream) + await connect(caller, callee) + dtmf = sender.dtmf + assert dtmf is not None + shortest = await tone_spacing(dtmf, 40, 30) + longer = await tone_spacing(dtmf, 70, 50) + # relative: a loaded machine delays every tone alike; equal if 40/30 were raised to 70/50 + assert longer - shortest > 0.05 / 2, (shortest, longer) + + def test_dtmf_before_a_negotiation(pc: webrtc.RTCPeerConnection) -> None: """Tones need a negotiated telephone-event codec.""" dtmf = pc.add_transceiver(webrtc.MediaType.audio).sender.dtmf From d6d8e2d4e40e2905a3a0f406e16df7db96c80b7b Mon Sep 17 00:00:00 2001 From: "Ilya (Marshal)" Date: Thu, 15 Oct 2026 21:32:11 +0200 Subject: [PATCH 5/5] Fix tests --- tests/test_ice_transport.py | 3 ++- tests/test_lifetime.py | 19 ++++++++++------- tests/test_rtp_sender_receiver.py | 35 ------------------------------- 3 files changed, 13 insertions(+), 44 deletions(-) diff --git a/tests/test_ice_transport.py b/tests/test_ice_transport.py index e3f3138..8bfdaf8 100644 --- a/tests/test_ice_transport.py +++ b/tests/test_ice_transport.py @@ -121,7 +121,8 @@ def on_candidate(event: webrtc.RTCPeerConnectionIceEvent, other: webrtc.RTCIceTr connected = [wait_for_event(t, 'statechange') for t in (local, remote)] local.gather() remote.gather() - assert local.gathering_state == webrtc.RTCIceGathererState.gathering + # read live: host candidates may all be gathered already + assert local.gathering_state != webrtc.RTCIceGathererState.new # both take the controlling role: one of them switches local.start(local_parameters(remote), 'controlling') remote.start(local_parameters(local), 'controlling') diff --git a/tests/test_lifetime.py b/tests/test_lifetime.py index c2f81aa..a01025d 100644 --- a/tests/test_lifetime.py +++ b/tests/test_lifetime.py @@ -665,6 +665,15 @@ def read() -> None: assert not reader.is_alive() +def _closed_connection_in_a_cycle() -> weakref.ref[webrtc.RTCPeerConnection]: + """A closed connection in a cycle with its handler.""" + pc = webrtc.RTCPeerConnection() + pc.add_transceiver(webrtc.MediaType.audio) + pc.on('connectionstatechange', lambda _event, _pc=pc: None) + pc.close() + return weakref.ref(pc) + + def _collect_on_dispatcher_wakes() -> None: gc.disable() # isolated: every wake goes through the Dispatcher @@ -678,14 +687,8 @@ def wake(mailbox_id: int) -> None: loops._wake(mailbox_id) async def main() -> weakref.ref[webrtc.RTCPeerConnection]: - # a closed connection in a cycle with its handler - pc = webrtc.RTCPeerConnection() - pc.add_transceiver(webrtc.MediaType.audio) - pc.on('connectionstatechange', lambda _event, _pc=pc: None) - pc.close() - ref = weakref.ref(pc) - del pc - + ref = _closed_connection_in_a_cycle() + await delivered() wrtc._set_wake(wake) transport = webrtc.RTCIceTransport() done = asyncio.get_running_loop().create_future() diff --git a/tests/test_rtp_sender_receiver.py b/tests/test_rtp_sender_receiver.py index 247e8fb..266d08d 100644 --- a/tests/test_rtp_sender_receiver.py +++ b/tests/test_rtp_sender_receiver.py @@ -10,7 +10,6 @@ from __future__ import annotations import asyncio -import statistics import time from typing import TYPE_CHECKING @@ -468,40 +467,6 @@ def is_last(event: webrtc.Event) -> bool: assert tones == ['1', 'A', '#', ''] -async def tone_spacing(dtmf: webrtc.RTCDTMFSender, duration: int, inter_tone_gap: int) -> float: - """The median time between tonechange events, robust to a late one.""" - times: list[float] = [] - - def on_tone_change(_: webrtc.RTCDTMFToneChangeEvent) -> None: - times.append(time.monotonic()) - - def is_last(event: webrtc.Event) -> bool: - assert isinstance(event, webrtc.RTCDTMFToneChangeEvent) - return event.tone == '' - - dtmf.on('tonechange', on_tone_change) - done = wait_for_event(dtmf, 'tonechange', timeout=seconds(5), predicate=is_last) - dtmf.insert_dtmf('1234567890', duration=duration, inter_tone_gap=inter_tone_gap) - await done - dtmf.off('tonechange', on_tone_change) - return statistics.median(b - a for a, b in zip(times, times[1:])) - - -@pytest.mark.asyncio -async def test_dtmf_shortest_tones_and_gaps( - caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection, audio_stream: webrtc.MediaStream -) -> None: - """Tones of 40 ms with gaps of 30 ms, the spec's minimums, are 50 ms shorter than 70/50 ones.""" - sender = caller.add_track(audio_stream.get_tracks()[0], audio_stream) - await connect(caller, callee) - dtmf = sender.dtmf - assert dtmf is not None - shortest = await tone_spacing(dtmf, 40, 30) - longer = await tone_spacing(dtmf, 70, 50) - # relative: a loaded machine delays every tone alike; equal if 40/30 were raised to 70/50 - assert longer - shortest > 0.05 / 2, (shortest, longer) - - def test_dtmf_before_a_negotiation(pc: webrtc.RTCPeerConnection) -> None: """Tones need a negotiated telephone-event codec.""" dtmf = pc.add_transceiver(webrtc.MediaType.audio).sender.dtmf