From e17d79ad7dee1d1a2948dc8be3a642bb5282cd9f Mon Sep 17 00:00:00 2001 From: "Ilya (Marshal)" Date: Tue, 29 Sep 2026 12:44:36 +0200 Subject: [PATCH 1/3] Fix deadlocks, leaks and crashes found by sanitizers and chaos tests --- .github/scripts/sanitizers-macos.sh | 55 +++ .github/workflows/ci.yml | 18 +- Makefile | 9 +- .../cpp/src/interfaces/interfaces.cpp | 30 ++ .../cpp/src/interfaces/media_stream.cpp | 44 +-- .../cpp/src/interfaces/media_stream.h | 2 - .../interfaces/peer_connection_factory.cpp | 46 ++- .../src/interfaces/peer_connection_factory.h | 3 + .../cpp/src/interfaces/rtc_data_channel.cpp | 10 +- .../cpp/src/interfaces/rtc_dtls_transport.cpp | 21 +- .../cpp/src/interfaces/rtc_dtls_transport.h | 3 + .../cpp/src/interfaces/rtc_dtmf_sender.cpp | 12 +- .../cpp/src/interfaces/rtc_ice_transport.cpp | 2 +- .../src/interfaces/rtc_peer_connection.cpp | 19 +- .../cpp/src/interfaces/rtc_peer_connection.h | 2 + .../cpp/src/interfaces/rtc_rtp_receiver.cpp | 16 +- .../cpp/src/interfaces/rtc_rtp_sender.cpp | 14 +- .../cpp/src/interfaces/rtc_sctp_transport.cpp | 15 +- python-webrtc/cpp/src/media/audio_samples.cpp | 12 +- .../media/media_stream_track_processor.cpp | 31 +- .../src/media/media_stream_track_processor.h | 9 +- .../cpp/src/media/track_generator.cpp | 66 +++- python-webrtc/cpp/src/media/track_generator.h | 24 +- .../cpp/src/media/video_frame_buffer.cpp | 26 +- .../cpp/src/media/video_frame_buffer.h | 3 + python-webrtc/cpp/src/media/wakeup.h | 19 + python-webrtc/cpp/src/module.cpp | 16 +- python-webrtc/cpp/src/utils/alive_count.h | 35 ++ python-webrtc/cpp/src/utils/buffer.h | 36 ++ python-webrtc/cpp/src/utils/gil.h | 9 + python-webrtc/cpp/src/utils/instance_holder.h | 74 ++-- .../cpp/src/utils/libwebrtc_thread.h | 22 +- python-webrtc/cpp/src/utils/listeners.h | 9 +- .../python/webrtc/functions/get_user_media.py | 25 +- .../python/webrtc/interfaces/media_stream.py | 34 +- .../webrtc/interfaces/media_stream_track.py | 65 +++- .../media_stream_track_processor.py | 2 + .../webrtc/interfaces/rtc_peer_connection.py | 32 +- .../webrtc/interfaces/track_generator.py | 15 +- .../python/webrtc/models/audio_data.py | 5 +- .../python/webrtc/models/rtp_parameters.py | 13 +- python-webrtc/python/webrtc/streams.py | 34 +- python-webrtc/python/webrtc/utils/events.py | 10 +- .../python/webrtc/utils/task_queue.py | 31 +- stubs/wrtc/__init__.pyi | 3 + tests/chaos.py | 344 ++++++++++++++++++ tests/conftest.py | 11 + tests/helpers.py | 21 ++ tests/test_events.py | 24 ++ tests/test_lifetime.py | 160 ++++++++ tests/test_media_stress.py | 6 +- tests/test_robustness_chaos.py | 39 ++ tests/test_robustness_exit.py | 108 ++++++ tests/test_robustness_media.py | 138 +++++++ tests/test_robustness_threads.py | 250 +++++++++++++ tests/test_rtp_sender_receiver.py | 34 ++ tests/test_streams.py | 62 ++++ tests/test_task_queue.py | 23 ++ tests/test_track_settings.py | 72 +++- tests/test_video.py | 23 +- 60 files changed, 2071 insertions(+), 225 deletions(-) create mode 100755 .github/scripts/sanitizers-macos.sh create mode 100644 python-webrtc/cpp/src/utils/alive_count.h create mode 100644 python-webrtc/cpp/src/utils/buffer.h create mode 100644 tests/chaos.py create mode 100644 tests/test_robustness_chaos.py create mode 100644 tests/test_robustness_exit.py create mode 100644 tests/test_robustness_media.py create mode 100644 tests/test_robustness_threads.py diff --git a/.github/scripts/sanitizers-macos.sh b/.github/scripts/sanitizers-macos.sh new file mode 100755 index 0000000..d961668 --- /dev/null +++ b/.github/scripts/sanitizers-macos.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# +# Builds the extension with AddressSanitizer and UndefinedBehaviorSanitizer on macOS (Apple Clang, natively), or +# ThreadSanitizer with SANITIZE=thread, and runs the Python tests against it. The Linux counterpart is sanitizers.sh. +# +# .github/scripts/sanitizers-macos.sh [pytest arguments] +# +# The build and a venv without the editable install (whose import hook would load the regular build) are kept +# in build/asan (build/tsan). + +set -euo pipefail + +SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SANITIZE="${SANITIZE:-address,undefined}" +if [ "$SANITIZE" = thread ]; then + BUILD="${WRTC_SANITIZERS_BUILD_DIR:-$SRC/build/tsan}" +else + BUILD="${WRTC_SANITIZERS_BUILD_DIR:-$SRC/build/asan}" +fi +if [ -z "${PYTHON:-}" ]; then + uv python install -q 3.13 + PYTHON="$(uv python find 3.13)" +fi + +if [ ! -x "$BUILD/venv/bin/python" ]; then + uv venv -q "$BUILD/venv" --python "$PYTHON" + uv pip install -q --python "$BUILD/venv/bin/python" cmake ninja "pybind11>=3.0" pytest pytest-asyncio pytest-timeout +fi +export PATH="$BUILD/venv/bin:$PATH" + +cmake -S "$SRC" -B "$BUILD" -G Ninja \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DWRTC_SANITIZE="$SANITIZE" \ + -DPython_EXECUTABLE="$BUILD/venv/bin/python" \ + -Dpybind11_DIR="$("$BUILD/venv/bin/python" -m pybind11 --cmakedir)" > /dev/null +cmake --build "$BUILD" + +# The interpreter isn't instrumented: the runtime must be loaded before anything else +if [ "$SANITIZE" = thread ]; then + DYLD_INSERT_LIBRARIES="$(clang -print-runtime-dir)/libclang_rt.tsan_osx_dynamic.dylib" + # libwebrtc isn't instrumented: races of its own aren't reported + export TSAN_OPTIONS="halt_on_error=1:report_signal_unsafe=0:strip_env=0" +else + DYLD_INSERT_LIBRARIES="$(clang -print-runtime-dir)/libclang_rt.asan_osx_dynamic.dylib" + export PYTHONMALLOC=malloc + # LeakSanitizer isn't supported on macOS; strip_env=0 keeps the runtime in subprocesses of the tests + export ASAN_OPTIONS="detect_leaks=0:halt_on_error=1:abort_on_error=0:strict_init_order=1:strip_env=0" + export UBSAN_OPTIONS="print_stacktrace=1:halt_on_error=1" +fi +export DYLD_INSERT_LIBRARIES +export PYTHONPATH="$BUILD/python-webrtc/cpp:$SRC/python-webrtc/python:$SRC" +export PYTHONDONTWRITEBYTECODE=1 + +cd "$SRC" +"$BUILD/venv/bin/python" -m pytest tests --ignore=tests/wpt -p no:cacheprovider --capture=sys "$@" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a85e3cb..a48fc6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,14 +84,28 @@ jobs: name: sanitizers (ASan + UBSan) runs-on: ubuntu-latest container: quay.io/pypa/manylinux_2_28_x86_64 - timeout-minutes: 30 + timeout-minutes: 60 steps: - uses: actions/checkout@v7 - run: .github/scripts/sanitizers.sh + # the collector running on libwebrtc threads, whenever they emit (see tests/conftest.py) + - run: .github/scripts/sanitizers.sh --gc-on-emit + # long random sequences of calls (tests/chaos.py) + - run: .github/scripts/sanitizers.sh --stress -k "stress or chaos" + + sanitizers-macos: + name: sanitizers (ASan + UBSan, macOS) + runs-on: macos-15 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v7 + - run: .github/scripts/sanitizers-macos.sh + - run: .github/scripts/sanitizers-macos.sh --gc-on-emit publish: if: startsWith(github.ref, 'refs/tags/v') - needs: [lint, sdist, wheels, sanitizers] + needs: [lint, sdist, wheels, sanitizers, sanitizers-macos] runs-on: ubuntu-latest environment: pypi permissions: diff --git a/Makefile b/Makefile index d4fa978..8f2e971 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: dev test lint format stub wheels doc clean +.PHONY: dev test asan tsan lint format stub wheels doc clean # editable install; the extension is rebuilt automatically on import after C++ changes dev: @@ -9,6 +9,13 @@ dev: test: uv run --no-sync pytest tests $(O) +# the tests against an ASan+UBSan build, natively on macOS (on Linux: .github/scripts/sanitizers.sh) +asan: + .github/scripts/sanitizers-macos.sh $(O) + +tsan: + SANITIZE=thread .github/scripts/sanitizers-macos.sh $(O) + lint: uvx ruff check uvx ruff format --check diff --git a/python-webrtc/cpp/src/interfaces/interfaces.cpp b/python-webrtc/cpp/src/interfaces/interfaces.cpp index 50f47bb..0ae4aa8 100644 --- a/python-webrtc/cpp/src/interfaces/interfaces.cpp +++ b/python-webrtc/cpp/src/interfaces/interfaces.cpp @@ -19,6 +19,16 @@ #include "rtc_data_channel.h" #include "rtc_dtmf_sender.h" #include "rtc_peer_connection.h" +#include "../media/media_stream_track_processor.h" +#include "../media/track_generator.h" +#include "../media/video_frame_buffer.h" +#include "../utils/alive_count.h" +#include "../utils/gil.h" + +#include +#include + +#include namespace python_webrtc { @@ -35,5 +45,25 @@ namespace python_webrtc { RTCRtpTransceiver::Init(m); RTCDataChannel::Init(m); RTCPeerConnection::Init(m); + + // the native objects alive, by type, so tests can check that none leaks + m.def("_alive", []() { + return std::map{ + {"RTCPeerConnection", AliveCount::count.load()}, + {"MediaStreamTrack", MediaStreamTrack::holder().Alive()}, + {"MediaStream", MediaStream::holder().Alive()}, + {"RTCRtpTransceiver", RTCRtpTransceiver::holder().Alive()}, + {"RTCRtpSender", RTCRtpSender::holder().Alive()}, + {"RTCRtpReceiver", RTCRtpReceiver::holder().Alive()}, + {"RTCDTMFSender", RTCDTMFSender::holder().Alive()}, + {"RTCDataChannel", RTCDataChannel::holder().Alive()}, + {"RTCSctpTransport", RTCSctpTransport::holder().Alive()}, + {"RTCDtlsTransport", RTCDtlsTransport::holder().Alive()}, + {"RTCIceTransport", RTCIceTransport::holder().Alive()}, + {"MediaStreamTrackProcessor", AliveCount::count.load()}, + {"TrackGenerator", AliveCount::count.load()}, + {"VideoFrameBuffer", AliveCount::count.load()}, + }; + }, nogil()); } } diff --git a/python-webrtc/cpp/src/interfaces/media_stream.cpp b/python-webrtc/cpp/src/interfaces/media_stream.cpp index a5e9564..abb3803 100644 --- a/python-webrtc/cpp/src/interfaces/media_stream.cpp +++ b/python-webrtc/cpp/src/interfaces/media_stream.cpp @@ -68,37 +68,14 @@ namespace python_webrtc { } std::vector> MediaStream::SyncTracks() { - auto tracks = std::vector>(); - decltype(_tracks) current; - // read before locking: they're calls to the signaling thread, where OnChanged takes the lock - auto streamTracks = this->tracks(); - { - std::lock_guard lock(_tracksMutex); - for (auto const &track: streamTracks) { - auto it = _tracks.find(track.get()); - auto wrapper = it != _tracks.end() ? it->second : MediaStreamTrack::holder().GetOrCreate(_factory, track); - current[track.get()] = wrapper; - tracks.push_back(std::move(wrapper)); - } - std::swap(_tracks, current); + // Python keeps the wrappers, the holder finds them + std::vector> tracks; + for (auto const &track: this->tracks()) { + tracks.push_back(MediaStreamTrack::holder().GetOrCreate(_factory, track)); } - // wrappers of removed tracks are released here, out of the lock return tracks; } - std::shared_ptr MediaStream::WrapTrack( - webrtc::scoped_refptr track) { - std::lock_guard lock(_tracksMutex); - auto it = _tracks.find(track.get()); - if (it != _tracks.end()) { - return it->second; - } - - auto wrapper = MediaStreamTrack::holder().GetOrCreate(_factory, track); - _tracks[track.get()] = wrapper; - return wrapper; - } - webrtc::scoped_refptr MediaStream::stream() { return _stream; } @@ -182,9 +159,6 @@ namespace python_webrtc { } else { _stream->AddTrack(static_cast>(*mediaStreamTrack)); } - - std::lock_guard lock(_tracksMutex); - _tracks[track.get()] = mediaStreamTrack; } void MediaStream::RemoveTrack(MediaStreamTrack &mediaStreamTrack) { @@ -200,16 +174,6 @@ namespace python_webrtc { } else { _stream->RemoveTrack(static_cast>(mediaStreamTrack)); } - - std::shared_ptr removed; - { - std::lock_guard lock(_tracksMutex); - auto it = _tracks.find(track.get()); - if (it != _tracks.end()) { - removed = std::move(it->second); - _tracks.erase(it); - } - } } std::shared_ptr MediaStream::Clone() { diff --git a/python-webrtc/cpp/src/interfaces/media_stream.h b/python-webrtc/cpp/src/interfaces/media_stream.h index f0e4ae7..924b4e0 100644 --- a/python-webrtc/cpp/src/interfaces/media_stream.h +++ b/python-webrtc/cpp/src/interfaces/media_stream.h @@ -78,13 +78,11 @@ namespace python_webrtc { // wrappers of the current tracks of the stream; the stream owns them, so their state outlives Python references std::vector> SyncTracks(); - std::shared_ptr WrapTrack(webrtc::scoped_refptr); std::shared_ptr _factory; webrtc::scoped_refptr _stream; std::mutex _tracksMutex; - std::unordered_map> _tracks; // the tracks the stream had when last notified, or as Python changed them, guarded by _tracksMutex std::set _known; diff --git a/python-webrtc/cpp/src/interfaces/peer_connection_factory.cpp b/python-webrtc/cpp/src/interfaces/peer_connection_factory.cpp index 71542b5..2064d6a 100644 --- a/python-webrtc/cpp/src/interfaces/peer_connection_factory.cpp +++ b/python-webrtc/cpp/src/interfaces/peer_connection_factory.cpp @@ -7,7 +7,9 @@ #include "peer_connection_factory.h" #include "../media/playout_audio_device.h" +#include "../media/wakeup.h" #include "../utils/gil.h" +#include "../utils/instance_holder.h" #include "../utils/libwebrtc_thread.h" #include @@ -24,8 +26,13 @@ #include #include +#include #include +#ifndef _WIN32 +#include +#endif + namespace python_webrtc { // Royalty-free codecs only (the prebuilts have no H.264). @@ -43,7 +50,7 @@ namespace python_webrtc { std::mutex PeerConnectionFactory::_mutex{}; std::atomic PeerConnectionFactory::_alive{0}; - PeerConnectionFactory::PeerConnectionFactory() { + PeerConnectionFactory::PeerConnectionFactory() : _generation(forks.load()) { _alive++; _workerThread = webrtc::Thread::CreateWithSocketServer(); @@ -108,7 +115,19 @@ namespace python_webrtc { _alive--; } + void RunOnSignalingThread(PeerConnectionFactory &factory, const std::function &function) { + gil_release_if_held release; + factory._signalingThread->BlockingCall([&]() { function(); }); + } + std::shared_ptr PeerConnectionFactory::Create() { +#ifdef __APPLE__ + // libwebrtc runs its task queues on libdispatch, which crashes in the child of a fork + if (forks.load() > 0) { + throw std::runtime_error("python-webrtc can't be used in the child of a fork on macOS (libdispatch doesn't support " + "it): use the spawn start method of multiprocessing"); + } +#endif return {new PeerConnectionFactory(), &PeerConnectionFactory::Destroy}; } @@ -123,6 +142,10 @@ namespace python_webrtc { } void PeerConnectionFactory::Destroy(PeerConnectionFactory *factory) { + // leaked while the interpreter finalizes or in a forked child: its threads may hang or be gone + if (!PythonAlive() || factory->_generation != forks.load()) { + return; + } // the last owner may be released by a task on one of the factory threads, which can't stop itself if (factory->_workerThread->IsCurrent() || factory->_signalingThread->IsCurrent()) { std::thread([factory]() { delete factory; }).detach(); @@ -140,8 +163,27 @@ namespace python_webrtc { [[maybe_unused]] bool result = webrtc::InitializeSSL(); assert(result); +#ifndef _WIN32 + // libwebrtc threads don't survive a fork: the child forgets the factories (also runs before exec, keep it minimal) + pthread_atfork( + []() { + _mutex.lock(); + Wakeup::LockForFork(); + }, + []() { + Wakeup::UnlockAfterFork(); + _mutex.unlock(); + }, + []() { + forks++; + _default.reset(); + Wakeup::UnlockAfterFork(); + _mutex.unlock(); + }); +#endif + pybind11::class_>(m, "PeerConnectionFactory") - .def(pybind11::init(&PeerConnectionFactory::Create), nogil()) + .def(pybind11::init(nogil_factory(&PeerConnectionFactory::Create))) .def_static("getOrCreateDefault", &PeerConnectionFactory::GetOrCreateDefault, nogil()) .def_static("dispose", &PeerConnectionFactory::Dispose, nogil()); diff --git a/python-webrtc/cpp/src/interfaces/peer_connection_factory.h b/python-webrtc/cpp/src/interfaces/peer_connection_factory.h index 84dd439..dc095b7 100644 --- a/python-webrtc/cpp/src/interfaces/peer_connection_factory.h +++ b/python-webrtc/cpp/src/interfaces/peer_connection_factory.h @@ -49,6 +49,9 @@ namespace python_webrtc { std::unique_ptr _workerThread; private: + // of the process the factory was created in (see forks) + const int _generation; + static void Destroy(PeerConnectionFactory *); static std::weak_ptr _default; diff --git a/python-webrtc/cpp/src/interfaces/rtc_data_channel.cpp b/python-webrtc/cpp/src/interfaces/rtc_data_channel.cpp index 6e961ee..f1d38f4 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_data_channel.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_data_channel.cpp @@ -29,6 +29,7 @@ namespace python_webrtc { } // messages received meanwhile are delivered to the observer once it's registered, and are held too _channel->RegisterObserver(this); + holder().SetObserver(_channel.get(), this); // a channel announced by the remote peer is open already, but its open event follows the datachannel one if (_lastState == DataState::kOpen) { Emit("open", _lastState); @@ -39,11 +40,10 @@ namespace python_webrtc { RTCDataChannel::~RTCDataChannel() { BlockingDestructor release("RTCDataChannel"); - // the channel has a single observer slot, a newer wrapper of it may have taken it over already - auto replaced = holder().HasLive(_channel.get()); // callbacks run on the signaling thread, so after this none of them can be running or start again - _factory->_signalingThread->BlockingCall([this, replaced]() { - if (!replaced) { + _factory->_signalingThread->BlockingCall([this]() { + // a newer wrapper of the channel may have taken its single observer slot + if (holder().TakeObserver(_channel.get(), this)) { _channel->UnregisterObserver(); } }); @@ -80,7 +80,7 @@ namespace python_webrtc { .def("close", &RTCDataChannel::Close, nogil()) .def("_surfaceState", &RTCDataChannel::SurfaceState, nogil(), pybind11::arg("state")) .def("_decreaseBufferedAmount", &RTCDataChannel::DecreaseBufferedAmount, nogil(), pybind11::arg("sent")) - .def("_release", &RTCDataChannel::Release); + .def("_release", &RTCDataChannel::Release, nogil()); } InstanceHolder &RTCDataChannel::holder() { diff --git a/python-webrtc/cpp/src/interfaces/rtc_dtls_transport.cpp b/python-webrtc/cpp/src/interfaces/rtc_dtls_transport.cpp index e4c6183..65ba0be 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_dtls_transport.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_dtls_transport.cpp @@ -42,6 +42,7 @@ namespace python_webrtc { _factory->_workerThread->BlockingCall([this]() { _transport->RegisterObserver(this); + holder().SetObserver(_transport.get(), this); _observing = true; auto information = _transport->Information(); @@ -57,15 +58,8 @@ namespace python_webrtc { RTCDtlsTransport::~RTCDtlsTransport() { BlockingDestructor release("RTCDtlsTransport"); - // the transport has a single observer slot, a newer wrapper of it may have taken it over already - auto replaced = holder().HasLive(_transport.get()); // callbacks run on the network thread, so after this none of them can be running or start again - _factory->_workerThread->BlockingCall([this, replaced]() { - if (_observing && !replaced) { - _transport->UnregisterObserver(); - } - _observing = false; - }); + _factory->_workerThread->BlockingCall([this]() { Unobserve(); }); _iceTransport = nullptr; _transport = nullptr; @@ -124,11 +118,16 @@ namespace python_webrtc { Emit("error", RTCCallbackException(std::move(rtcError))); } - void RTCDtlsTransport::Stop() { - if (_observing) { + void RTCDtlsTransport::Unobserve() { + // a newer wrapper of the transport may have taken its single observer slot + if (_observing && holder().TakeObserver(_transport.get(), this)) { _transport->UnregisterObserver(); - _observing = false; } + _observing = false; + } + + void RTCDtlsTransport::Stop() { + Unobserve(); _iceTransport->OnRTCDtlsTransportStopped(); } diff --git a/python-webrtc/cpp/src/interfaces/rtc_dtls_transport.h b/python-webrtc/cpp/src/interfaces/rtc_dtls_transport.h index e5d763f..4e96423 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_dtls_transport.h +++ b/python-webrtc/cpp/src/interfaces/rtc_dtls_transport.h @@ -57,6 +57,9 @@ namespace python_webrtc { // on the network thread void Stop(); + // unregisters, unless a newer wrapper took the observer slot (on the network thread) + void Unobserve(); + std::shared_ptr _factory; webrtc::scoped_refptr _transport; // a dtls transport runs over the same ice transport for its whole life diff --git a/python-webrtc/cpp/src/interfaces/rtc_dtmf_sender.cpp b/python-webrtc/cpp/src/interfaces/rtc_dtmf_sender.cpp index 2681bac..40de318 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_dtmf_sender.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_dtmf_sender.cpp @@ -19,17 +19,19 @@ namespace python_webrtc { std::shared_ptr factory, webrtc::scoped_refptr dtmf) : _factory(std::move(factory)), _dtmf(std::move(dtmf)) { // see AliveGuard - _factory->_signalingThread->PostTask(_alive.Guard([this]() { _dtmf->RegisterObserver(this); })); + _factory->_signalingThread->PostTask(_alive.Guard([this]() { + _dtmf->RegisterObserver(this); + holder().SetObserver(_dtmf.get(), this); + })); } RTCDTMFSender::~RTCDTMFSender() { BlockingDestructor release("RTCDTMFSender"); - // the sender has a single observer slot, a newer wrapper of it may have taken it over already - auto replaced = holder().HasLive(_dtmf.get()); // callbacks run on the signaling thread, so after this none of them can be running or start again - _factory->_signalingThread->BlockingCall([this, replaced]() { - if (!replaced) { + _factory->_signalingThread->BlockingCall([this]() { + // a newer wrapper of the sender may have taken its single observer slot + if (holder().TakeObserver(_dtmf.get(), this)) { _dtmf->UnregisterObserver(); } }); diff --git a/python-webrtc/cpp/src/interfaces/rtc_ice_transport.cpp b/python-webrtc/cpp/src/interfaces/rtc_ice_transport.cpp index 01a692a..c8c44fe 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_ice_transport.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_ice_transport.cpp @@ -103,7 +103,7 @@ namespace python_webrtc { void RTCIceTransport::Init(pybind11::module &m) { Listeners::BindClass(m, "RTCIceTransport") // a transport of its own - .def(pybind11::init([]() { return CreateStandalone(PeerConnectionFactory::GetOrCreateDefault()); }), nogil()) + .def(pybind11::init(nogil_factory(+[]() { return CreateStandalone(PeerConnectionFactory::GetOrCreateDefault()); }))) .def_property_readonly("component", nogil_fn(&RTCIceTransport::GetComponent)) .def_property_readonly("gatheringState", nogil_fn(&RTCIceTransport::GetGatheringState)) .def_property_readonly("role", nogil_fn(&RTCIceTransport::GetRole)) diff --git a/python-webrtc/cpp/src/interfaces/rtc_peer_connection.cpp b/python-webrtc/cpp/src/interfaces/rtc_peer_connection.cpp index 7ded3a0..f8942d2 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_peer_connection.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_peer_connection.cpp @@ -122,9 +122,9 @@ namespace python_webrtc { void RTCPeerConnection::Init(pybind11::module &m) { Listeners::BindClass(m, "RTCPeerConnection") - .def(pybind11::init([](const std::optional &configuration) { + .def(pybind11::init(nogil_factory(+[](const std::optional &configuration) { return std::shared_ptr(new RTCPeerConnection(configuration), DeleteOffLibwebrtcThread()); - }), nogil()) + }))) .def("createOffer", &RTCPeerConnection::CreateOffer, nogil(), pybind11::arg("onSuccess"), pybind11::arg("onFailure"), pybind11::arg("iceRestart"), pybind11::arg("voiceActivityDetection")) @@ -219,6 +219,11 @@ namespace python_webrtc { template std::shared_ptr RTCPeerConnection::Wrap(Wrappers &wrappers, webrtc::scoped_refptr object) { + if (!onLibwebrtcThread) { + // on the signaling thread, which wraps objects too: the lock isn't held while waiting for it + gil_release_if_held release; + return _factory->_signalingThread->BlockingCall([&]() { return Wrap(wrappers, std::move(object)); }); + } std::shared_ptr wrapper; { std::lock_guard lock(_wrappersMutex); @@ -237,6 +242,11 @@ namespace python_webrtc { template std::vector> RTCPeerConnection::Sync( Wrappers &wrappers, const std::vector> &objects) { + if (!onLibwebrtcThread) { + // see Wrap + gil_release_if_held release; + return _factory->_signalingThread->BlockingCall([&]() { return Sync(wrappers, objects); }); + } std::vector> result; Wrappers current; { @@ -1112,6 +1122,11 @@ namespace python_webrtc { } std::optional> RTCPeerConnection::GetSctp() { + if (!onLibwebrtcThread) { + // see Wrap + gil_release_if_held release; + return _factory->_signalingThread->BlockingCall([this]() { return GetSctp(); }); + } auto pc = connection(); auto transport = pc ? pc->GetSctpTransport() : nullptr; if (!transport) { diff --git a/python-webrtc/cpp/src/interfaces/rtc_peer_connection.h b/python-webrtc/cpp/src/interfaces/rtc_peer_connection.h index b82cc20..1446fef 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_peer_connection.h +++ b/python-webrtc/cpp/src/interfaces/rtc_peer_connection.h @@ -26,6 +26,7 @@ #include #include +#include "../utils/alive_count.h" #include "../exceptions.h" #include "../models/python_webrtc/rtc_configuration.h" #include "../models/python_webrtc/rtc_session_description.h" @@ -367,6 +368,7 @@ namespace python_webrtc { void FireRemoteStreamChanges(); // wrappers' destructors use the threads of the factory: declared first, to be destroyed last + AliveCount _counted; std::shared_ptr _factory; std::mutex _connectionMutex; diff --git a/python-webrtc/cpp/src/interfaces/rtc_rtp_receiver.cpp b/python-webrtc/cpp/src/interfaces/rtc_rtp_receiver.cpp index 26f7866..a41adce 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_rtp_receiver.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_rtp_receiver.cpp @@ -26,17 +26,19 @@ namespace python_webrtc { // the track of a receiver is a remote one _track->MarkRemote(); // see AliveGuard - _factory->_signalingThread->PostTask(_alive.Guard([this]() { _receiver->SetObserver(this); })); + _factory->_signalingThread->PostTask(_alive.Guard([this]() { + _receiver->SetObserver(this); + holder().SetObserver(_receiver.get(), this); + })); } RTCRtpReceiver::~RTCRtpReceiver() { BlockingDestructor release("RTCRtpReceiver"); - // the receiver has a single observer slot, a newer wrapper of it may have taken it over already - auto replaced = holder().HasLive(_receiver.get()); // callbacks run on the signaling thread, so after this none of them can be running or start again - _factory->_signalingThread->BlockingCall([this, replaced]() { - if (!replaced) { + _factory->_signalingThread->BlockingCall([this]() { + // a newer wrapper of the receiver may have taken its single observer slot + if (holder().TakeObserver(_receiver.get(), this)) { _receiver->SetObserver(nullptr); } }); @@ -84,12 +86,14 @@ namespace python_webrtc { std::optional> RTCRtpReceiver::GetTransport() { auto transport = _receiver->dtls_transport(); + // wrapped out of the lock: wrapping may wait for the signaling thread + auto wrapper = RTCDtlsTransport::holder().GetOrCreate(_factory, transport); std::shared_ptr previous; std::lock_guard lock(_mutex); if (!_transport || _transport->transport() != transport) { previous = std::move(_transport); - _transport = RTCDtlsTransport::holder().GetOrCreate(_factory, transport); + _transport = std::move(wrapper); } if (_transport) { diff --git a/python-webrtc/cpp/src/interfaces/rtc_rtp_sender.cpp b/python-webrtc/cpp/src/interfaces/rtc_rtp_sender.cpp index 326a1e0..df01644 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_rtp_sender.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_rtp_sender.cpp @@ -68,12 +68,14 @@ namespace python_webrtc { std::optional> RTCRtpSender::GetTrack() { auto track = _sender->track(); + // wrapped out of the lock: wrapping may wait for the signaling thread + auto wrapper = MediaStreamTrack::holder().GetOrCreate(_factory, track); std::shared_ptr previous; std::lock_guard lock(_mutex); if (!_track || _track->track() != track) { previous = std::move(_track); - _track = MediaStreamTrack::holder().GetOrCreate(_factory, track); + _track = std::move(wrapper); } if (_track) { @@ -84,12 +86,14 @@ namespace python_webrtc { std::optional> RTCRtpSender::GetTransport() { auto transport = _sender->dtls_transport(); + // see GetTrack + auto wrapper = RTCDtlsTransport::holder().GetOrCreate(_factory, transport); std::shared_ptr previous; std::lock_guard lock(_mutex); if (!_transport || _transport->transport() != transport) { previous = std::move(_transport); - _transport = RTCDtlsTransport::holder().GetOrCreate(_factory, transport); + _transport = std::move(wrapper); } if (_transport) { @@ -104,9 +108,11 @@ namespace python_webrtc { std::shared_ptr RTCRtpSender::GetDtmf() { auto dtmf = _sender->GetDtmfSender(); + // see GetTrack + auto wrapper = RTCDTMFSender::holder().GetOrCreate(_factory, dtmf); std::lock_guard lock(_mutex); - if (dtmf && (!_dtmf || _dtmf.get() != RTCDTMFSender::holder().Find(dtmf.get()).get())) { - _dtmf = RTCDTMFSender::holder().GetOrCreate(_factory, dtmf); + if (wrapper && _dtmf != wrapper) { + _dtmf = std::move(wrapper); _dtmf->SetTransceiver(TransceiverGetter()); } return dtmf ? _dtmf : nullptr; diff --git a/python-webrtc/cpp/src/interfaces/rtc_sctp_transport.cpp b/python-webrtc/cpp/src/interfaces/rtc_sctp_transport.cpp index fe5fd98..c445ec0 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_sctp_transport.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_sctp_transport.cpp @@ -18,6 +18,7 @@ namespace python_webrtc { _factory->_workerThread->BlockingCall([this, &dtlsTransport]() { dtlsTransport = _transport->dtls_transport(); _transport->RegisterObserver(this); + holder().SetObserver(_transport.get(), this); _observing = true; _lastState = _transport->Information().state(); @@ -32,15 +33,8 @@ namespace python_webrtc { RTCSctpTransport::~RTCSctpTransport() { BlockingDestructor release("RTCSctpTransport"); - // the transport has a single observer slot, a newer wrapper of it may have taken it over already - auto replaced = holder().HasLive(_transport.get()); // callbacks run on the network thread, so after this none of them can be running or start again - _factory->_workerThread->BlockingCall([this, replaced]() { - if (_observing && !replaced) { - _transport->UnregisterObserver(); - } - _observing = false; - }); + _factory->_workerThread->BlockingCall([this]() { Stop(); }); _dtlsTransport = nullptr; _transport = nullptr; @@ -63,10 +57,11 @@ namespace python_webrtc { } void RTCSctpTransport::Stop() { - if (_observing) { + // a newer wrapper of the transport may have taken its single observer slot + if (_observing && holder().TakeObserver(_transport.get(), this)) { _transport->UnregisterObserver(); - _observing = false; } + _observing = false; } void RTCSctpTransport::OnStateChange(webrtc::SctpTransportInformation info) { diff --git a/python-webrtc/cpp/src/media/audio_samples.cpp b/python-webrtc/cpp/src/media/audio_samples.cpp index 52c9ea5..bfc23c2 100644 --- a/python-webrtc/cpp/src/media/audio_samples.cpp +++ b/python-webrtc/cpp/src/media/audio_samples.cpp @@ -12,6 +12,8 @@ #include #include +#include "utils/buffer.h" + namespace python_webrtc { namespace { @@ -138,16 +140,18 @@ namespace python_webrtc { size_t frameCount) { auto sourceFormat = ParseFormat(sourceFormatName); auto destinationFormat = ParseFormat(destinationFormatName); - auto sourceInfo = source.request(); - auto destinationInfo = destination.request(true); + auto sourceInfo = ContiguousBuffer(source); + auto destinationInfo = ContiguousBuffer(destination, true); auto sourceSize = static_cast(sourceInfo.size * sourceInfo.itemsize); auto destinationSize = static_cast(destinationInfo.size * destinationInfo.itemsize); size_t sourceSample = Size(sourceFormat.type); size_t destinationSample = Size(destinationFormat.type); size_t copiedChannels = destinationFormat.planar ? 1 : channels; - if (frameOffset + frameCount > frames || sourceSize < frames * channels * sourceSample || - destinationSize < frameCount * copiedChannels * destinationSample || + // divided rather than multiplied, which could overflow + if (channels == 0 || frameOffset > frames || frameCount > frames - frameOffset || + frames > sourceSize / sourceSample / channels || + frameCount > destinationSize / destinationSample / copiedChannels || (destinationFormat.planar ? planeIndex >= channels : planeIndex != 0)) { throw pybind11::value_error("The copy is out of the bounds of the samples or of the destination"); } diff --git a/python-webrtc/cpp/src/media/media_stream_track_processor.cpp b/python-webrtc/cpp/src/media/media_stream_track_processor.cpp index 7ab99b7..63cfc23 100644 --- a/python-webrtc/cpp/src/media/media_stream_track_processor.cpp +++ b/python-webrtc/cpp/src/media/media_stream_track_processor.cpp @@ -17,18 +17,24 @@ namespace python_webrtc { - MediaStreamTrackProcessor::MediaStreamTrackProcessor(std::shared_ptr track, size_t maxBufferSize) - : _track(std::move(track)), - _video(_track->track()->kind() == webrtc::MediaStreamTrackInterface::kVideoKind), + MediaStreamTrackProcessor::MediaStreamTrackProcessor( + std::shared_ptr factory, webrtc::scoped_refptr track, + size_t maxBufferSize) + : _factory(std::move(factory)), + _track(std::move(track)), + _video(_track->kind() == webrtc::MediaStreamTrackInterface::kVideoKind), _maxBufferSize(std::max(1, maxBufferSize)) {} std::shared_ptr MediaStreamTrackProcessor::Create(std::shared_ptr track, size_t maxBufferSize) { + // Python keeps the track's wrapper, so the collector sees handlers of the track referencing the processor std::shared_ptr processor( - new MediaStreamTrackProcessor(std::move(track), maxBufferSize), DeleteOffLibwebrtcThread()); - processor->Attach(); + new MediaStreamTrackProcessor(track->factory(), track->track(), maxBufferSize), DeleteOffLibwebrtcThread()); + if (!track->ended()) { + processor->Attach(); + } // after the sink is attached: an end meanwhile detaches it - processor->_track->AddEndObserver(processor); + track->AddEndObserver(processor); return processor; } @@ -40,7 +46,7 @@ namespace python_webrtc { void MediaStreamTrackProcessor::Init(pybind11::module &m) { Listeners::BindClass(m, "MediaStreamTrackProcessor") - .def(pybind11::init(&MediaStreamTrackProcessor::Create), nogil(), pybind11::arg("track"), + .def(pybind11::init(nogil_factory(&MediaStreamTrackProcessor::Create)), pybind11::arg("track"), pybind11::arg("maxBufferSize")) .def("read", &MediaStreamTrackProcessor::Read) .def("cancel", &MediaStreamTrackProcessor::Cancel, nogil()) @@ -52,14 +58,13 @@ namespace python_webrtc { void MediaStreamTrackProcessor::Attach() { std::lock_guard lock(_attachMutex); - if (_attached || _track->ended()) { + if (_attached) { return; } if (_video) { - static_cast>(*_track)->AddOrUpdateSink( - this, webrtc::VideoSinkWants()); + dynamic_cast(_track.get())->AddOrUpdateSink(this, webrtc::VideoSinkWants()); } else { - static_cast>(*_track)->AddSink(this); + dynamic_cast(_track.get())->AddSink(this); } _attached = true; } @@ -71,9 +76,9 @@ namespace python_webrtc { } // once removed, the track doesn't call the sink anymore if (_video) { - static_cast>(*_track)->RemoveSink(this); + dynamic_cast(_track.get())->RemoveSink(this); } else { - static_cast>(*_track)->RemoveSink(this); + dynamic_cast(_track.get())->RemoveSink(this); } _attached = false; } diff --git a/python-webrtc/cpp/src/media/media_stream_track_processor.h b/python-webrtc/cpp/src/media/media_stream_track_processor.h index f75f7d8..882a01f 100644 --- a/python-webrtc/cpp/src/media/media_stream_track_processor.h +++ b/python-webrtc/cpp/src/media/media_stream_track_processor.h @@ -22,6 +22,7 @@ #include +#include "../utils/alive_count.h" #include "video_frame_buffer.h" #include "wakeup.h" #include "../interfaces/media_stream_track.h" @@ -76,7 +77,8 @@ namespace python_webrtc { void OnTrackEnded() override; private: - MediaStreamTrackProcessor(std::shared_ptr track, size_t maxBufferSize); + MediaStreamTrackProcessor(std::shared_ptr factory, + webrtc::scoped_refptr track, size_t maxBufferSize); void Attach(); @@ -106,7 +108,10 @@ namespace python_webrtc { // wakes Python, unless a wakeup is pending void WakeLocked(); - std::shared_ptr _track; + AliveCount _counted; + // the threads of the factory run the proxy of the track + std::shared_ptr _factory; + webrtc::scoped_refptr _track; const bool _video; const size_t _maxBufferSize; diff --git a/python-webrtc/cpp/src/media/track_generator.cpp b/python-webrtc/cpp/src/media/track_generator.cpp index e600b31..a4697fc 100644 --- a/python-webrtc/cpp/src/media/track_generator.cpp +++ b/python-webrtc/cpp/src/media/track_generator.cpp @@ -8,14 +8,18 @@ #include "track_generator.h" #include +#include #include +#include +#include #include #include #include #include "../exceptions.h" #include "../utils/gil.h" +#include "../utils/libwebrtc_thread.h" namespace python_webrtc { @@ -23,6 +27,9 @@ namespace python_webrtc { // libwebrtc takes audio in 10 ms frames constexpr int kAudioFramesPerSecond = 100; + // libwebrtc's native rates: its resampler crashes on much lower ones + constexpr int kMinSampleRate = 8000; + constexpr int kMaxSampleRate = 384000; } // namespace @@ -39,13 +46,42 @@ namespace python_webrtc { _audioSource = webrtc::make_ref_counted(); track = _factory->factory()->CreateAudioTrack(webrtc::CreateRandomUuid(), _audioSource.get()); } - _track = MediaStreamTrack::holder().GetOrCreate(_factory, track); + _webrtcTrack = track; + _initialTrack = MediaStreamTrack::holder().GetOrCreate(_factory, track); + _initialTrack->AddEndObserver(_endState); + _track = _initialTrack; + } + + std::shared_ptr TrackGenerator::GetTrack() { + // wrapped out of the lock: wrapping may wait for the signaling thread (the holder finds a live wrapper) + auto wrapped = MediaStreamTrack::holder().GetOrCreate(_factory, _webrtcTrack); + std::lock_guard lock(_trackMutex); + auto track = _track.lock(); + if (!track) { + track = std::move(wrapped); + if (_endState->ended) { + // a new wrapper of a track stopped meanwhile + track->Stop(); + } else { + track->AddEndObserver(_endState); + } + if (_muted) { + track->SetMuted(true); + } + _track = track; + } + _initialTrack = nullptr; + return track; + } + + std::shared_ptr TrackGenerator::Create(const std::string &kind) { + return std::shared_ptr(new TrackGenerator(kind), DeleteOffLibwebrtcThread()); } void TrackGenerator::Init(pybind11::module &m) { pybind11::class_>(m, "TrackGenerator") - .def(pybind11::init(), nogil(), pybind11::arg("kind")) - .def_property_readonly("track", &TrackGenerator::GetTrack) + .def(pybind11::init(nogil_factory(&TrackGenerator::Create)), pybind11::arg("kind")) + .def_property_readonly("track", nogil_fn(&TrackGenerator::GetTrack)) .def_property_readonly("kind", &TrackGenerator::GetKind) .def_property_readonly("live", nogil_fn(&TrackGenerator::GetLive)) .def_property("muted", nogil_fn(&TrackGenerator::GetMuted), nogil_fn(&TrackGenerator::SetMuted)) @@ -57,7 +93,12 @@ namespace python_webrtc { } bool TrackGenerator::GetLive() { - return _track->active(); + if (_endState->ended) { + return false; + } + auto track = _track.lock(); + return track ? track->active() + : _webrtcTrack->state() == webrtc::MediaStreamTrackInterface::TrackState::kLive; } bool TrackGenerator::GetMuted() { @@ -66,7 +107,9 @@ namespace python_webrtc { void TrackGenerator::SetMuted(bool muted) { _muted = muted; - _track->SetMuted(muted); + if (auto track = _track.lock()) { + track->SetMuted(muted); + } } void TrackGenerator::WriteVideo(const std::shared_ptr &buffer, int64_t timestampUs, @@ -91,8 +134,19 @@ namespace python_webrtc { if (_video) { throw pybind11::type_error("A video generator takes VideoFrame"); } + // beyond what a webrtc::AudioFrame holds, its checks abort + if (sampleRate < kMinSampleRate || sampleRate > kMaxSampleRate || channels == 0 || + channels > webrtc::kMaxNumberOfAudioChannels || + static_cast(sampleRate / kAudioFramesPerSecond) * channels > webrtc::AudioFrame::kMaxDataSizeSamples) { + throw pybind11::value_error("Audio of " + std::to_string(channels) + " channels at " + + std::to_string(sampleRate) + " Hz isn't supported: up to " + + std::to_string(webrtc::kMaxNumberOfAudioChannels) + " channels from " + + std::to_string(kMinSampleRate) + " to " + std::to_string(kMaxSampleRate) + + " Hz, and " + std::to_string(webrtc::AudioFrame::kMaxDataSizeSamples) + + " samples every 10 ms"); + } std::string_view data = samples; - if (data.size() != frames * channels * sizeof(int16_t)) { + if (frames > data.size() || data.size() != frames * channels * sizeof(int16_t)) { throw pybind11::value_error("The samples don't have the given number of frames"); } pybind11::gil_scoped_release release; diff --git a/python-webrtc/cpp/src/media/track_generator.h b/python-webrtc/cpp/src/media/track_generator.h index 05fcef7..dcc7ac9 100644 --- a/python-webrtc/cpp/src/media/track_generator.h +++ b/python-webrtc/cpp/src/media/track_generator.h @@ -18,6 +18,7 @@ #include +#include "../utils/alive_count.h" #include "video_frame_buffer.h" #include "../interfaces/media_stream_track.h" #include "../interfaces/peer_connection_factory.h" @@ -31,9 +32,13 @@ namespace python_webrtc { public: explicit TrackGenerator(const std::string &kind); + // deleted without the GIL: the track's proxy is destroyed on the signaling thread + static std::shared_ptr Create(const std::string &kind); + static void Init(pybind11::module &m); - std::shared_ptr GetTrack() { return _track; } + // the wrapper of the track, owned by Python once it has it + std::shared_ptr GetTrack(); std::string GetKind() { return _video ? "video" : "audio"; } @@ -53,11 +58,26 @@ namespace python_webrtc { void Close(); private: + // whether the track ended, kept when its wrapper is gone + struct EndState : TrackEndObserver { + std::atomic ended = false; + + void OnTrackEnded() override { + ended = true; + } + }; + + AliveCount _counted; std::shared_ptr _factory; const bool _video; webrtc::scoped_refptr _videoSource; webrtc::scoped_refptr _audioSource; - std::shared_ptr _track; + webrtc::scoped_refptr _webrtcTrack; + // weak once Python has it: an audio generator is its own track, a strong one hides a cycle from the collector + std::mutex _trackMutex; + std::shared_ptr _initialTrack; + std::weak_ptr _track; + std::shared_ptr _endState = std::make_shared(); std::atomic _muted = false; // samples short of a 10 ms frame, sent with the next ones of the same format diff --git a/python-webrtc/cpp/src/media/video_frame_buffer.cpp b/python-webrtc/cpp/src/media/video_frame_buffer.cpp index 76c801a..fe8f9fe 100644 --- a/python-webrtc/cpp/src/media/video_frame_buffer.cpp +++ b/python-webrtc/cpp/src/media/video_frame_buffer.cpp @@ -8,6 +8,7 @@ #include "video_frame_buffer.h" #include +#include #include #include @@ -20,10 +21,15 @@ #include +#include "utils/buffer.h" + namespace python_webrtc { namespace { + // keeps the sizes of planes and rows in ints + constexpr int kMaxDimension = 1 << 24; + using Layout = PixelFormat::Layout; const PixelFormat FORMATS[] = { @@ -147,14 +153,14 @@ namespace python_webrtc { const std::vector &layout) { const auto &format = PixelFormat::Parse(formatName); auto planes = format.Planes(); - if (width <= 0 || height <= 0) { - throw pybind11::value_error("The frame must have a positive size"); + if (width <= 0 || height <= 0 || width > kMaxDimension || height > kMaxDimension) { + throw pybind11::value_error("The frame must have a positive size of at most " + std::to_string(kMaxDimension)); } if (layout.size() != planes.size()) { throw pybind11::value_error("The layout must have one entry per plane"); } - auto info = data.request(); + auto info = ContiguousBuffer(data); auto source = static_cast(info.ptr); auto sourceSize = static_cast(info.size * info.itemsize); @@ -168,7 +174,7 @@ namespace python_webrtc { rows[i] = planes[i].Rows(height); auto [offset, stride] = layout[i]; if (stride < static_cast(result->_stride[i]) || - offset + stride * (rows[i] - 1) + result->_stride[i] > sourceSize) { + !RowsFit(offset, stride, rows[i], result->_stride[i], sourceSize)) { throw pybind11::value_error("The layout doesn't fit in the data"); } total += static_cast(result->_stride[i]) * rows[i]; @@ -212,7 +218,7 @@ namespace python_webrtc { } void VideoFrameBuffer::CopyPlanes(const pybind11::buffer &destination, const std::vector &copies) const { - auto info = destination.request(true); + auto info = ContiguousBuffer(destination, true); auto size = static_cast(info.size * info.itemsize); auto target = static_cast(info.ptr); auto planes = _format->Planes(); @@ -224,8 +230,8 @@ namespace python_webrtc { size_t planeRows = planes[i].Rows(_height); size_t planeRowBytes = static_cast(planes[i].Columns(_width)) * planes[i].sampleBytes; if (rows > 0 && rowBytes > 0 && - (top + rows > planeRows || leftBytes + rowBytes > planeRowBytes || stride < rowBytes || - offset + stride * (rows - 1) + rowBytes > size)) { + (top > planeRows || rows > planeRows - top || leftBytes > planeRowBytes || + rowBytes > planeRowBytes - leftBytes || stride < rowBytes || !RowsFit(offset, stride, rows, rowBytes, size))) { throw pybind11::value_error("The copy is out of the bounds of the frame or of the destination"); } } @@ -273,12 +279,12 @@ namespace python_webrtc { if (format.layout != Layout::RGB) { throw pybind11::value_error("Frames are converted to RGB formats only"); } - if (x < 0 || y < 0 || width <= 0 || height <= 0 || x + width > _width || y + height > _height) { + if (x < 0 || y < 0 || width <= 0 || height <= 0 || x > _width - width || y > _height - height) { throw pybind11::value_error("The rect is out of the bounds of the frame"); } - auto info = destination.request(true); + auto info = ContiguousBuffer(destination, true); auto size = static_cast(info.size * info.itemsize); - if (stride < static_cast(width) * 4 || offset + stride * (height - 1) + width * 4 > size) { + if (stride < static_cast(width) * 4 || !RowsFit(offset, stride, height, static_cast(width) * 4, size)) { throw pybind11::value_error("The destination is too small"); } auto dst = static_cast(info.ptr) + offset; diff --git a/python-webrtc/cpp/src/media/video_frame_buffer.h b/python-webrtc/cpp/src/media/video_frame_buffer.h index d8b41d6..80a4174 100644 --- a/python-webrtc/cpp/src/media/video_frame_buffer.h +++ b/python-webrtc/cpp/src/media/video_frame_buffer.h @@ -19,6 +19,8 @@ #include +#include "../utils/alive_count.h" + namespace python_webrtc { // A pixel format of VideoFrame (WebCodecs VideoPixelFormat) @@ -107,6 +109,7 @@ namespace python_webrtc { // what the planes point into webrtc::scoped_refptr _webrtc; std::shared_ptr> _owned; + AliveCount _counted; }; } // namespace python_webrtc diff --git a/python-webrtc/cpp/src/media/wakeup.h b/python-webrtc/cpp/src/media/wakeup.h index 5dd903b..5b6aecf 100644 --- a/python-webrtc/cpp/src/media/wakeup.h +++ b/python-webrtc/cpp/src/media/wakeup.h @@ -13,6 +13,8 @@ #include #include +#include "../utils/libwebrtc_thread.h" + namespace python_webrtc { // What a Wakeup delivers to, on its thread @@ -31,11 +33,26 @@ namespace python_webrtc { auto &wakeup = Instance(); { std::lock_guard lock(wakeup._mutex); + if (wakeup._generation != forks.load()) { + // the child of a fork: the thread is gone, and so are the objects of the parent + wakeup._generation = forks.load(); + wakeup._targets.clear(); + std::thread([&wakeup]() { wakeup.Run(); }).detach(); + } wakeup._targets.push_back(std::move(target)); } wakeup._posted.notify_one(); } + // held across a fork, so the child doesn't get it locked by a thread it doesn't have + static void LockForFork() { + Instance()._mutex.lock(); + } + + static void UnlockAfterFork() { + Instance()._mutex.unlock(); + } + private: static Wakeup &Instance() { // never destroyed: its thread may run while the process exits @@ -64,6 +81,8 @@ namespace python_webrtc { } std::mutex _mutex; + // of the process the thread runs in (see forks) + int _generation = forks.load(); std::condition_variable _posted; std::deque> _targets; }; diff --git a/python-webrtc/cpp/src/module.cpp b/python-webrtc/cpp/src/module.cpp index 5f75ebe..2f86c34 100644 --- a/python-webrtc/cpp/src/module.cpp +++ b/python-webrtc/cpp/src/module.cpp @@ -16,6 +16,14 @@ namespace py = pybind11; +#if defined(__SANITIZE_ADDRESS__) || defined(__SANITIZE_THREAD__) +#define WRTC_SANITIZED +#elif defined(__has_feature) +#if __has_feature(address_sanitizer) || __has_feature(thread_sanitizer) +#define WRTC_SANITIZED +#endif +#endif + static bool copyrightShowed = false; static void ping() { @@ -26,13 +34,19 @@ PYBIND11_MODULE(wrtc, m) { if (!copyrightShowed) { auto ver = std::string(PROJECT_VER); auto dev = ver.find("dev") != std::string::npos ? " DEV" : ""; - py::print("Python WebRTC v" + ver + dev + ", Copyright (C) 2022 Il`ya (Marshal) "); + py::print("Python WebRTC v" + ver + dev + ", Copyright (C) 2026 Ilya (Marshal) "); py::print("Licensed under the terms of the BSD 3-Clause License\n\n"); copyrightShowed = true; } m.def("ping", &ping); + // the memory of ASan (its quarantine) and TSan (its shadow) makes resident memory say nothing about leaks +#ifdef WRTC_SANITIZED + m.attr("_sanitized") = true; +#else + m.attr("_sanitized") = false; +#endif python_webrtc::Exceptions::Init(m); python_webrtc::Models::Init(m); diff --git a/python-webrtc/cpp/src/utils/alive_count.h b/python-webrtc/cpp/src/utils/alive_count.h new file mode 100644 index 0000000..6f8734e --- /dev/null +++ b/python-webrtc/cpp/src/utils/alive_count.h @@ -0,0 +1,35 @@ +// +// Copyright 2026 Ilya (Marshal) . All rights reserved. +// +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE.md file in the root of the project. +// + +#pragma once + +#include + +namespace python_webrtc { + + // Counts the live objects of a type, a member of it, so tests can check that none leaks (wrtc._alive) + template + class AliveCount { + public: + AliveCount() { + count++; + } + + AliveCount(const AliveCount &) { + count++; + } + + AliveCount &operator=(const AliveCount &) = default; + + ~AliveCount() { + count--; + } + + static inline std::atomic count{0}; + }; + +} // namespace python_webrtc diff --git a/python-webrtc/cpp/src/utils/buffer.h b/python-webrtc/cpp/src/utils/buffer.h new file mode 100644 index 0000000..e0384fc --- /dev/null +++ b/python-webrtc/cpp/src/utils/buffer.h @@ -0,0 +1,36 @@ +// +// Copyright 2026 Ilya (Marshal) . All rights reserved. +// +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE.md file in the root of the project. +// + +#pragma once + +#include + +#include + +namespace python_webrtc { + + // A Python buffer used as one block: a strided view (like view[::-1]) would be accessed out of its bounds + inline pybind11::buffer_info ContiguousBuffer(const pybind11::buffer &buffer, bool writable = false) { + auto info = buffer.request(writable); + if (!PyBuffer_IsContiguous(info.view(), 'C')) { + throw pybind11::type_error("The buffer must be contiguous"); + } + return info; + } + + // Whether rows of rowBytes, stride apart and starting at offset, fit in size bytes, without overflowing + inline bool RowsFit(size_t offset, size_t stride, size_t rows, size_t rowBytes, size_t size) { + if (rows == 0 || rowBytes == 0) { + return true; + } + if (offset > size || rowBytes > size - offset) { + return false; + } + return rows == 1 || stride <= (size - offset - rowBytes) / (rows - 1); + } + +} // namespace python_webrtc diff --git a/python-webrtc/cpp/src/utils/gil.h b/python-webrtc/cpp/src/utils/gil.h index f587da7..8717b8f 100644 --- a/python-webrtc/cpp/src/utils/gil.h +++ b/python-webrtc/cpp/src/utils/gil.h @@ -24,6 +24,15 @@ namespace python_webrtc { return pybind11::cpp_function(std::forward(f), nogil()); } + // A pybind11::init factory without the GIL: a call guard would also cover registering the instance, which needs it + template + auto nogil_factory(R (*factory)(Args...)) { + return [factory](Args... args) { + pybind11::gil_scoped_release release; + return factory(std::forward(args)...); + }; + } + // Whether Python code can still run: libwebrtc threads may outlive the interpreter inline bool PythonAlive() { #if PY_VERSION_HEX >= 0x030D0000 diff --git a/python-webrtc/cpp/src/utils/instance_holder.h b/python-webrtc/cpp/src/utils/instance_holder.h index 7c3cd74..d90dc48 100644 --- a/python-webrtc/cpp/src/utils/instance_holder.h +++ b/python-webrtc/cpp/src/utils/instance_holder.h @@ -7,7 +7,7 @@ #pragma once -#include +#include #include #include #include @@ -23,8 +23,11 @@ namespace python_webrtc { class PeerConnectionFactory; - // A wrapper of a libwebrtc object that takes a single observer: a new wrapper of the object must not register - // before the dying one has unregistered. Wrappers of objects that take any number of observers don't wait. + // Runs a function on the signaling thread of a factory (defined with the factory) + void RunOnSignalingThread(PeerConnectionFactory &factory, const std::function &function); + + // A wrapper of a libwebrtc object that takes a single observer: the holder tells which wrapper is registered, so a + // dying one doesn't unregister a newer one (both done on the observer's thread) struct SingleObserverSlot {}; // At most one wrapper T per libwebrtc object U, so Python always sees the same object for it. Entries are weak: @@ -36,14 +39,19 @@ namespace python_webrtc { if (!object) { return nullptr; } + if (auto instance = Find(object.get())) { + return instance; + } + if (!onLibwebrtcThread) { + // created on the signaling thread: constructors call it, which may be wrapping objects under the same locks + std::shared_ptr instance; + RunOnSignalingThread(*factory, [&]() { instance = GetOrCreate(factory, object); }); + return instance; + } // wrappers may create nested wrappers (sctp -> dtls -> ice) while holding the lock std::unique_lock lock(_mutex); auto key = object.get(); - // a wrapper of the object being destroyed first unregisters from it, the new one registers after - if constexpr (std::is_base_of_v) { - _destroyed.wait(lock, [&]() { return _destroying.count(key) == 0; }); - } auto it = _store.find(key); if (it != _store.end()) { if (auto instance = it->second.lock()) { @@ -51,8 +59,13 @@ namespace python_webrtc { } } - std::shared_ptr instance(new T(factory, std::move(object)), [this, key](T *dying) { - // marked right away, so a new wrapper waits for this one even when it's destroyed elsewhere + std::shared_ptr instance(new T(factory, std::move(object)), [this, key, generation = forks.load()](T *dying) { + // left to the exit of the process (see ReleaseOffLibwebrtcThread): the lock may be held by a hung thread + if (!PythonAlive() || generation != forks.load()) { + return; + } + // the lock's holder may wait for a libwebrtc thread waiting for the GIL + gil_release_if_held release; StartDestroying(key); ReleaseOffLibwebrtcThread([this, key, dying]() { Destroy(key, dying); }); }); @@ -67,13 +80,31 @@ namespace python_webrtc { return it != _store.end() ? it->second.lock() : nullptr; } - // Whether another wrapper of the same libwebrtc object is alive. Only meaningful in a destructor of the wrapper, - // where it tells that the object was re-wrapped meanwhile and the new wrapper has taken over its observer slot. - bool HasLive(const U *object) { - // called from the destructor of the dying wrapper, which Destroy runs without holding the lock + // wrappers alive or being destroyed, for tests (wrtc._alive) + int Alive() { std::lock_guard lock(_mutex); - auto it = _store.find(const_cast(object)); - return it != _store.end() && !it->second.expired(); + int alive = static_cast(_destroying.size()); + for (const auto &entry: _store) { + alive += entry.second.expired() ? 0 : 1; + } + return alive; + } + + // A wrapper registered as the observer of its object, on the observer's thread + void SetObserver(const U *object, const T *wrapper) { + std::lock_guard lock(_observersMutex); + _observers[object] = wrapper; + } + + // Whether a wrapper is still the observer of its object, which it no longer is then, on the observer's thread + bool TakeObserver(const U *object, const T *wrapper) { + std::lock_guard lock(_observersMutex); + auto it = _observers.find(object); + if (it == _observers.end() || it->second != wrapper) { + return false; + } + _observers.erase(it); + return true; } private: @@ -89,21 +120,18 @@ namespace python_webrtc { void Destroy(U *key, T *dying) { gil_release_if_held release; - // Out of the lock: destructors block on libwebrtc threads (to unregister observers), which may be waiting - // for this lock themselves (a callback wrapping an object). A replacement waits for this to finish. + // out of the lock: destructors block on libwebrtc threads, which may be waiting for it (wrapping an object) delete dying; - { - std::lock_guard lock(_mutex); - _destroying.erase(_destroying.find(key)); - } - _destroyed.notify_all(); + std::lock_guard lock(_mutex); + _destroying.erase(_destroying.find(key)); } std::recursive_mutex _mutex; - std::condition_variable_any _destroyed; std::unordered_map> _store; // objects whose wrapper is being destroyed std::unordered_multiset _destroying; + std::mutex _observersMutex; + std::unordered_map _observers; }; } // namespace python_webrtc diff --git a/python-webrtc/cpp/src/utils/libwebrtc_thread.h b/python-webrtc/cpp/src/utils/libwebrtc_thread.h index 4a05e89..4e973c0 100644 --- a/python-webrtc/cpp/src/utils/libwebrtc_thread.h +++ b/python-webrtc/cpp/src/utils/libwebrtc_thread.h @@ -7,6 +7,7 @@ #pragma once +#include #include #include #include @@ -40,9 +41,15 @@ namespace python_webrtc { bool _previous; }; - // Runs a release right away, or on a thread of its own on a libwebrtc thread + // Forks, counted in the child: objects from before one are never released there, their threads are gone + inline std::atomic forks{0}; + + // Runs a release right away, or on a thread of its own on a libwebrtc thread; leaks while the interpreter finalizes template - void ReleaseOffLibwebrtcThread(F &&release) { + void ReleaseOffLibwebrtcThread(F &&release, int generation = forks.load()) { + if (!PythonAlive() || generation != forks.load()) { + return; + } if (onLibwebrtcThread) { std::thread(std::forward(release)).detach(); } else { @@ -52,9 +59,18 @@ namespace python_webrtc { // Deletes wrappers whose destructors block on libwebrtc threads off those threads struct DeleteOffLibwebrtcThread { + // of the process the wrapper was created in (see forks) + int generation = forks.load(); + template void operator()(T *dying) const { - ReleaseOffLibwebrtcThread([dying]() { delete dying; }); + ReleaseOffLibwebrtcThread( + [dying]() { + // members too are released without it, like proxies destroyed on their thread + gil_release_if_held release; + delete dying; + }, + generation); } }; diff --git a/python-webrtc/cpp/src/utils/listeners.h b/python-webrtc/cpp/src/utils/listeners.h index 026215b..ed3636a 100644 --- a/python-webrtc/cpp/src/utils/listeners.h +++ b/python-webrtc/cpp/src/utils/listeners.h @@ -42,9 +42,12 @@ namespace python_webrtc { void SetListeners(pybind11::object listeners) { _listeners = listeners.is_none() ? pybind11::object() : std::move(listeners); _active = static_cast(_listeners); - // events held until Python had the object are delivered now - if (_active && IsHeld()) { - Release(); + if (_active) { + // held events are delivered now, without the GIL: a thread releasing them holds their lock waiting for it + pybind11::gil_scoped_release release; + if (IsHeld()) { + Release(); + } } } diff --git a/python-webrtc/python/webrtc/functions/get_user_media.py b/python-webrtc/python/webrtc/functions/get_user_media.py index 711d120..6521ce3 100644 --- a/python-webrtc/python/webrtc/functions/get_user_media.py +++ b/python-webrtc/python/webrtc/functions/get_user_media.py @@ -7,8 +7,8 @@ from typing import TYPE_CHECKING, Dict, Optional, Union -from webrtc import MediaStream, MediaTrackConstraints, wrtc -from webrtc.interfaces.media_stream_track import _selected +from webrtc import MediaStream, MediaTrackConstraints, MediaTrackSettings, OverconstrainedError, wrtc +from webrtc.interfaces.media_stream_track import _CAMERA_CAPABILITIES, _check_numbers, _selected, _unsatisfied if TYPE_CHECKING: import webrtc @@ -44,16 +44,25 @@ def get_user_media( :obj:`webrtc.MediaStream`: The stream. Raises: - :obj:`TypeError`: If neither audio nor video is requested. - :obj:`ValueError`: If the size or the frame rate of the video isn't positive. + :obj:`TypeError`: If neither audio nor video is requested, or a value isn't a finite number (negative for + the size). + :obj:`webrtc.OverconstrainedError`: If a required value (``exact``, ``min``, ``max``) is beyond what the + camera can do: 1 to 4096 pixels wide and high, 1 to 120 frames per second. Other values are brought + within that. """ if not audio and not video: raise TypeError('audio or video must be requested') constraints = MediaTrackConstraints(width=width, height=height, frame_rate=frame_rate) - # the defaults of a camera, within the range of a constraint that has neither an exact nor an ideal value - width, height, frame_rate = _selected(width, 640), _selected(height, 480), _selected(frame_rate, 30.0) - if video and (width <= 0 or height <= 0 or frame_rate <= 0): - raise ValueError('the size and the frame rate of the video must be positive') + _check_numbers(constraints) + if video: + failed = _unsatisfied(constraints, _CAMERA_CAPABILITIES, MediaTrackSettings()) + if failed is not None: + raise OverconstrainedError(failed, f"The constraint {failed} can't be satisfied") + # the camera's defaults, within the constraints and the camera's capabilities + capabilities = _CAMERA_CAPABILITIES + width = _selected(width, 640, capabilities.width) + height = _selected(height, 480, capabilities.height) + frame_rate = _selected(frame_rate, 30.0, capabilities.frame_rate) stream = MediaStream._wrap(wrtc.getUserMedia(bool(audio), bool(video), width, height, float(frame_rate))) for track in stream.get_video_tracks(): track._native_obj._constraints = constraints diff --git a/python-webrtc/python/webrtc/interfaces/media_stream.py b/python-webrtc/python/webrtc/interfaces/media_stream.py index 49da222..d433ab1 100644 --- a/python-webrtc/python/webrtc/interfaces/media_stream.py +++ b/python-webrtc/python/webrtc/interfaces/media_stream.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, List, Optional, Union -from webrtc import MediaStreamTrack, MediaStreamTrackEvent, WebRTCObject, wrtc +from webrtc import MediaStreamTrack, MediaStreamTrackEvent, MediaType, WebRTCObject, wrtc from webrtc.utils.events import EventTarget if TYPE_CHECKING: @@ -34,6 +34,22 @@ def __init__(self, tracks: Optional[Union[List['webrtc.MediaStreamTrack'], 'webr if isinstance(tracks, MediaStream): tracks = tracks.get_tracks() super().__init__(self._class.create([track._native_obj for track in tracks or []])) + self._keep_tracks() + + @classmethod + def _wrap(cls, item) -> 'MediaStream': + stream = super()._wrap(item) + stream._keep_tracks() + return stream + + def _keep_tracks(self) -> list: + """The native tracks, kept here: the native stream keeps them weakly""" + self._tracks = self._native_obj.getTracks() + return self._tracks + + def _on_event(self, name: str, *args): + if name in ('addtrack', 'removetrack'): + self._keep_tracks() def _create_event(self, name: str, *args): (track,) = args @@ -55,40 +71,44 @@ def get_audio_tracks(self) -> List['webrtc.MediaStreamTrack']: stored in the :obj:`webrtc.MediaStream` object that have their kind attribute set to "audio". The order is not defined, and may not only vary from one machine to another, but also from one call to another. """ - return MediaStreamTrack._wrap_many(self._native_obj.getAudioTracks()) + return MediaStreamTrack._wrap_many([t for t in self._keep_tracks() if t.kind == MediaType.audio]) def get_video_tracks(self) -> List['webrtc.MediaStreamTrack']: """Returns a :obj:`list` of the :obj:`webrtc.MediaStreamTrack` objects stored in the :obj:`webrtc.MediaStream` object that have their kind attribute set to "video". The order is not defined, and may not only vary from one machine to another, but also from one call to another. """ - return MediaStreamTrack._wrap_many(self._native_obj.getVideoTracks()) + return MediaStreamTrack._wrap_many([t for t in self._keep_tracks() if t.kind == MediaType.video]) def get_tracks(self) -> List['webrtc.MediaStreamTrack']: """Returns a :obj:`list` of all :obj:`webrtc.MediaStreamTrack` objects stored in the :obj:`webrtc.MediaStream` object, regardless of the value of the kind attribute. The order is not defined, and may not only vary from one machine to another, but also from one call to another. """ - return MediaStreamTrack._wrap_many(self._native_obj.getTracks()) + return MediaStreamTrack._wrap_many(self._keep_tracks()) def get_track_by_id(self, track_id: str) -> Optional['webrtc.MediaStreamTrack']: """Returns the track whose ID corresponds to the one given in parameters, :obj:`track_id`. If no track with that ID does exist, it returns :obj:`None`. If several tracks have the same ID, it returns the first one. """ - return MediaStreamTrack._wrap_optional(self._native_obj.getTrackById(track_id)) + track = self._native_obj.getTrackById(track_id) + self._keep_tracks() + return MediaStreamTrack._wrap_optional(track) def add_track(self, track: 'webrtc.MediaStreamTrack'): """Stores a copy of the :obj:`webrtc.MediaStreamTrack` given as argument. If the track has already been added to the :obj:`webrtc.MediaStream` object, nothing happens. """ - return self._native_obj.addTrack(track._native_obj) + self._native_obj.addTrack(track._native_obj) + self._keep_tracks() def remove_track(self, track: 'webrtc.MediaStreamTrack'): """Removes the :obj:`webrtc.MediaStreamTrack` given as argument. If the track is not part of the :obj:`webrtc.MediaStream` object, nothing happens. """ - return self._native_obj.removeTrack(track._native_obj) + self._native_obj.removeTrack(track._native_obj) + self._keep_tracks() def clone(self) -> 'webrtc.MediaStream': """Returns a clone of the :obj:`webrtc.MediaStream` object. diff --git a/python-webrtc/python/webrtc/interfaces/media_stream_track.py b/python-webrtc/python/webrtc/interfaces/media_stream_track.py index d2c6288..70319a9 100644 --- a/python-webrtc/python/webrtc/interfaces/media_stream_track.py +++ b/python-webrtc/python/webrtc/interfaces/media_stream_track.py @@ -6,6 +6,7 @@ # import asyncio +import math from typing import TYPE_CHECKING, Any, Dict, Optional from webrtc import ( @@ -97,21 +98,47 @@ def _satisfied(value: Any, capability: Any, current: Any) -> bool: return high is None or current <= high -def _selected(value: Any, current: float) -> float: - """The value a constraint selects: the exact or ideal one, otherwise the current one within the range""" +def _selected(value: Any, current: float, capability: Any = None) -> float: + """The value a constraint selects (exact, ideal or current), the nearest within its range and the capability""" + low, high = float('-inf'), float('inf') + if isinstance(capability, (ULongRange, DoubleRange)): + low = capability.min if capability.min is not None else low + high = capability.max if capability.max is not None else high if value is None: - return current - if not isinstance(value, dict): - return value - if value.get('exact') is not None: - return value['exact'] - if value.get('ideal') is not None: - return value['ideal'] - if value.get('max') is not None: - current = min(current, value['max']) - if value.get('min') is not None: - current = max(current, value['min']) - return current + selected = current + elif not isinstance(value, dict): + selected = value + elif value.get('exact') is not None: + selected = value['exact'] + else: + low = max(low, value['min']) if value.get('min') is not None else low + high = min(high, value['max']) if value.get('max') is not None else high + selected = value['ideal'] if value.get('ideal') is not None else current + return min(max(selected, low), high) + + +# the members of constraints that are numbers: unsigned longs, and restricted doubles +_ULONG_CONSTRAINTS = ('width', 'height', 'sample_rate', 'sample_size', 'channel_count') +_DOUBLE_CONSTRAINTS = ('aspect_ratio', 'frame_rate') + + +def _check_numbers(constraint_set: MediaTrackConstraints) -> None: + """The WebIDL types of the numbers of a constraint set: finite, and not negative for unsigned longs""" + for name in _ULONG_CONSTRAINTS + _DOUBLE_CONSTRAINTS: + value = getattr(constraint_set, name) + members = [value.get(key) for key in ('exact', 'ideal', 'min', 'max')] if isinstance(value, dict) else [value] + for member in members: + if member is None: + continue + unsigned = name in _ULONG_CONSTRAINTS + if ( + isinstance(member, bool) + or not isinstance(member, (int, float)) + or not math.isfinite(member) + or (unsigned and member < 0) + ): + kind = 'a finite number that is not negative' if unsigned else 'a finite number' + raise TypeError(f'{name} must be {kind}, not {member!r}') def _unsatisfied( @@ -266,6 +293,9 @@ def apply_constraints(self, constraints: Optional[Any] = None) -> asyncio.Future return future def _apply_constraints(self, constraints: MediaTrackConstraints) -> None: + advanced = [MediaTrackConstraints._parse(constraint_set) for constraint_set in constraints.advanced or ()] + for constraint_set in [constraints, *advanced]: + _check_numbers(constraint_set) if self.ready_state == 'ended': return capabilities = self.get_capabilities() @@ -277,13 +307,12 @@ def _apply_constraints(self, constraints: MediaTrackConstraints) -> None: camera = self._native_obj._camera() if camera is not None: width, height, frame_rate = camera - advanced = [MediaTrackConstraints._parse(constraint_set) for constraint_set in constraints.advanced or ()] # the advanced sets that can be satisfied apply in order after the basic one satisfiable = [c for c in advanced if _unsatisfied(c, capabilities, settings) is None] for constraint_set in [constraints, *satisfiable]: - width = _selected(constraint_set.width, width) - height = _selected(constraint_set.height, height) - frame_rate = _selected(constraint_set.frame_rate, frame_rate) + width = _selected(constraint_set.width, width, capabilities.width) + height = _selected(constraint_set.height, height, capabilities.height) + frame_rate = _selected(constraint_set.frame_rate, frame_rate, capabilities.frame_rate) if (width, height, frame_rate) != camera: self._native_obj._reconfigureCamera(int(width), int(height), float(frame_rate)) self._native_obj._constraints = constraints diff --git a/python-webrtc/python/webrtc/interfaces/media_stream_track_processor.py b/python-webrtc/python/webrtc/interfaces/media_stream_track_processor.py index abdff5d..d32905d 100644 --- a/python-webrtc/python/webrtc/interfaces/media_stream_track_processor.py +++ b/python-webrtc/python/webrtc/interfaces/media_stream_track_processor.py @@ -138,6 +138,8 @@ def __init__( raise TypeError(f'max_buffer_size must be from 0 to 65535, not {max_buffer_size}') super().__init__(self._class(track._native_obj, max(1, max_buffer_size))) + # the native processor doesn't keep the track, Python does + self._track = track self._video = video self._source = _TrackSource(self) self._readable = ReadableStream(self._source, high_water_mark=0) diff --git a/python-webrtc/python/webrtc/interfaces/rtc_peer_connection.py b/python-webrtc/python/webrtc/interfaces/rtc_peer_connection.py index 5f06538..56dc67e 100644 --- a/python-webrtc/python/webrtc/interfaces/rtc_peer_connection.py +++ b/python-webrtc/python/webrtc/interfaces/rtc_peer_connection.py @@ -6,8 +6,9 @@ # import asyncio +import dataclasses import re -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union +from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Union from webrtc import ( Event, @@ -22,6 +23,7 @@ RTCPeerConnectionIceErrorEvent, RTCPeerConnectionIceEvent, RTCRtpCodec, + RTCRtpEncodingParameters, RTCSdpType, RTCSessionDescription, RTCSessionDescriptionInit, @@ -35,6 +37,7 @@ ) from webrtc.utils.callbacks_to_async import to_async from webrtc.utils.events import EventTarget +from webrtc.utils.names import snake_case from webrtc.utils.operations import OperationsChain, later from webrtc.utils.task_queue import TaskQueue @@ -411,7 +414,7 @@ def add_track( def add_transceiver( self, track_or_kind: Union['webrtc.MediaStreamTrack', 'webrtc.MediaType'], - init: Optional['webrtc.RtpTransceiverInit'] = None, + init: Optional[Union['webrtc.RtpTransceiverInit', Dict[str, Any]]] = None, ) -> 'webrtc.RTCRtpTransceiver': """Creates a new :obj:`webrtc.RTCRtpTransceiver` and adds it to the set of transceivers associated with the connection. Each transceiver represents a bidirectional stream, with both an :obj:`webrtc.RTCRtpSender` and @@ -422,8 +425,9 @@ def add_transceiver( :obj:`webrtc.MediaStreamTrack` to associate with the transceiver, or :attr:`webrtc.MediaType.audio` or :attr:`webrtc.MediaType.video` (or its value), which is used as the kind of the receiver's track, and by extension of the :obj:`webrtc.RTCRtpReceiver` itself. - init (:obj:`webrtc.RtpTransceiverInit`, optional): An object for specifying any options when creating - the new transceiver. It isn't changed. + init (:obj:`webrtc.RtpTransceiverInit` or :obj:`dict`, optional): An object for specifying any options + when creating the new transceiver, or a dictionary of its members (the encodings may be dictionaries + too). It isn't changed. Returns: :obj:`webrtc.RTCRtpTransceiver`: The new transceiver. @@ -440,6 +444,8 @@ def add_transceiver( if kind not in (MediaType.audio, MediaType.video): raise TypeError(f'{kind!r} is not a kind of track') native_init = None + if isinstance(init, dict): + init = _transceiver_init(init) if init is not None: _check_send_encodings(init.send_encodings, kind) # a copy, with the encodings for the kind @@ -832,6 +838,24 @@ def _init_of(description: 'wrtc.RTCSessionDescription') -> 'webrtc.RTCSessionDes return RTCSessionDescriptionInit(description.type, description.sdp) +def _members(value: Dict[str, Any], names: Iterable[str]) -> Dict[str, Any]: + """The members of a dictionary, with snake_case or camelCase names: unknown ones are ignored, as in WebIDL""" + members = {snake_case(name): member for name, member in value.items()} + return {name: member for name, member in members.items() if name in names} + + +def _transceiver_init(init: Dict[str, Any]) -> RtpTransceiverInit: + """An init from a dictionary, as in browsers, with its encodings dictionaries too""" + members = _members(init, ('direction', 'send_encodings', 'streams')) + encodings = members.get('send_encodings') + if encodings is not None: + names = [field.name for field in dataclasses.fields(RTCRtpEncodingParameters)] + members['send_encodings'] = [ + RTCRtpEncodingParameters(**_members(e, names)) if isinstance(e, dict) else e for e in encodings + ] + return RtpTransceiverInit(**members) + + def _check_send_encodings(encodings: List['webrtc.RTCRtpEncodingParameters'], kind: 'webrtc.MediaType') -> None: """Validates the send encodings of a new transceiver, as the specification requires.""" from webrtc import RTCRtpSender diff --git a/python-webrtc/python/webrtc/interfaces/track_generator.py b/python-webrtc/python/webrtc/interfaces/track_generator.py index b511823..f0292f5 100644 --- a/python-webrtc/python/webrtc/interfaces/track_generator.py +++ b/python-webrtc/python/webrtc/interfaces/track_generator.py @@ -11,6 +11,7 @@ from typing import Any, Union from webrtc import AudioData, AudioSampleFormat, MediaStreamTrack, MediaType, VideoFrame, wrtc +from webrtc.exceptions import NotSupportedError from webrtc.streams import WritableStream @@ -46,8 +47,14 @@ def _write_audio(self, data: Any) -> None: samples = bytearray(audio.number_of_frames * audio.number_of_channels * 2) audio.copy_to(samples, {'plane_index': 0, 'format': AudioSampleFormat.s16}) samples = bytes(samples) - self._native.writeAudio(samples, int(audio.sample_rate), audio.number_of_channels, audio.number_of_frames) - audio.close() + # rates beyond an int are unsupported too: the native check rejects them + rate = min(int(audio.sample_rate), 2**31 - 1) + try: + self._native.writeAudio(samples, rate, audio.number_of_channels, audio.number_of_frames) + except ValueError as e: + raise NotSupportedError(str(e)) from None + finally: + audio.close() def close(self) -> None: # ends the tracks of the generator @@ -73,12 +80,14 @@ class VideoTrackGenerator: def __init__(self): self._native = wrtc.TrackGenerator('video') + # the native generator doesn't keep the track, Python does + self._track = MediaStreamTrack._wrap(self._native.track) self._writable = WritableStream(_TrackSink(self._native)) @property def track(self) -> MediaStreamTrack: """:obj:`webrtc.MediaStreamTrack`: The track of the frames.""" - return MediaStreamTrack._wrap(self._native.track) + return self._track @property def writable(self) -> WritableStream: diff --git a/python-webrtc/python/webrtc/models/audio_data.py b/python-webrtc/python/webrtc/models/audio_data.py index 88855c5..1860364 100644 --- a/python-webrtc/python/webrtc/models/audio_data.py +++ b/python-webrtc/python/webrtc/models/audio_data.py @@ -7,6 +7,7 @@ """AudioData of WebCodecs (https://developer.mozilla.org/en-US/docs/Web/API/AudioData) and its dictionaries.""" +import math import warnings from dataclasses import dataclass from typing import Any, NamedTuple, Optional, Union @@ -146,8 +147,8 @@ def __init__(self, init: Any = None, **options): format = _sample_format(init.format) sample_rate = init.sample_rate - if isinstance(sample_rate, bool) or not isinstance(sample_rate, (int, float)) or not sample_rate > 0: - raise TypeError('sample_rate must be positive') + if isinstance(sample_rate, bool) or not isinstance(sample_rate, (int, float)) or not 0 < sample_rate < math.inf: + raise TypeError('sample_rate must be positive and finite') frames = _unsigned(init.number_of_frames, 'number_of_frames') channels = _unsigned(init.number_of_channels, 'number_of_channels') if frames == 0 or channels == 0: diff --git a/python-webrtc/python/webrtc/models/rtp_parameters.py b/python-webrtc/python/webrtc/models/rtp_parameters.py index e0b3eb2..943631a 100644 --- a/python-webrtc/python/webrtc/models/rtp_parameters.py +++ b/python-webrtc/python/webrtc/models/rtp_parameters.py @@ -8,6 +8,7 @@ """RTP parameters and capabilities of senders, receivers and transceivers.""" import dataclasses +import math from dataclasses import dataclass, field from typing import Any, Dict, List, Optional @@ -225,8 +226,18 @@ def _for_kind(self, kind: MediaType) -> 'RTCRtpEncodingParameters': def _apply(self, native: 'wrtc.RtpEncodingParameters') -> 'wrtc.RtpEncodingParameters': """:meth:`_to_native` into an existing native encoding: sets the members that can be changed.""" + # the WebIDL types: an [EnforceRange] unsigned long and restricted doubles + bitrate = self.max_bitrate + if bitrate is not None and ( + isinstance(bitrate, bool) or not isinstance(bitrate, int) or not 0 <= bitrate < 2**32 + ): + raise TypeError(f'max_bitrate must be an unsigned 32-bit integer, not {bitrate!r}') + for name in ('max_framerate', 'scale_resolution_down_by'): + value = getattr(self, name) + if value is not None and (not isinstance(value, (int, float)) or not math.isfinite(value)): + raise TypeError(f'{name} must be a finite number, not {value!r}') native.active = bool(self.active) - native.maxBitrate = self.max_bitrate + native.maxBitrate = min(bitrate, 2**31 - 1) if bitrate is not None else None native.maxFramerate = self.max_framerate native.scaleResolutionDownBy = self.scale_resolution_down_by native.bitratePriority = _BITRATE_PRIORITY[RTCPriorityType(self.priority)] diff --git a/python-webrtc/python/webrtc/streams.py b/python-webrtc/python/webrtc/streams.py index 7fc3be1..3fadf18 100644 --- a/python-webrtc/python/webrtc/streams.py +++ b/python-webrtc/python/webrtc/streams.py @@ -13,7 +13,7 @@ import collections import inspect from dataclasses import dataclass -from typing import Any, AsyncIterator, Callable, Deque, Optional, Tuple +from typing import Any, AsyncIterator, Callable, Deque, Optional, Set, Tuple __all__ = [ 'ReadableStream', @@ -45,6 +45,10 @@ def _resolved(value: Any = None) -> asyncio.Future: return future +# pipes running, see ReadableStream.pipe_to +_running_pipes: Set[asyncio.Future] = set() + + def _rejected(error: BaseException) -> asyncio.Future: future = _pending() future.set_exception(error) @@ -82,9 +86,16 @@ def _reason_error(reason: Any) -> BaseException: return reason if isinstance(reason, BaseException) else TypeError(str(reason)) +def _member(obj: Any, name: str) -> Any: + """A method of an underlying source, sink or transformer: an object, or a dictionary as in browsers""" + if isinstance(obj, dict): + return obj.get(name) + return getattr(obj, name, None) if obj is not None else None + + def _call(obj: Any, name: str, *args) -> Any: """Calls a method of an underlying source, sink or transformer, if it has one.""" - method = getattr(obj, name, None) if obj is not None else None + method = _member(obj, name) return method(*args) if method is not None else None @@ -246,7 +257,7 @@ class ReadableStream: Args: underlying_source (optional): An object with optional ``start(controller)``, ``pull(controller)`` and - ``cancel(reason)`` methods, which may be coroutine functions. + ``cancel(reason)`` methods (or a :obj:`dict` of them), which may be coroutine functions. high_water_mark (:obj:`float`, optional): How many chunks are queued ahead of reads, 1 by default. """ @@ -302,7 +313,11 @@ def pipe_to( return _rejected(TypeError('A stream is locked')) reader = self.get_reader() writer = destination.get_writer() - return asyncio.ensure_future(self._pipe(reader, writer, prevent_close, prevent_abort, prevent_cancel)) + pipe = asyncio.ensure_future(self._pipe(reader, writer, prevent_close, prevent_abort, prevent_cancel)) + # kept until done, as in browsers: asyncio keeps tasks weakly + _running_pipes.add(pipe) + pipe.add_done_callback(_running_pipes.discard) + return pipe @staticmethod async def _pipe( @@ -322,6 +337,9 @@ async def _pipe( return # writes aren't awaited, like in the specification _handled(writer.write(result.value)) + except GeneratorExit: + # closed, at exit: nothing can be awaited anymore + raise except BaseException as e: if writer._stream._state in ('erroring', 'errored'): if not prevent_cancel: @@ -581,7 +599,7 @@ class WritableStream: Args: underlying_sink (optional): An object with optional ``start(controller)``, ``write(chunk, controller)``, - ``close()`` and ``abort(reason)`` methods, which may be coroutine functions. + ``close()`` and ``abort(reason)`` methods (or a :obj:`dict` of them), which may be coroutine functions. high_water_mark (:obj:`float`, optional): How many chunks are queued until writers see backpressure, 1 by default. """ @@ -824,8 +842,8 @@ class TransformStream: Args: transformer (optional): An object with optional ``start(controller)``, ``transform(chunk, controller)`` and - ``flush(controller)`` methods, which may be coroutine functions. Chunks pass unchanged without - ``transform``. + ``flush(controller)`` methods (or a :obj:`dict` of them), which may be coroutine functions. Chunks pass + unchanged without ``transform``. """ def __init__(self, transformer: Any = None): @@ -863,7 +881,7 @@ async def write(self, chunk, controller): ): stream._pull_waiter = _pending() await stream._pull_waiter - transform = getattr(stream._transformer, 'transform', None) + transform = _member(stream._transformer, 'transform') if transform is None: stream._controller.enqueue(chunk) else: diff --git a/python-webrtc/python/webrtc/utils/events.py b/python-webrtc/python/webrtc/utils/events.py index a85a612..3d08298 100644 --- a/python-webrtc/python/webrtc/utils/events.py +++ b/python-webrtc/python/webrtc/utils/events.py @@ -51,7 +51,13 @@ def __call__(self, name: str, *args): if registrations is None: # the garbage collector cleared this object (in a cycle with its target) before the native one let go return - loops = [self.primary_loop] if self.primary_loop else [] + primary_loop = self.primary_loop + if primary_loop is not None and primary_loop.is_closed(): + # used from another loop since (like another asyncio.run): a handler's open loop takes over, on copies + primary_loop = self.primary_loop = next( + (r.loop for regs in list(registrations.values()) for r in list(regs) if not r.loop.is_closed()), None + ) + loops = [primary_loop] if primary_loop else [] for registration in registrations.get(name, ()): if registration.loop not in loops: loops.append(registration.loop) @@ -62,7 +68,7 @@ def __call__(self, name: str, *args): def ensure_primary_loop(self) -> Optional[asyncio.AbstractEventLoop]: """Makes the running loop the primary one if there's none yet. Returns the running loop, if any.""" loop = _running_loop() - if loop is not None and self.primary_loop is None: + if loop is not None and (self.primary_loop is None or self.primary_loop.is_closed()): self.primary_loop = loop return loop diff --git a/python-webrtc/python/webrtc/utils/task_queue.py b/python-webrtc/python/webrtc/utils/task_queue.py index bed5dfa..916a3fb 100644 --- a/python-webrtc/python/webrtc/utils/task_queue.py +++ b/python-webrtc/python/webrtc/utils/task_queue.py @@ -28,16 +28,23 @@ class TaskQueue: #: How many callbacks run in one iteration of the loop, when they schedule nothing MAX_BATCH = 100 + #: The queues of the loops without a ``__dict__`` (like uvloop's), the others keep their own _queues: 'weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, TaskQueue]' = weakref.WeakKeyDictionary() + _ATTRIBUTE = '_webrtc_task_queue' def __init__(self, loop: asyncio.AbstractEventLoop): - self._loop = loop + # weakly: a queue in _queues referencing its loop would keep it forever + self._loop_ref = weakref.ref(loop) self._items = collections.deque() self._lock = threading.Lock() self._scheduled = False # whether the last callback resumed code (like a coroutine awaiting a result) that runs before the next one self._resumed = False + @property + def _loop(self) -> asyncio.AbstractEventLoop: + return self._loop_ref() + @classmethod def of(cls, loop: asyncio.AbstractEventLoop) -> 'TaskQueue': """Returns the queue of a loop. @@ -48,9 +55,20 @@ def of(cls, loop: asyncio.AbstractEventLoop) -> 'TaskQueue': Returns: :obj:`TaskQueue`: Its queue, created on first use. """ - # No lock: an allocation under it may run the garbage collector, setdefault is atomic + # kept by the loop, so a closed loop is collected with what's still queued; setdefault is atomic + try: + attributes = vars(loop) + except TypeError: + attributes = None + if attributes is not None: + queue = attributes.get(cls._ATTRIBUTE) + return queue if queue is not None else attributes.setdefault(cls._ATTRIBUTE, cls(loop)) queue = cls._queues.get(loop) if queue is None: + # the loops closed meanwhile won't run what's queued + for other in list(cls._queues): + if other.is_closed(): + cls._queues.pop(other)._items.clear() queue = cls._queues.setdefault(loop, cls(loop)) return queue @@ -87,10 +105,13 @@ def post(self, callback: Callable, *args, resumes: bool = False, after_ready: bo if self._scheduled: return self._scheduled = True + loop = self._loop try: - self._loop.call_soon_threadsafe(self._run) - except RuntimeError: # the loop is closed - pass + if loop is None: + raise RuntimeError('the loop is gone') + loop.call_soon_threadsafe(self._run) + except RuntimeError: # the loop is closed: nothing will run what's queued, nor release it + self._items.clear() def _others_ready(self) -> bool: """Whether the loop has callbacks ready that are like microtasks: the steps of coroutines and callbacks diff --git a/stubs/wrtc/__init__.pyi b/stubs/wrtc/__init__.pyi index 72176a6..1a93513 100644 --- a/stubs/wrtc/__init__.pyi +++ b/stubs/wrtc/__init__.pyi @@ -705,6 +705,8 @@ class TrackGenerator: @property def track(self) -> MediaStreamTrack: ... +def _alive() -> dict[str, int]: + ... def _alive_factories() -> int: ... def copyAudioSamples(source: collections.abc.Buffer, sourceFormat: str, channels: typing.SupportsInt | typing.SupportsIndex, frames: typing.SupportsInt | typing.SupportsIndex, destination: collections.abc.Buffer, destinationFormat: str, planeIndex: typing.SupportsInt | typing.SupportsIndex, frameOffset: typing.SupportsInt | typing.SupportsIndex, frameCount: typing.SupportsInt | typing.SupportsIndex) -> None: @@ -713,3 +715,4 @@ def getUserMedia(audio: bool, video: bool, width: typing.SupportsInt | typing.Su ... def ping() -> None: ... +_sanitized: bool = False diff --git a/tests/chaos.py b/tests/chaos.py new file mode 100644 index 0000000..7f8be27 --- /dev/null +++ b/tests/chaos.py @@ -0,0 +1,344 @@ +# +# Copyright 2026 Ilya (Marshal) . All rights reserved. +# +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE.md file in the root of the project. +# + +"""Random sequences of API calls, to find crashes, deadlocks and leaks. Every step is printed before it runs, so the +output of a crash names the sequence; the same seed replays it. + + python -m tests.chaos --seed 7 --steps 500 +""" + +import argparse +import asyncio +import gc +import random +import sys +import threading +import time + +import webrtc +import wrtc +from tests.helpers import connect + +#: How long one step may take: longer is a deadlock +STEP_TIMEOUT = 20 + + +class Chaos: + def __init__(self, seed: int): + self.random = random.Random(seed) + self.connections = [] + self.channels = [] + self.tracks = [] + self.processors = [] + self.generators = [] + self.frames = [] + self.tasks = [] + + def pick(self, pool): + return self.random.choice(pool) if pool else None + + def drop(self, pool): + if pool: + pool.pop(self.random.randrange(len(pool))) + + def handler(self): + """A handler doing something to a random object: closing, raising, referencing (a cycle), collecting""" + target = self.pick(self.connections + self.channels + self.tracks) + action = self.random.randrange(5) + + def handle(event): + if action == 0 and target is not None: + target.close() if hasattr(target, 'close') else target.stop() + elif action == 1: + raise RuntimeError('a handler raises') + elif action == 2: + gc.collect() + elif action == 3: + return repr(target) + + return handle + + # the steps, each with the objects it works on + + async def new_connection(self): + self.connections.append(webrtc.RTCPeerConnection()) + + async def close_connection(self): + pc = self.pick(self.connections) + if pc: + pc.close() + + async def drop_connection(self): + self.drop(self.connections) + + async def connect_two(self): + if len(self.connections) >= 2: + a, b = self.random.sample(self.connections, 2) + await connect(a, b, timeout=5) + + async def add_track(self): + pc, track = self.pick(self.connections), self.pick(self.tracks) + if pc and track: + pc.add_track(track) + + async def remove_track(self): + pc = self.pick(self.connections) + if pc and pc.get_senders(): + pc.remove_track(self.random.choice(pc.get_senders())) + + async def add_transceiver(self): + pc = self.pick(self.connections) + if pc: + transceiver = pc.add_transceiver(self.random.choice(['audio', 'video'])) + if self.random.random() < 0.3: + transceiver.stop() + elif self.random.random() < 0.3: + transceiver.direction = self.random.choice(list(webrtc.TransceiverDirection)[:4]) + + async def negotiate(self): + pc = self.pick(self.connections) + if pc: + await pc.set_local_description() + + async def create_channel(self): + pc = self.pick(self.connections) + if pc: + channel = pc.create_data_channel(f'chaos{self.random.randrange(1000)}') + channel.on(self.random.choice(['open', 'message', 'close']), self.handler()) + self.channels.append(channel) + + async def send(self): + channel = self.pick(self.channels) + if channel: + channel.send(self.random.choice(['text', b'\x00' * self.random.randrange(70000), bytearray(10)])) + + async def close_channel(self): + channel = self.pick(self.channels) + if channel: + channel.close() + + async def stats(self): + pc = self.pick(self.connections) + if pc: + await pc.get_stats() + + async def restart_ice(self): + pc = self.pick(self.connections) + if pc: + pc.restart_ice() + + async def handle_connection_event(self): + pc = self.pick(self.connections) + if pc: + pc.on(self.random.choice(['connectionstatechange', 'icecandidate', 'track', 'datachannel']), self.handler()) + + async def get_user_media(self): + self.tracks.extend(webrtc.get_user_media(audio=True, video=True).get_tracks()) + + async def stop_track(self): + track = self.pick(self.tracks) + if track: + track.stop() + + async def clone_track(self): + track = self.pick(self.tracks) + if track: + self.tracks.append(track.clone()) + + async def toggle_track(self): + track = self.pick(self.tracks) + if track: + track.enabled = not track.enabled + + async def drop_track(self): + self.drop(self.tracks) + + async def new_processor(self): + track = self.pick(self.tracks) + if track: + processor = webrtc.MediaStreamTrackProcessor(track, max_buffer_size=self.random.randrange(4)) + track.on('ended', self.handler()) + self.processors.append((processor, processor.readable.get_reader())) + + async def read(self): + if self.processors: + _, reader = self.pick(self.processors) + try: + result = await asyncio.wait_for(reader.read(), 0.2) + except asyncio.TimeoutError: + return + if not result.done: + result.value.close() + + async def cancel_processor(self): + if self.processors: + _, reader = self.pick(self.processors) + await reader.cancel() + + async def drop_processor(self): + self.drop(self.processors) + + async def new_generator(self): + if self.random.random() < 0.5: + generator = webrtc.VideoTrackGenerator() + self.generators.append((generator.writable.get_writer(), 'video')) + self.tracks.append(generator.track) + else: + generator = webrtc.MediaStreamTrackGenerator('audio') + self.generators.append((generator.writable.get_writer(), 'audio')) + self.tracks.append(generator) + + async def write(self): + if not self.generators: + return + writer, kind = self.pick(self.generators) + if kind == 'video': + width, height = self.random.choice([(2, 2), (33, 17), (320, 240)]) + chunk = webrtc.VideoFrame( + bytes(width * height * 4), format='RGBA', coded_width=width, coded_height=height, timestamp=0 + ) + else: + rate, channels = self.random.choice([(48000, 2), (8000, 1), (44100, 1), (1000, 1), (48000, 20)]) + frames = rate // 100 + chunk = webrtc.AudioData( + format='s16', + sample_rate=rate, + number_of_frames=frames, + number_of_channels=channels, + timestamp=0, + data=bytes(frames * channels * 2), + ) + await writer.write(chunk) + + async def close_generator(self): + if self.generators: + writer, _ = self.pick(self.generators) + await writer.close() + + async def drop_generator(self): + self.drop(self.generators) + + async def frame(self): + fmt = self.random.choice(list(webrtc.VideoPixelFormat)) + width, height = self.random.randrange(1, 40), self.random.randrange(1, 40) + frame = webrtc.VideoFrame( + bytes(width * height * 8), format=fmt, coded_width=width, coded_height=height, timestamp=0 + ) + options = self.random.choice([None, {'format': 'RGBA'}, {'format': 'BGRX'}]) + await frame.copy_to(bytearray(frame.allocation_size(options)), options) + self.frames.append(frame) + + async def use_frame(self): + frame = self.pick(self.frames) + if frame: + self.random.choice([frame.close, lambda: self.frames.append(frame.clone())])() + + async def replace_track(self): + pc = self.pick(self.connections) + if pc and pc.get_senders(): + await self.random.choice(pc.get_senders()).replace_track(self.pick(self.tracks + [None])) + + async def set_parameters(self): + pc = self.pick(self.connections) + if pc and pc.get_senders(): + sender = self.random.choice(pc.get_senders()) + parameters = sender.get_parameters() + for encoding in parameters.encodings: + encoding.active = self.random.random() < 0.8 + encoding.max_bitrate = self.random.choice([None, 30000, 2**31]) + await sender.set_parameters(parameters) + + async def pipe(self): + track = self.pick([track for track in self.tracks if track.kind == 'video']) + if track: + processor = webrtc.MediaStreamTrackProcessor(track) + generator = webrtc.VideoTrackGenerator() + self.tracks.append(generator.track) + self.tasks.append(processor.readable.pipe_through(webrtc.TransformStream()).pipe_to(generator.writable)) + + async def constraints(self): + track = self.pick(self.tracks) + if track: + track.get_settings() + track.apply_constraints(self.random.choice([{'width': 320}, {'frame_rate': 5}, {'width': {'exact': 7}}])) + + async def stream(self): + tracks = self.random.sample(self.tracks, min(len(self.tracks), 2)) + stream = webrtc.MediaStream(tracks) + if tracks and self.random.random() < 0.5: + stream.remove_track(tracks[0]) + stream.get_tracks() + + async def reader_thread(self): + """A thread reading the objects while the loop goes on""" + connections, tracks = list(self.connections), list(self.tracks) + + def read(): + for _ in range(50): + for pc in connections: + _ = pc.connection_state, pc.get_transceivers(), pc.sctp + for track in tracks: + _ = track.ready_state, track.muted, track.enabled + + thread = threading.Thread(target=read, daemon=True) + thread.start() + self.tasks.append(thread) + + async def collect(self): + gc.collect() + + async def pause(self): + await asyncio.sleep(self.random.random() * 0.05) + + STEPS = [name for name in dir() if not name.startswith('_') and name not in ('pick', 'drop', 'handler')] + + async def run(self, steps: int): + loop = asyncio.get_running_loop() + # handlers raise on purpose + loop.set_exception_handler(lambda loop, context: None) + for index in range(steps): + name = self.random.choice(self.STEPS) + print(f'{index} {name}', flush=True) + started = time.monotonic() + try: + await asyncio.wait_for(getattr(self, name)(), STEP_TIMEOUT) + except asyncio.TimeoutError: + if time.monotonic() - started >= STEP_TIMEOUT: + print(f'step {index} {name} is stuck', flush=True) + sys.exit(3) + except Exception as e: # noqa: BLE001 (misuse is expected, crashes and deadlocks aren't) + print(f' {type(e).__name__}: {str(e)[:80]}', flush=True) + for pc in self.connections: + pc.close() + for track in self.tracks: + track.stop() + for task in self.tasks: + if isinstance(task, threading.Thread): + task.join(STEP_TIMEOUT) + if task.is_alive(): + print('a reader thread is stuck', flush=True) + sys.exit(3) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--seed', type=int, default=0) + parser.add_argument('--steps', type=int, default=300) + args = parser.parse_args() + print(f'seed {args.seed}, {args.steps} steps', flush=True) + asyncio.run(Chaos(args.seed).run(args.steps)) + # the last references may be released on helper threads + deadline = time.monotonic() + 1 + while wrtc._alive_factories() and time.monotonic() < deadline: + gc.collect() + time.sleep(0.05) + alive = {name: count for name, count in wrtc._alive().items() if count} + print(f'done, {wrtc._alive_factories()} factories alive, native objects alive: {alive}', flush=True) + + +if __name__ == '__main__': + main() diff --git a/tests/conftest.py b/tests/conftest.py index fdbebc4..6298dc5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,9 +16,20 @@ def pytest_addoption(parser): parser.addoption('--gc-on-emit', action='store_true', help='collect garbage on events of libwebrtc threads') + parser.addoption('--stress', action='store_true', help='run the long stress tests too') + + +def pytest_collection_modifyitems(config, items): + if config.getoption('--stress'): + return + skip = pytest.mark.skip(reason='a long stress test, run with --stress') + for item in items: + if 'stress' in item.keywords: + item.add_marker(skip) def pytest_configure(config): + config.addinivalue_line('markers', 'stress: a long stress test, run with --stress') if not config.getoption('--gc-on-emit'): return # the collector runs on libwebrtc threads, as it may whenever they emit: whatever it releases must not block them diff --git a/tests/helpers.py b/tests/helpers.py index 0ae37ca..cbc2f87 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -12,8 +12,12 @@ import os import subprocess import sys +import textwrap + +import pytest import webrtc +import wrtc async def exchange_offer(caller, callee): @@ -167,6 +171,10 @@ async def writing(write, *args, **kwargs): await asyncio.wait_for(task, 10) +# the memory of sanitizers (ASan quarantine, TSan shadow) hides leaks from resident memory +skip_if_sanitized = pytest.mark.skipif(wrtc._sanitized, reason='resident memory says nothing under sanitizers') + + def rss_bytes(): """The resident memory of the process, in bytes""" if sys.platform.startswith('linux'): @@ -196,3 +204,16 @@ class Counters(ctypes.Structure): return counters.WorkingSetSize # macOS and other BSDs return int(subprocess.check_output(['ps', '-o', 'rss=', '-p', str(os.getpid())])) * 1024 + + +#: The root of the project, which has the tests package (pytest may run from elsewhere, like cibuildwheel) +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def run_isolated(script, timeout=60): + """Runs a script in its own process, so a crash or a deadlock fails the test only; returns its output""" + result = subprocess.run( + [sys.executable, '-c', textwrap.dedent(script)], capture_output=True, text=True, timeout=timeout, cwd=ROOT + ) + assert result.returncode == 0, f'exit code {result.returncode}:\n{result.stderr[-3000:]}' + return result.stdout diff --git a/tests/test_events.py b/tests/test_events.py index 8a7dabc..e878fb5 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -161,3 +161,27 @@ async def test_restart_ice_before_negotiation_needs_nothing(pc): pc.restart_ice() await asyncio.sleep(QUIET_PERIOD) assert events == [] + + +def test_objects_used_from_another_loop_see_their_events(): + """Once its first loop is closed, an object is updated on the loop of its handlers""" + objects = {} + + async def first(): + caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + channel = caller.create_data_channel('loops') + opened = wait_for_event(channel, 'open') + await connect(caller, callee) + await opened + objects.update(caller=caller, callee=callee, channel=channel) + + async def second(): + channel = objects['channel'] + closed = wait_for_event(channel, 'close') + objects['callee'].close() + await closed + assert channel.ready_state == webrtc.RTCDataChannelState.closed + objects['caller'].close() + + asyncio.run(first()) + asyncio.run(second()) diff --git a/tests/test_lifetime.py b/tests/test_lifetime.py index 5a5d900..5cd5654 100644 --- a/tests/test_lifetime.py +++ b/tests/test_lifetime.py @@ -476,3 +476,163 @@ def collecting(self, name, *args): root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) result = subprocess.run([sys.executable, '-c', script], capture_output=True, text=True, timeout=60, cwd=root) assert 'collected' in result.stdout, result.stderr[-2000:] + + +@pytest.mark.asyncio +async def test_generators_are_collected(): + """An audio generator is its own track: its handlers mustn't keep it alive""" + baseline = alive_factories() + + def create(): + audio = webrtc.MediaStreamTrackGenerator('audio') + video = webrtc.VideoTrackGenerator() + audio.on('ended', lambda event: audio.kind) + video.track.on('ended', lambda event: video.track) + return weakref.ref(audio), weakref.ref(video), weakref.ref(video.track) + + refs = [ref for _ in range(10) for ref in create()] + collect() + + assert [ref for ref in refs if ref() is not None] == [] + assert alive_factories() == baseline + + +def test_generator_track_stays_ended_without_its_wrapper(): + """A generator whose stopped track is collected keeps dropping what's written""" + generator = wrtc.TrackGenerator('video') + track = generator.track + assert generator.live + track.stop() + del track + collect() + + assert not generator.live + assert webrtc.MediaStreamTrack._wrap(generator.track).ready_state == webrtc.MediaStreamTrackState.ended + + +def processor_with_handler_on_its_track(): + track = webrtc.get_user_media(audio=False, video=True).get_tracks()[0] + processor = webrtc.MediaStreamTrackProcessor(track) + track.on('ended', lambda event: processor.readable) + track.stop() + return processor + + +def stream_with_handler_on_its_track(): + stream = webrtc.get_user_media(audio=True, video=False) + stream.get_tracks()[0].on('ended', lambda event: stream.id) + return stream + + +def processor_of_generator_with_handler(): + generator = webrtc.MediaStreamTrackGenerator('video') + processor = webrtc.MediaStreamTrackProcessor(generator) + generator.on('ended', lambda event: processor.readable) + generator.stop() + return processor + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'create', + [processor_with_handler_on_its_track, stream_with_handler_on_its_track, processor_of_generator_with_handler], +) +async def test_handlers_of_owned_tracks_do_not_keep_owners_alive(create): + """Handlers of a track referencing its processor or stream don't keep them alive""" + baseline = alive_factories() + refs = [weakref.ref(create()) for _ in range(5)] + await asyncio.sleep(QUIET_PERIOD) + collect() + + assert [ref for ref in refs if ref() is not None] == [] + assert alive_factories() == baseline + + +def test_stream_keeps_the_state_of_its_tracks(): + """The native stream keeps its tracks weakly, the Python one keeps them: a stopped track stays stopped""" + stream = webrtc.MediaStream(webrtc.get_user_media(audio=True, video=True).get_tracks()) + for track in stream.get_tracks(): + track.stop() + del track + collect() + + assert all(track.ready_state == webrtc.MediaStreamTrackState.ended for track in stream.get_tracks()) + assert len(stream.get_audio_tracks()) == len(stream.get_video_tracks()) == 1 + + +@pytest.mark.xfail( + strict=True, + reason='known leak: the native sender and receiver keep the wrappers of their tracks, whose state (like the id of ' + 'a remote track) is theirs, so handlers of the track referencing its sender or receiver are a cycle through C++', +) +@pytest.mark.asyncio +@pytest.mark.parametrize('part', ['sender', 'receiver']) +async def test_handler_of_a_track_referencing_its_sender_or_receiver(part): + baseline = alive_factories() + + def create(): + pc = webrtc.RTCPeerConnection() + if part == 'sender': + owner = pc.add_track(webrtc.get_user_media(audio=True, video=False).get_tracks()[0]) + else: + owner = pc.add_transceiver(webrtc.MediaType.audio).receiver + owner.track.on('ended', lambda event: owner.track) + pc.close() + return weakref.ref(owner) + + ref = create() + await asyncio.sleep(QUIET_PERIOD) + collect() + + assert ref() is None + assert alive_factories() == baseline + + +def alive_objects(): + """The native objects alive by type, once releases on helper threads are done""" + collect() + alive = wrtc._alive() + deadline = time.monotonic() + 2 + while time.monotonic() < deadline: + time.sleep(0.05) + collect() + current = wrtc._alive() + if current == alive: + break + alive = current + return alive + + +@pytest.mark.asyncio +async def test_a_session_releases_every_native_object(): + """Connections with media, channels, processors and generators, closed and dropped: nothing native is left""" + baseline = alive_objects() + + async def session(): + caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + stream = webrtc.get_user_media(audio=True, video=True) + for track in stream.get_tracks(): + caller.add_track(track, stream) + generator = webrtc.VideoTrackGenerator() + caller.add_track(generator.track) + channel = caller.create_data_channel('session') + received = wait_for_event(callee, 'track') + await connect(caller, callee) + remote = (await received).track + reader = webrtc.MediaStreamTrackProcessor(remote).readable.get_reader() + writer = generator.writable.get_writer() + await writer.write( + webrtc.VideoFrame(bytes(64 * 48 * 4), format='RGBA', coded_width=64, coded_height=48, timestamp=0) + ) + (await asyncio.wait_for(reader.read(), 5)).value.close() + await caller.get_stats() + channel.send('bye') + for track in stream.get_tracks(): + track.stop() + caller.close() + callee.close() + + await session() + await asyncio.sleep(QUIET_PERIOD) + + assert alive_objects() == baseline diff --git a/tests/test_media_stress.py b/tests/test_media_stress.py index ed594cd..def3100 100644 --- a/tests/test_media_stress.py +++ b/tests/test_media_stress.py @@ -15,7 +15,8 @@ import pytest import webrtc -from tests.helpers import connect_track, rss_bytes, wait_until, write_video, writing +import wrtc +from tests.helpers import connect_track, rss_bytes, skip_if_sanitized, wait_until, write_video, writing from webrtc.utils.task_queue import TaskQueue TIMEOUT = 20 @@ -154,9 +155,10 @@ async def cycle(): # callbacks still queued on the loop hold the last objects await wait_until(lambda: collected(refs), 'every cycle to be collected') growth = rss_bytes() - before - assert growth < 20 * 1024 * 1024, f'{growth / 1e6:.1f} MB more after 1000 cycles' + assert wrtc._sanitized or growth < 20 * 1024 * 1024, f'{growth / 1e6:.1f} MB more after 1000 cycles' +@skip_if_sanitized @pytest.mark.asyncio async def test_unread_frames_do_not_grow_memory(video_stream): """A processor nobody reads keeps at most its buffer: memory stays flat while frames keep coming""" diff --git a/tests/test_robustness_chaos.py b/tests/test_robustness_chaos.py new file mode 100644 index 0000000..8cd5382 --- /dev/null +++ b/tests/test_robustness_chaos.py @@ -0,0 +1,39 @@ +# +# Copyright 2026 Ilya (Marshal) . All rights reserved. +# +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE.md file in the root of the project. +# + +"""Random sequences of API calls (tests/chaos.py) in processes of their own: no crash, no deadlock. A failure +prints the seed and the steps, replayed with ``python -m tests.chaos --seed --steps ``.""" + +import subprocess +import sys + +import pytest + +from tests.helpers import ROOT + + +def run_chaos(seed, steps, timeout): + command = [sys.executable, '-m', 'tests.chaos', '--seed', str(seed), '--steps', str(steps)] + try: + result = subprocess.run(command, capture_output=True, text=True, timeout=timeout, cwd=ROOT) + except subprocess.TimeoutExpired as e: + pytest.fail(f'seed {seed} is stuck after:\n{(e.stdout or b"")[-3000:]}') + output = result.stdout + result.stderr + assert result.returncode == 0, f'seed {seed}, exit code {result.returncode}:\n{output[-5000:]}' + assert 'done' in result.stdout, output[-3000:] + + +@pytest.mark.parametrize('seed', range(2)) +def test_chaos(seed): + run_chaos(seed, steps=150, timeout=120) + + +@pytest.mark.stress +@pytest.mark.timeout(900) +@pytest.mark.parametrize('seed', range(100, 120)) +def test_chaos_long(seed): + run_chaos(seed, steps=1000, timeout=600) diff --git a/tests/test_robustness_exit.py b/tests/test_robustness_exit.py new file mode 100644 index 0000000..23b694b --- /dev/null +++ b/tests/test_robustness_exit.py @@ -0,0 +1,108 @@ +# +# Copyright 2026 Ilya (Marshal) . All rights reserved. +# +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE.md file in the root of the project. +# + +"""The interpreter exits while objects are alive and busy: no hang, no crash.""" + +import os + +import pytest + +from tests.helpers import run_isolated + +BUSY_AT_EXIT = ''' + import asyncio + import threading + import webrtc + from tests.helpers import connect + + async def main(): + stream = webrtc.get_user_media(audio=True, video=True) + caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + for track in stream.get_tracks(): + caller.add_track(track, stream) + channel = caller.create_data_channel('exit') + await connect(caller, callee) + + def spin(): + while True: + _ = caller.connection_state, caller.get_transceivers(), callee.get_receivers() + try: + channel.send('busy') + except Exception: + pass + + threading.Thread(target=spin, daemon=True).start() + await asyncio.sleep(0.3) + return caller, callee, channel + + # kept alive until the interpreter finalizes + objects = asyncio.run(main()) + print('exiting') +''' + + +@pytest.mark.parametrize('attempt', range(5)) +def test_exit_while_objects_are_busy(attempt): + """Wrappers released by the last collection must not block on threads hung in the GIL""" + assert 'exiting' in run_isolated(BUSY_AT_EXIT, timeout=30) + + +@pytest.mark.skipif(not hasattr(os, 'fork'), reason='no fork') +def test_forked_child_leaves_the_objects_of_its_parent_alone(): + """The child of a fork doesn't block on its parent's threads; new objects work (raise on macOS)""" + output = run_isolated( + """ + import asyncio + import gc + import os + import sys + import time + import warnings + import webrtc + from tests.helpers import connect + + warnings.simplefilter('ignore', DeprecationWarning) # fork with threads + + async def use(): + caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + track = webrtc.get_user_media(audio=False, video=True).get_tracks()[0] + caller.add_track(track) + await asyncio.wait_for(connect(caller, callee), 10) + reader = webrtc.MediaStreamTrackProcessor(track).readable.get_reader() + (await asyncio.wait_for(reader.read(), 5)).value.close() + track.stop() + caller.close() + callee.close() + + parent = [webrtc.RTCPeerConnection(), webrtc.get_user_media(audio=True, video=True)] + asyncio.run(use()) + pid = os.fork() + if pid == 0: + parent.clear() + gc.collect() + if sys.platform == 'darwin': + try: + webrtc.RTCPeerConnection() + except RuntimeError: + raise SystemExit(0) + raise SystemExit(1) + asyncio.run(use()) + raise SystemExit(0) + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + done, status = os.waitpid(pid, os.WNOHANG) + if done: + print('child exited', os.waitstatus_to_exitcode(status)) + break + time.sleep(0.05) + else: + os.kill(pid, 9) + print('child is stuck') + """, + timeout=60, + ) + assert 'child exited 0' in output, output diff --git a/tests/test_robustness_media.py b/tests/test_robustness_media.py new file mode 100644 index 0000000..873f563 --- /dev/null +++ b/tests/test_robustness_media.py @@ -0,0 +1,138 @@ +# +# Copyright 2026 Ilya (Marshal) . All rights reserved. +# +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE.md file in the root of the project. +# + +"""Hostile media input: buffers, sizes and formats that must be rejected rather than read, written or sent.""" + +import pytest + +import webrtc +import wrtc +from tests.helpers import run_isolated + +WIDTH, HEIGHT = 16, 16 +I420_SIZE = WIDTH * HEIGHT * 3 // 2 + + +def i420_frame(): + return webrtc.VideoFrame(bytes(I420_SIZE), format='I420', coded_width=WIDTH, coded_height=HEIGHT, timestamp=0) + + +def reversed_view(size): + """A view of size bytes whose pointer is its last byte: read or written forward, it's out of its buffer""" + return memoryview(bytearray(size))[::-1] + + +def strided_view(size): + return memoryview(bytearray(size * 2))[::2] + + +@pytest.mark.parametrize('view', [reversed_view, strided_view]) +def test_frame_from_non_contiguous_buffer_is_rejected(view): + with pytest.raises(TypeError, match='contiguous'): + webrtc.VideoFrame(view(I420_SIZE), format='I420', coded_width=WIDTH, coded_height=HEIGHT, timestamp=0) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('view', [reversed_view, strided_view]) +@pytest.mark.parametrize('options', [None, {'format': 'RGBA'}]) +async def test_frame_copy_to_non_contiguous_destination_is_rejected(view, options): + frame = i420_frame() + with pytest.raises(TypeError, match='contiguous'): + await frame.copy_to(view(frame.allocation_size(options)), options) + frame.close() + + +@pytest.mark.parametrize('view', [reversed_view, strided_view]) +def test_audio_copy_to_non_contiguous_destination_is_rejected(view): + data = webrtc.AudioData( + format='s16', sample_rate=48000, number_of_frames=480, number_of_channels=2, timestamp=0, data=bytes(1920) + ) + with pytest.raises(TypeError, match='contiguous'): + data.copy_to(view(1920), {'plane_index': 0}) + data.close() + + +def test_native_bounds_checks_do_not_overflow(): + """Offsets and strides near the top of size_t wrap around in naive bounds checks""" + top = 2**64 - 1 + planes = [(0, WIDTH), (WIDTH * HEIGHT, WIDTH // 2), (WIDTH * HEIGHT * 5 // 4, WIDTH // 2)] + with pytest.raises(ValueError): + wrtc.VideoFrameBuffer.fromData('I420', WIDTH, HEIGHT, bytes(I420_SIZE), [(top, WIDTH)] + planes[1:]) + with pytest.raises(ValueError): + wrtc.VideoFrameBuffer.fromData('I420', WIDTH, HEIGHT, bytes(I420_SIZE), [(0, 2**63)] * 3) + with pytest.raises(ValueError): + wrtc.VideoFrameBuffer.fromData('I420', 2**31 - 1, 2**31 - 1, bytes(I420_SIZE), [(0, 2**31 - 1)] * 3) + + buffer = wrtc.VideoFrameBuffer.fromData('I420', WIDTH, HEIGHT, bytes(I420_SIZE), planes) + destination = bytearray(I420_SIZE) + half = (0, 0, WIDTH // 2, HEIGHT // 2, 0, WIDTH // 2) + with pytest.raises(ValueError): + buffer.copyPlanes(destination, [(0, 0, WIDTH, HEIGHT, top - 100, 1), half, half]) + with pytest.raises(ValueError): + buffer.copyPlanes(destination, [(0, top, WIDTH, 2, 0, WIDTH), half, half]) + with pytest.raises(ValueError): + buffer.convertTo(bytearray(16), 'RGBA', 0, 0, WIDTH, HEIGHT, top - 100, WIDTH * 4, '', False) + with pytest.raises(ValueError): + buffer.convertTo(bytearray(WIDTH * HEIGHT * 4), 'RGBA', 2**31 - 1, 0, 2, 1, 0, WIDTH * 4, '', False) + + with pytest.raises(ValueError): + wrtc.copyAudioSamples(bytes(16), 's16', 2**40, 2**40, bytearray(16), 's16', 0, 0, 1) + with pytest.raises(ValueError): + wrtc.copyAudioSamples(bytes(16), 's16', 1, 4, bytearray(16), 's16', 0, top, 2) + with pytest.raises(ValueError): + wrtc.copyAudioSamples(bytes(16), 's16', 0, 4, bytearray(16), 's16-planar', 0, 0, 1) + + +@pytest.mark.parametrize('rate', [float('inf'), float('nan'), 0, -1]) +def test_audio_data_sample_rate_is_positive_and_finite(rate): + with pytest.raises(TypeError): + webrtc.AudioData( + format='s16', sample_rate=rate, number_of_frames=1, number_of_channels=1, timestamp=0, data=bytes(2) + ) + + +def test_generator_rejects_audio_libwebrtc_cannot_send(): + """Audio beyond libwebrtc's frames or resampler is rejected, it aborted the process""" + output = run_isolated( + ''' + import asyncio + import webrtc + from tests.helpers import connect + + async def write(rate, channels): + generator = webrtc.MediaStreamTrackGenerator('audio') + caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + caller.add_track(generator) + await connect(caller, callee) + writer = generator.writable.get_writer() + frames = max(1, int(rate) // 100) + try: + for i in range(10): + data = webrtc.AudioData(format='s16', sample_rate=rate, number_of_frames=frames, + number_of_channels=channels, timestamp=i * 10000, + data=bytes(frames * channels * 2)) + await writer.write(data) + await asyncio.sleep(0.01) + result = 'written' + except webrtc.NotSupportedError: + result = 'rejected' + caller.close() + callee.close() + return result + + async def main(): + for rate, channels in ((1000, 1), (150, 1), (0.5, 1), (2**40, 1), (1000000, 1), (48000, 24), + (384000, 16)): + print(rate, channels, await write(rate, channels)) + for rate, channels in ((8000, 1), (48000, 16), (384000, 2), (44100, 2)): + print(rate, channels, await write(rate, channels)) + + asyncio.run(main()) + ''' + ) + results = [line.split()[-1] for line in output.splitlines() if line.endswith(('written', 'rejected'))] + assert results == ['rejected'] * 7 + ['written'] * 4, output diff --git a/tests/test_robustness_threads.py b/tests/test_robustness_threads.py new file mode 100644 index 0000000..e76e949 --- /dev/null +++ b/tests/test_robustness_threads.py @@ -0,0 +1,250 @@ +# +# Copyright 2026 Ilya (Marshal) . All rights reserved. +# +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE.md file in the root of the project. +# + +"""The same objects used from many Python threads at once: no crash, no deadlock, no corruption.""" + +import pytest + +from tests.helpers import run_isolated + + +def test_constructors_from_many_threads(): + """Constructors register their Python object with the GIL: pybind11's registry was corrupted""" + output = run_isolated( + ''' + import gc + import threading + import time + import webrtc + + track = webrtc.get_user_media(audio=False, video=True).get_tracks()[0] + stop = threading.Event() + errors = [] + + def construct(): + try: + while not stop.is_set(): + webrtc.MediaStreamTrackProcessor(track) + webrtc.RTCPeerConnection().close() + webrtc.RTCIceTransport().stop() + gc.collect() + except Exception as e: + errors.append(e) + + threads = [threading.Thread(target=construct) for _ in range(6)] + for thread in threads: + thread.start() + time.sleep(3) + stop.set() + for thread in threads: + thread.join(30) + assert not thread.is_alive(), 'stuck' + track.stop() + assert not errors, errors + print('constructed') + ''', + timeout=90, + ) + assert 'constructed' in output + + +@pytest.mark.parametrize('kind', ['video generator', 'audio generator', 'processor']) +def test_released_while_libwebrtc_threads_wait_for_the_gil(kind): + """A track's proxy released with the GIL deadlocked with the signaling thread""" + output = run_isolated( + f""" + import asyncio + import gc + import sys + import time + import webrtc + from tests.helpers import connect + + async def main(): + caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + channel = caller.create_data_channel('busy') + opened = asyncio.get_running_loop().create_future() + channel.on('open', lambda event: opened.done() or opened.set_result(None)) + callee.on('datachannel', lambda event: event.channel.on('message', lambda message: None)) + await connect(caller, callee) + await opened + if {kind!r} == 'video generator': + released = webrtc.VideoTrackGenerator()._native + elif {kind!r} == 'audio generator': + released = webrtc.MediaStreamTrackGenerator('audio') + else: + released = webrtc.MediaStreamTrackProcessor(webrtc.VideoTrackGenerator().track) + gc.collect() + # the signaling thread delivers the messages, waiting for the GIL this thread keeps + sys.setswitchinterval(1000) + for _ in range(200): + channel.send(b'busy') + end = time.perf_counter() + 0.3 + while time.perf_counter() < end: + pass + del released + gc.collect() + sys.setswitchinterval(0.005) + caller.close() + callee.close() + print('released') + + asyncio.run(main()) + """, + timeout=30, + ) + assert 'released' in output + + +def test_wrappers_created_and_released_on_many_threads(): + """Releasing a wrapper with the GIL waited for a holder lock held across a BlockingCall""" + output = run_isolated( + """ + import asyncio + import threading + import time + import webrtc + from tests.helpers import connect + + async def main(): + caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + channel = caller.create_data_channel('busy') + opened = asyncio.get_running_loop().create_future() + channel.on('open', lambda event: opened.done() or opened.set_result(None)) + callee.on('datachannel', lambda event: event.channel.on('message', lambda message: None)) + await connect(caller, callee) + await opened + stop = threading.Event() + + def create(): + while not stop.is_set(): + for track in webrtc.get_user_media(audio=True, video=True).get_tracks(): + track.stop() + + def release(): + while not stop.is_set(): + tracks = [webrtc.get_user_media(audio=True, video=False).get_tracks()[0] for _ in range(5)] + del tracks + + threads = [threading.Thread(target=f, daemon=True) for f in (create, create, release, release)] + for thread in threads: + thread.start() + end = time.monotonic() + 4 + while time.monotonic() < end: + for _ in range(100): + channel.send(b'busy') + await asyncio.sleep(0.001) + stop.set() + for thread in threads: + thread.join(20) + assert not thread.is_alive(), 'stuck' + caller.close() + callee.close() + print('done') + + asyncio.run(main()) + """, + timeout=60, + ) + assert 'done' in output + + +def test_wrappers_created_while_a_description_wraps_them(): + """A thread creating a wrapper held the holder's lock waiting for the signaling thread, which waited for it""" + output = run_isolated( + """ + import asyncio + import threading + import time + import webrtc + from tests.helpers import exchange_offer_answer + + async def main(): + stream = webrtc.get_user_media(audio=True, video=True) + stop = threading.Event() + + def create(): + # new transceivers, wrapped on this thread + while not stop.is_set(): + pc = webrtc.RTCPeerConnection() + for track in stream.get_tracks(): + pc.add_transceiver(track) + pc.get_transceivers() + pc.close() + + threads = [threading.Thread(target=create, daemon=True) for _ in range(3)] + for thread in threads: + thread.start() + end = time.monotonic() + 4 + while time.monotonic() < end: + # new transceivers of the callee, wrapped on the signaling thread by its track events + caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + for track in stream.get_tracks(): + caller.add_transceiver(track) + await asyncio.wait_for(exchange_offer_answer(caller, callee), 10) + caller.close() + callee.close() + stop.set() + for thread in threads: + thread.join(20) + assert not thread.is_alive(), 'stuck' + print('done') + + asyncio.run(main()) + """, + timeout=60, + ) + assert 'done' in output + + +def test_objects_of_connections_read_while_they_connect(): + """Wrapping under a lock of the connection (its SCTP transport, its tracks) waited for the signaling thread""" + output = run_isolated( + """ + import asyncio + import threading + import time + import webrtc + from tests.helpers import connect + + async def main(): + stream = webrtc.get_user_media(audio=True, video=True) + connections = [] + stop = threading.Event() + + def read(): + while not stop.is_set(): + for pc in list(connections): + _ = pc.sctp, pc.get_transceivers(), pc.get_senders() + for receiver in pc.get_receivers(): + _ = receiver.transport, receiver.track.ready_state + stream.get_tracks() + + threads = [threading.Thread(target=read, daemon=True) for _ in range(3)] + for thread in threads: + thread.start() + end = time.monotonic() + 4 + while time.monotonic() < end: + caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + connections[:] = [caller, callee] + for track in stream.get_tracks(): + caller.add_track(track, stream) + caller.create_data_channel('read') + await connect(caller, callee) + caller.close() + callee.close() + stop.set() + for thread in threads: + thread.join(20) + assert not thread.is_alive(), 'stuck' + print('done') + + asyncio.run(main()) + """, + timeout=60, + ) + assert 'done' in output diff --git a/tests/test_rtp_sender_receiver.py b/tests/test_rtp_sender_receiver.py index d8e2ac4..6fd1b1f 100644 --- a/tests/test_rtp_sender_receiver.py +++ b/tests/test_rtp_sender_receiver.py @@ -235,3 +235,37 @@ async def test_synchronization_sources(caller, callee, video_stream): assert 0 <= source.rtp_timestamp < 2**32 assert source.audio_level is None assert receiver.get_contributing_sources() == [] + + +@pytest.mark.parametrize( + 'encoding', + [ + {'max_bitrate': -1}, + {'max_bitrate': 2**32}, + {'max_bitrate': 1.5}, + {'max_framerate': float('inf')}, + {'scale_resolution_down_by': float('nan')}, + ], +) +def test_encodings_have_their_webidl_types(pc, encoding): + """An [EnforceRange] unsigned long and restricted doubles: other values are a TypeError, not sent to libwebrtc""" + with pytest.raises(TypeError): + pc.add_transceiver( + webrtc.MediaType.video, + webrtc.RtpTransceiverInit(send_encodings=[webrtc.RTCRtpEncodingParameters(**encoding)]), + ) + + +def test_encoding_bitrate_beyond_an_int_is_no_limit(pc): + init = webrtc.RtpTransceiverInit(send_encodings=[webrtc.RTCRtpEncodingParameters(max_bitrate=2**32 - 1)]) + sender = pc.add_transceiver(webrtc.MediaType.video, init).sender + assert sender.get_parameters().encodings[0].max_bitrate == 2**31 - 1 + + +def test_transceiver_init_as_a_dictionary(pc): + """As in browsers, with camelCase or snake_case names, the encodings too; unknown members are ignored""" + init = {'direction': 'sendonly', 'sendEncodings': [{'rid': 'a', 'maxBitrate': 100000}, {'rid': 'b'}], 'x': 1} + transceiver = pc.add_transceiver(webrtc.MediaType.video, init) + assert transceiver.direction == webrtc.TransceiverDirection.sendonly + encodings = transceiver.sender.get_parameters().encodings + assert [(e.rid, e.max_bitrate) for e in encodings] == [('a', 100000), ('b', None)] diff --git a/tests/test_streams.py b/tests/test_streams.py index 6ba88bc..a4623fc 100644 --- a/tests/test_streams.py +++ b/tests/test_streams.py @@ -8,6 +8,8 @@ """The streams media processing uses: readable, writable and transform streams of objects.""" import asyncio +import gc +import weakref import pytest @@ -220,3 +222,63 @@ def test_streams_need_a_loop(): """Readers and writers use futures of the running loop""" with pytest.raises(RuntimeError): webrtc.ReadableStream(Chunks([])).get_reader() + + +@pytest.mark.asyncio +async def test_pipe_goes_on_when_nothing_references_it(): + """A pipe nobody references goes on: a collected one errored its streams with GeneratorExit""" + # what the source waits for, known only weakly, like a native object waking it + waiting = weakref.WeakSet() + + class Woken: + def __init__(self): + self.next = 0 + + def pull(self, controller): + # kept by the source, as a processor keeps its pending read + woken = self.woken = asyncio.get_running_loop().create_future() + waiting.add(woken) + woken.add_done_callback(lambda _: self.deliver(controller)) + return woken + + def deliver(self, controller): + if self.next == 50: + controller.close() + else: + controller.enqueue(self.next) + self.next += 1 + + written = [] + + class Sink: + def write(self, chunk, controller): + written.append(chunk) + + def start(): + # only the last pipe is referenced + source = webrtc.ReadableStream(Woken(), high_water_mark=0) + return source.pipe_through(webrtc.TransformStream()).pipe_to(webrtc.WritableStream(Sink())) + + done = start() + deadline = asyncio.get_running_loop().time() + 5 + while not done.done() and asyncio.get_running_loop().time() < deadline: + gc.collect() + for woken in list(waiting): + if not woken.done(): + woken.set_result(None) + await asyncio.sleep(0.005) + await asyncio.wait_for(done, 1) + assert written == list(range(50)) + + +@pytest.mark.asyncio +async def test_sources_sinks_and_transformers_as_dictionaries(): + """As in browsers, methods may be members of a dictionary: they were ignored, a transform changed nothing""" + written = [] + source = webrtc.ReadableStream({'pull': lambda controller: controller.enqueue(2)}) + transform = webrtc.TransformStream({'transform': lambda chunk, controller: controller.enqueue(chunk * 10)}) + sink = webrtc.WritableStream({'write': lambda chunk, controller: written.append(chunk)}) + pipe = source.pipe_through(transform).pipe_to(sink) + await wait_until(lambda: len(written) >= 3, 'chunks written') + pipe.cancel() + assert written[:3] == [20, 20, 20] diff --git a/tests/test_task_queue.py b/tests/test_task_queue.py index c58abc6..a6d2c1e 100644 --- a/tests/test_task_queue.py +++ b/tests/test_task_queue.py @@ -8,7 +8,9 @@ """Order of the callbacks of TaskQueue, which delivers events and results of operations.""" import asyncio +import gc import threading +import weakref import pytest @@ -92,3 +94,24 @@ async def awaiting(): loop.call_later(0, timer.set_result, None) await timer assert order[-1] == 'later' + + +def test_loops_are_collected_with_what_they_had_queued(): + """A closed loop is collected with what was still queued for it""" + + class Held: + pass + + refs = [] + for _ in range(5): + loop = asyncio.new_event_loop() + held = Held() + held.loop = loop + # never run: the loop closes first + TaskQueue.of(loop).post(lambda held=held: None) + loop.close() + refs.append((weakref.ref(loop), weakref.ref(held))) + del loop, held + gc.collect() + + assert [ref for pair in refs for ref in pair if ref() is not None] == [] diff --git a/tests/test_track_settings.py b/tests/test_track_settings.py index 244eccd..a7c08e1 100644 --- a/tests/test_track_settings.py +++ b/tests/test_track_settings.py @@ -10,7 +10,7 @@ import pytest import webrtc -from tests.helpers import connect_track, wait_until +from tests.helpers import connect_track, run_isolated, wait_until @pytest.mark.asyncio @@ -111,3 +111,73 @@ async def test_constraints_of_an_ended_track(video_stream): track = video_stream.get_tracks()[0] track.stop() await track.apply_constraints({'width': {'exact': 100000}}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'constraints', + [{'frame_rate': float('nan')}, {'frame_rate': float('inf')}, {'width': '1'}], +) +async def test_constraints_have_their_webidl_types(video_stream, constraints): + """Unsigned longs and restricted doubles: other values are a TypeError""" + with pytest.raises(TypeError): + await video_stream.get_tracks()[0].apply_constraints(constraints) + with pytest.raises(TypeError): + webrtc.get_user_media(audio=False, video=True, **constraints) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'constraints, expected', + [ + ({'frame_rate': 10**9}, (640, 480, 120)), + ({'frame_rate': 0}, (640, 480, 1)), + ({'frame_rate': {'ideal': -5}}, (640, 480, 1)), + ], +) +async def test_camera_stays_within_its_capabilities(video_stream, constraints, expected): + """Ideal values beyond the capabilities select the nearest ones""" + track = video_stream.get_tracks()[0] + await track.apply_constraints(constraints) + assert track._native_obj._camera() == expected + + track = webrtc.get_user_media(audio=False, video=True, **constraints).get_tracks()[0] + assert track._native_obj._camera() == expected + track.stop() + + +def test_get_user_media_rejects_what_the_camera_cannot_do(): + with pytest.raises(webrtc.OverconstrainedError): + webrtc.get_user_media(audio=False, video=True, width={'exact': 5000}) + with pytest.raises(webrtc.OverconstrainedError): + webrtc.get_user_media(audio=False, video=True, frame_rate={'min': 500}) + + +def test_camera_of_impossible_sizes(): + """A camera of no size, a negative one or a huge one aborted the process""" + output = run_isolated( + """ + import asyncio + import webrtc + + async def main(): + track = webrtc.get_user_media(audio=False, video=True).get_tracks()[0] + for negative in ({'width': -1}, {'height': {'ideal': -5}}): + try: + await track.apply_constraints(negative) + except TypeError: + print('rejected') + await track.apply_constraints({'width': 10**6, 'height': {'ideal': 10**6}}) + print(track._native_obj._camera()) + await track.apply_constraints({'width': 0, 'height': 0}) + print(track._native_obj._camera()) + track.stop() + (track,) = webrtc.get_user_media(audio=False, video=True, width=0, height=0).get_tracks() + print(track._native_obj._camera()) + + asyncio.run(main()) + """ + ) + assert output.count('rejected') == 2, output + assert output.count('(4096, 4096, 30.0)') == 1, output + assert output.count('(1, 1, 30.0)') == 2, output diff --git a/tests/test_video.py b/tests/test_video.py index c7afb7d..f4e002d 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -29,16 +29,27 @@ def test_get_user_media_needs_audio_or_video(): @pytest.mark.parametrize( - 'constraints', - [{'width': {'exact': 0}}, {'height': {'ideal': 0}}, {'frame_rate': {'max': 0}}, {'width': {'min': 0, 'max': -1}}], - ids=['exact', 'ideal', 'max', 'min and max'], + 'constraints, error', + [ + ({'width': {'exact': 0}}, webrtc.OverconstrainedError), + ({'frame_rate': {'max': 0}}, webrtc.OverconstrainedError), + ({'width': {'min': 0, 'max': -1}}, TypeError), + ], + ids=['exact', 'max', 'negative'], ) -def test_get_user_media_constraint_selects_a_value(constraints): - """A constraint selects its exact or ideal value, else the default within its range: here a non-positive one""" - with pytest.raises(ValueError): +def test_get_user_media_constraint_beyond_the_camera(constraints, error): + """A required value the camera can't have is overconstrained, a negative size isn't an unsigned long""" + with pytest.raises(error): webrtc.get_user_media(audio=False, video=True, **constraints) +def test_get_user_media_ideal_beyond_the_camera(): + """An ideal value selects the nearest one the camera can have""" + (track,) = webrtc.get_user_media(audio=False, video=True, height={'ideal': 0}).get_tracks() + assert track._native_obj._camera() == (640, 1, 30) + track.stop() + + def test_get_user_media_constraints(): """Constraints that select a positive value are accepted""" stream = webrtc.get_user_media( From 5edb93c1aa7c399a583e77361f9c5c46522ba6a6 Mon Sep 17 00:00:00 2001 From: "Ilya (Marshal)" Date: Tue, 29 Sep 2026 16:36:18 +0200 Subject: [PATCH 2/3] Stop threads taking the GIL at interpreter exit, fix CI flakes --- .github/scripts/sanitizers-macos.sh | 5 +- .../cpp/src/interfaces/media_stream_track.cpp | 15 +-- .../src/interfaces/rtc_peer_connection.cpp | 13 +-- .../cpp/src/interfaces/rtc_rtp_receiver.cpp | 3 +- .../cpp/src/interfaces/rtc_rtp_sender.cpp | 5 +- python-webrtc/cpp/src/media/audio_samples.cpp | 3 +- .../media/media_stream_track_processor.cpp | 6 +- .../cpp/src/media/track_generator.cpp | 2 +- .../cpp/src/media/video_frame_buffer.cpp | 7 +- python-webrtc/cpp/src/module.cpp | 2 + python-webrtc/cpp/src/utils/gil.h | 99 ++++++++++++++++--- python-webrtc/cpp/src/utils/listeners.h | 8 +- python-webrtc/cpp/src/utils/python_callback.h | 62 ++++++++++++ tests/helpers.py | 11 ++- tests/test_media_stress.py | 8 +- tests/test_robustness_exit.py | 26 +++++ tests/test_robustness_media.py | 3 +- 17 files changed, 233 insertions(+), 45 deletions(-) create mode 100644 python-webrtc/cpp/src/utils/python_callback.h diff --git a/.github/scripts/sanitizers-macos.sh b/.github/scripts/sanitizers-macos.sh index d961668..4b4ab97 100755 --- a/.github/scripts/sanitizers-macos.sh +++ b/.github/scripts/sanitizers-macos.sh @@ -43,8 +43,9 @@ if [ "$SANITIZE" = thread ]; then else DYLD_INSERT_LIBRARIES="$(clang -print-runtime-dir)/libclang_rt.asan_osx_dynamic.dylib" export PYTHONMALLOC=malloc - # LeakSanitizer isn't supported on macOS; strip_env=0 keeps the runtime in subprocesses of the tests - export ASAN_OPTIONS="detect_leaks=0:halt_on_error=1:abort_on_error=0:strict_init_order=1:strip_env=0" + # LeakSanitizer isn't supported on macOS; strip_env=0 keeps the runtime in subprocesses of the tests; + # container annotations can't match libwebrtc's libc++, whose code the linker may fold with ours + export ASAN_OPTIONS="detect_leaks=0:detect_container_overflow=0:halt_on_error=1:abort_on_error=0:strict_init_order=1:strip_env=0" export UBSAN_OPTIONS="print_stacktrace=1:halt_on_error=1" fi export DYLD_INSERT_LIBRARIES diff --git a/python-webrtc/cpp/src/interfaces/media_stream_track.cpp b/python-webrtc/cpp/src/interfaces/media_stream_track.cpp index 2fa5183..d014a71 100644 --- a/python-webrtc/cpp/src/interfaces/media_stream_track.cpp +++ b/python-webrtc/cpp/src/interfaces/media_stream_track.cpp @@ -42,11 +42,14 @@ namespace python_webrtc { _track = nullptr; // released as the listeners are (see DropListeners) - if (!PythonAlive()) { - (void) _constraints.release(); - } else if (_constraints) { - pybind11::gil_scoped_acquire gil; - pybind11::object dropped = std::move(_constraints); + { + PythonEntry entry; + if (!entry) { + (void) _constraints.release(); + } else if (_constraints) { + pybind11::gil_scoped_acquire gil; + pybind11::object dropped = std::move(_constraints); + } } DropListeners(); } @@ -257,7 +260,7 @@ namespace python_webrtc { std::optional> camera; bool microphone = false; { - pybind11::gil_scoped_release release; + gil_release release; video = _monitor.video(); audio = _monitor.audio(); camera = GetCamera(); diff --git a/python-webrtc/cpp/src/interfaces/rtc_peer_connection.cpp b/python-webrtc/cpp/src/interfaces/rtc_peer_connection.cpp index f8942d2..1885245 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_peer_connection.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_peer_connection.cpp @@ -6,6 +6,7 @@ // #include "rtc_peer_connection.h" +#include "../utils/python_callback.h" #include #include @@ -125,16 +126,16 @@ namespace python_webrtc { .def(pybind11::init(nogil_factory(+[](const std::optional &configuration) { return std::shared_ptr(new RTCPeerConnection(configuration), DeleteOffLibwebrtcThread()); }))) - .def("createOffer", &RTCPeerConnection::CreateOffer, nogil(), + .def("createOffer", WithCallbacks(&RTCPeerConnection::CreateOffer), pybind11::arg("onSuccess"), pybind11::arg("onFailure"), pybind11::arg("iceRestart"), pybind11::arg("voiceActivityDetection")) - .def("createAnswer", &RTCPeerConnection::CreateAnswer, nogil(), + .def("createAnswer", WithCallbacks(&RTCPeerConnection::CreateAnswer), pybind11::arg("onSuccess"), pybind11::arg("onFailure"), pybind11::arg("voiceActivityDetection")) - .def("setLocalDescription", &RTCPeerConnection::SetLocalDescription, nogil(), + .def("setLocalDescription", WithCallbacks(&RTCPeerConnection::SetLocalDescription), pybind11::arg("onSuccess"), pybind11::arg("onFailure"), pybind11::arg("description")) - .def("setRemoteDescription", &RTCPeerConnection::SetRemoteDescription, nogil(), + .def("setRemoteDescription", WithCallbacks(&RTCPeerConnection::SetRemoteDescription), pybind11::arg("onSuccess"), pybind11::arg("onFailure"), pybind11::arg("description")) - .def("addIceCandidate", &RTCPeerConnection::AddIceCandidate, nogil(), + .def("addIceCandidate", WithCallbacks(&RTCPeerConnection::AddIceCandidate), pybind11::arg("onSuccess"), pybind11::arg("onFailure"), pybind11::arg("candidate"), pybind11::arg("sdpMid"), pybind11::arg("sdpMLineIndex"), pybind11::arg("usernameFragment")) .def("addTrack", @@ -157,7 +158,7 @@ namespace python_webrtc { pybind11::arg("label"), pybind11::arg("ordered"), pybind11::arg("maxPacketLifeTime"), pybind11::arg("maxRetransmits"), pybind11::arg("protocol"), pybind11::arg("negotiated"), pybind11::arg("id"), pybind11::arg("priority")) - .def("getStats", &RTCPeerConnection::GetStats, nogil(), pybind11::arg("onSuccess"), pybind11::arg("onFailure")) + .def("getStats", WithCallbacks(&RTCPeerConnection::GetStats), pybind11::arg("onSuccess"), pybind11::arg("onFailure")) .def("restartIce", &RTCPeerConnection::RestartIce, nogil()) .def("getConfiguration", &RTCPeerConnection::GetConfiguration, nogil()) .def("setConfiguration", &RTCPeerConnection::SetConfiguration, nogil(), pybind11::arg("configuration")) diff --git a/python-webrtc/cpp/src/interfaces/rtc_rtp_receiver.cpp b/python-webrtc/cpp/src/interfaces/rtc_rtp_receiver.cpp index a41adce..07a9169 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_rtp_receiver.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_rtp_receiver.cpp @@ -6,6 +6,7 @@ // #include "rtc_rtp_receiver.h" +#include "../utils/python_callback.h" #include @@ -59,7 +60,7 @@ namespace python_webrtc { .def_property("jitterBufferTarget", nogil_fn(&RTCRtpReceiver::GetJitterBufferTarget), nogil_fn(&RTCRtpReceiver::SetJitterBufferTarget)) .def("getParameters", &RTCRtpReceiver::GetParameters, nogil()) - .def("getStats", &RTCRtpReceiver::GetStats, nogil(), pybind11::arg("onSuccess"), pybind11::arg("onFailure")) + .def("getStats", WithCallbacks(&RTCRtpReceiver::GetStats), pybind11::arg("onSuccess"), pybind11::arg("onFailure")) .def_static("getCapabilities", &RTCRtpReceiver::GetCapabilities, nogil(), pybind11::arg("kind")) .def("_getSources", &RTCRtpReceiver::GetSources, nogil()); } diff --git a/python-webrtc/cpp/src/interfaces/rtc_rtp_sender.cpp b/python-webrtc/cpp/src/interfaces/rtc_rtp_sender.cpp index df01644..4d68c08 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_rtp_sender.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_rtp_sender.cpp @@ -6,6 +6,7 @@ // #include "rtc_rtp_sender.h" +#include "../utils/python_callback.h" #include #include @@ -27,12 +28,12 @@ namespace python_webrtc { .def_property_readonly("kind", nogil_fn(&RTCRtpSender::GetKind)) .def_property_readonly("dtmf", nogil_fn(&RTCRtpSender::GetDtmf)) .def("getParameters", &RTCRtpSender::GetParameters, nogil()) - .def("setParameters", &RTCRtpSender::SetParameters, nogil(), + .def("setParameters", WithCallbacks(&RTCRtpSender::SetParameters), pybind11::arg("onSuccess"), pybind11::arg("onFailure"), pybind11::arg("parameters")) .def("replaceTrack", &RTCRtpSender::ReplaceTrack, nogil(), pybind11::arg("track")) .def("setStreams", &RTCRtpSender::SetStreams, nogil(), pybind11::arg("streamIds")) .def("getStreamIds", &RTCRtpSender::GetStreamIds, nogil()) - .def("getStats", &RTCRtpSender::GetStats, nogil(), pybind11::arg("onSuccess"), pybind11::arg("onFailure")) + .def("getStats", WithCallbacks(&RTCRtpSender::GetStats), pybind11::arg("onSuccess"), pybind11::arg("onFailure")) .def_static("getCapabilities", &RTCRtpSender::GetCapabilities, nogil(), pybind11::arg("kind")) .def("_transceiverStopped", &RTCRtpSender::IsTransceiverStopped, nogil()) .def("_lastParameters", &RTCRtpSender::GetLastParameters, nogil()) diff --git a/python-webrtc/cpp/src/media/audio_samples.cpp b/python-webrtc/cpp/src/media/audio_samples.cpp index bfc23c2..d0f0762 100644 --- a/python-webrtc/cpp/src/media/audio_samples.cpp +++ b/python-webrtc/cpp/src/media/audio_samples.cpp @@ -13,6 +13,7 @@ #include #include "utils/buffer.h" +#include "utils/gil.h" namespace python_webrtc { @@ -158,7 +159,7 @@ namespace python_webrtc { auto from = static_cast(sourceInfo.ptr); auto to = static_cast(destinationInfo.ptr); - pybind11::gil_scoped_release release; + gil_release release; for (size_t frame = 0; frame < frameCount; ++frame) { for (size_t c = 0; c < copiedChannels; ++c) { size_t channel = destinationFormat.planar ? planeIndex : c; diff --git a/python-webrtc/cpp/src/media/media_stream_track_processor.cpp b/python-webrtc/cpp/src/media/media_stream_track_processor.cpp index 63cfc23..ace47ae 100644 --- a/python-webrtc/cpp/src/media/media_stream_track_processor.cpp +++ b/python-webrtc/cpp/src/media/media_stream_track_processor.cpp @@ -161,7 +161,7 @@ namespace python_webrtc { std::optional item; bool ended; { - pybind11::gil_scoped_release release; + gil_release release; std::lock_guard lock(_mutex); if (!_queue.empty()) { item = std::move(_queue.front()); @@ -171,7 +171,7 @@ namespace python_webrtc { } if (ended && !item) { // the sink is detached here rather than on the thread that ended the track - pybind11::gil_scoped_release release; + gil_release release; Detach(); } if (!item) { @@ -180,7 +180,7 @@ namespace python_webrtc { if (auto video = std::get_if(&*item)) { std::shared_ptr buffer; { - pybind11::gil_scoped_release release; + gil_release release; buffer = VideoFrameBuffer::FromWebrtc(video->buffer); } return pybind11::make_tuple(buffer, video->timestampUs, video->rotation, video->rtpTimestamp); diff --git a/python-webrtc/cpp/src/media/track_generator.cpp b/python-webrtc/cpp/src/media/track_generator.cpp index a4697fc..94bb535 100644 --- a/python-webrtc/cpp/src/media/track_generator.cpp +++ b/python-webrtc/cpp/src/media/track_generator.cpp @@ -149,7 +149,7 @@ namespace python_webrtc { if (frames > data.size() || data.size() != frames * channels * sizeof(int16_t)) { throw pybind11::value_error("The samples don't have the given number of frames"); } - pybind11::gil_scoped_release release; + gil_release release; if (!GetLive() || _muted) { return; } diff --git a/python-webrtc/cpp/src/media/video_frame_buffer.cpp b/python-webrtc/cpp/src/media/video_frame_buffer.cpp index fe8f9fe..2c11425 100644 --- a/python-webrtc/cpp/src/media/video_frame_buffer.cpp +++ b/python-webrtc/cpp/src/media/video_frame_buffer.cpp @@ -22,6 +22,7 @@ #include #include "utils/buffer.h" +#include "utils/gil.h" namespace python_webrtc { @@ -182,7 +183,7 @@ namespace python_webrtc { result->_owned = std::make_shared>(total); { - pybind11::gil_scoped_release release; + gil_release release; for (size_t i = 0; i < planes.size(); ++i) { auto [offset, stride] = layout[i]; size_t rowBytes = result->_stride[i]; @@ -236,7 +237,7 @@ namespace python_webrtc { } } - pybind11::gil_scoped_release release; + gil_release release; for (size_t i = 0; i < planes.size(); ++i) { auto [leftBytes, top, rowBytes, rows, offset, stride] = copies[i]; for (size_t row = 0; row < rows && rowBytes > 0; ++row) { @@ -305,7 +306,7 @@ namespace python_webrtc { } auto constants = rgbOrder ? yvu : yuv; - pybind11::gil_scoped_release release; + gil_release release; auto planes = _format->Planes(); auto source = ToEightBit(); auto plane = [&](int i) { diff --git a/python-webrtc/cpp/src/module.cpp b/python-webrtc/cpp/src/module.cpp index 2f86c34..ba8c3c5 100644 --- a/python-webrtc/cpp/src/module.cpp +++ b/python-webrtc/cpp/src/module.cpp @@ -8,6 +8,7 @@ #include #include "config.h" +#include "utils/gil.h" #include "exceptions.h" #include "models/models.h" #include "interfaces/interfaces.h" @@ -41,6 +42,7 @@ PYBIND11_MODULE(wrtc, m) { } m.def("ping", &ping); + py::module_::import("atexit").attr("register")(py::cpp_function(&python_webrtc::StopEnteringPython)); // the memory of ASan (its quarantine) and TSan (its shadow) makes resident memory say nothing about leaks #ifdef WRTC_SANITIZED m.attr("_sanitized") = true; diff --git a/python-webrtc/cpp/src/utils/gil.h b/python-webrtc/cpp/src/utils/gil.h index 8717b8f..68d59f3 100644 --- a/python-webrtc/cpp/src/utils/gil.h +++ b/python-webrtc/cpp/src/utils/gil.h @@ -7,16 +7,100 @@ #pragma once +#include +#include #include +#include #include #include namespace python_webrtc { + // Once the interpreter finalizes, another thread taking the GIL is stopped: with pthread_exit before 3.14, whose + // unwinding through native frames (noexcept ones, libwebrtc's) aborts. So threads stop taking it at exit already. + inline std::atomic pythonExiting{false}; + // the thread running the exit, which finalizes the interpreter + inline std::atomic exitThread{}; + // threads entering Python or taking the GIL back, which the exit waits for + inline std::atomic pythonEntries{0}; + + // Whether Python code can still run: libwebrtc threads may outlive the interpreter + inline bool PythonAlive() { + if (pythonExiting) { + return false; + } +#if PY_VERSION_HEX >= 0x030D0000 + return Py_IsInitialized() && !Py_IsFinalizing(); +#else + return Py_IsInitialized() && !_Py_IsFinalizing(); +#endif + } + + // Taking the GIL from a thread without it: not once the interpreter exits (see pythonExiting) + class PythonEntry { + public: + PythonEntry() { + pythonEntries++; + _entered = PythonAlive(); + } + + ~PythonEntry() { + pythonEntries--; + } + + PythonEntry(const PythonEntry &) = delete; + PythonEntry &operator=(const PythonEntry &) = delete; + + explicit operator bool() const { + return _entered; + } + + private: + bool _entered; + }; + + // Releases the GIL, like pybind11::gil_scoped_release. Once the interpreter exits, other threads than the exiting + // one (daemon threads) wait for the process to end rather than take it back, as in 3.14. + class gil_release { + public: + gil_release() : _state(PyEval_SaveThread()) {} + + ~gil_release() { + { + PythonEntry entry; + if (entry || std::this_thread::get_id() == exitThread) { + PyEval_RestoreThread(_state); + return; + } + } + while (true) { + std::this_thread::sleep_for(std::chrono::hours(1)); + } + } + + gil_release(const gil_release &) = delete; + gil_release &operator=(const gil_release &) = delete; + + private: + PyThreadState *_state; + }; + + // An atexit handler: threads stop taking the GIL before the interpreter finalizes, the ones taking it are waited for + inline void StopEnteringPython() { + exitThread = std::this_thread::get_id(); + pythonExiting = true; + gil_release release; + // bounded: a Python handler may never return + auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (pythonEntries > 0 && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + } + // Calls into libwebrtc block on its internal threads, which may themselves need the GIL // (to run or release Python callbacks), so every native call has to release it first. - using nogil = pybind11::call_guard; + using nogil = pybind11::call_guard; // Property getters/setters can't take a call guard directly, wrap them into a function instead. template @@ -28,20 +112,11 @@ namespace python_webrtc { template auto nogil_factory(R (*factory)(Args...)) { return [factory](Args... args) { - pybind11::gil_scoped_release release; + gil_release release; return factory(std::forward(args)...); }; } - // Whether Python code can still run: libwebrtc threads may outlive the interpreter - inline bool PythonAlive() { -#if PY_VERSION_HEX >= 0x030D0000 - return Py_IsInitialized() && !Py_IsFinalizing(); -#else - return Py_IsInitialized() && !_Py_IsFinalizing(); -#endif - } - // Destructors of wrappers block on libwebrtc threads (to unregister observers, to stop threads), // which may be waiting for the GIL. They can run on any thread, with or without the GIL held. class gil_release_if_held { @@ -53,7 +128,7 @@ namespace python_webrtc { } private: - std::optional _release; + std::optional _release; }; } // namespace python_webrtc diff --git a/python-webrtc/cpp/src/utils/listeners.h b/python-webrtc/cpp/src/utils/listeners.h index ed3636a..bba4443 100644 --- a/python-webrtc/cpp/src/utils/listeners.h +++ b/python-webrtc/cpp/src/utils/listeners.h @@ -44,7 +44,7 @@ namespace python_webrtc { _active = static_cast(_listeners); if (_active) { // held events are delivered now, without the GIL: a thread releasing them holds their lock waiting for it - pybind11::gil_scoped_release release; + gil_release release; if (IsHeld()) { Release(); } @@ -91,7 +91,8 @@ namespace python_webrtc { // their libwebrtc objects are released, rather than leaving it to ~Listeners, which runs after the members. void DropListeners() { _active = false; - if (!PythonAlive()) { + PythonEntry entry; + if (!entry) { // the interpreter is gone, and so are the objects (void) _listeners.release(); return; @@ -144,7 +145,8 @@ namespace python_webrtc { template void EmitNow(const char *name, Args... args) { - if (!_active || !PythonAlive()) { + PythonEntry entry; + if (!_active || !entry) { return; } diff --git a/python-webrtc/cpp/src/utils/python_callback.h b/python-webrtc/cpp/src/utils/python_callback.h new file mode 100644 index 0000000..f37abeb --- /dev/null +++ b/python-webrtc/cpp/src/utils/python_callback.h @@ -0,0 +1,62 @@ +// +// Copyright 2026 Ilya (Marshal) . All rights reserved. +// +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE.md file in the root of the project. +// + +#pragma once + +#include +#include +#include + +#include + +#include "../exceptions.h" +#include "gil.h" + +namespace python_webrtc { + + // A Python function called back from libwebrtc threads. pybind11's std::function takes the GIL to be called, copied + // or released, which stops the thread once the interpreter exits: this one is ignored then (see PythonEntry). + template + std::function PythonCallback(pybind11::function function) { + std::shared_ptr held(new pybind11::function(std::move(function)), [](pybind11::function *f) { + PythonEntry entry; + if (!entry) { + // the interpreter is gone + (void) f->release(); + delete f; + return; + } + pybind11::gil_scoped_acquire gil; + delete f; + }); + return [held](Args... args) { + PythonEntry entry; + if (!entry) { + return; + } + pybind11::gil_scoped_acquire gil; + try { + (*held)(std::move(args)...); + } catch (pybind11::error_already_set &e) { + e.discard_as_unraisable("callback"); + } + }; + } + + // A method taking (onSuccess, onFailure, ...) callbacks, bound with PythonCallbacks and without the GIL + template + auto WithCallbacks(void (C::*method)(std::function &, std::function &, + Rest...)) { + return [method](C &self, pybind11::function onSuccess, pybind11::function onFailure, Rest... rest) { + auto success = PythonCallback(std::move(onSuccess)); + auto failure = PythonCallback(std::move(onFailure)); + gil_release release; + (self.*method)(success, failure, std::forward(rest)...); + }; + } + +} // namespace python_webrtc diff --git a/tests/helpers.py b/tests/helpers.py index cbc2f87..fe8241e 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -212,8 +212,15 @@ class Counters(ctypes.Structure): def run_isolated(script, timeout=60): """Runs a script in its own process, so a crash or a deadlock fails the test only; returns its output""" + # a crash tells where: the Python stacks of every thread, and glibc's fatal errors, written to a tty otherwise + env = {**os.environ, 'PYTHONFAULTHANDLER': '1', 'LIBC_FATAL_STDERR_': '1'} result = subprocess.run( - [sys.executable, '-c', textwrap.dedent(script)], capture_output=True, text=True, timeout=timeout, cwd=ROOT + [sys.executable, '-c', textwrap.dedent(script)], + capture_output=True, + text=True, + timeout=timeout, + cwd=ROOT, + env=env, ) - assert result.returncode == 0, f'exit code {result.returncode}:\n{result.stderr[-3000:]}' + assert result.returncode == 0, f'exit code {result.returncode}:\n{result.stderr[-6000:]}' return result.stdout diff --git a/tests/test_media_stress.py b/tests/test_media_stress.py index def3100..914ed5f 100644 --- a/tests/test_media_stress.py +++ b/tests/test_media_stress.py @@ -52,16 +52,20 @@ async def test_stop_track_while_reading(video_stream, audio_stream): """Tracks stopped while tasks read them close their streams""" tracks = [*video_stream.get_tracks(), *audio_stream.get_tracks()] + read = [] + async def read_all(track): count = 0 async for media in webrtc.MediaStreamTrackProcessor(track).readable: media.close() count += 1 + if count == 1: + read.append(track) return count tasks = [asyncio.ensure_future(read_all(track)) for track in tracks for _ in range(3)] - # let every task read some media - await asyncio.sleep(0.3) + # every task reads some media first (a fixed wait was too short on slow machines) + await wait_until(lambda: len(read) == len(tasks), 'every task to read media', TIMEOUT) for track in tracks: track.stop() assert all(count > 0 for count in await asyncio.wait_for(asyncio.gather(*tasks), TIMEOUT)) diff --git a/tests/test_robustness_exit.py b/tests/test_robustness_exit.py index 23b694b..0ffa087 100644 --- a/tests/test_robustness_exit.py +++ b/tests/test_robustness_exit.py @@ -51,6 +51,32 @@ def test_exit_while_objects_are_busy(attempt): assert 'exiting' in run_isolated(BUSY_AT_EXIT, timeout=30) +PENDING_AT_EXIT = ''' + import threading + import time + import webrtc + + pc = webrtc.RTCPeerConnection() + pc.add_transceiver('audio') + + def spin(): + while True: + pc._native_obj.getStats(lambda report: None, lambda error: None) + time.sleep(0) + + for _ in range(4): + threading.Thread(target=spin, daemon=True).start() + time.sleep(0.3) + print('exiting') +''' + + +@pytest.mark.parametrize('attempt', range(5)) +def test_exit_while_operations_are_pending(attempt): + """Callbacks of operations completing at exit are dropped, not run by a libwebrtc thread taking the GIL""" + assert 'exiting' in run_isolated(PENDING_AT_EXIT, timeout=30) + + @pytest.mark.skipif(not hasattr(os, 'fork'), reason='no fork') def test_forked_child_leaves_the_objects_of_its_parent_alone(): """The child of a fork doesn't block on its parent's threads; new objects work (raise on macOS)""" diff --git a/tests/test_robustness_media.py b/tests/test_robustness_media.py index 873f563..224617e 100644 --- a/tests/test_robustness_media.py +++ b/tests/test_robustness_media.py @@ -109,7 +109,8 @@ async def write(rate, channels): caller.add_track(generator) await connect(caller, callee) writer = generator.writable.get_writer() - frames = max(1, int(rate) // 100) + # 10 ms, a few at most: the rate alone is rejected, a 2**40 Hz buffer would be 22 GB + frames = max(1, min(int(rate) // 100, 4800)) try: for i in range(10): data = webrtc.AudioData(format='s16', sample_rate=rate, number_of_frames=frames, From a2c02bc0f6cbc059aff2b46f44e528f10fa0cfc9 Mon Sep 17 00:00:00 2001 From: "Ilya (Marshal)" Date: Tue, 29 Sep 2026 17:15:27 +0200 Subject: [PATCH 3/3] Wait for the data channel to open in exit-after-connect test --- tests/test_lifetime.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_lifetime.py b/tests/test_lifetime.py index 5cd5654..76f3d54 100644 --- a/tests/test_lifetime.py +++ b/tests/test_lifetime.py @@ -386,13 +386,16 @@ def test_process_exits_after_connecting(): ''' import asyncio import webrtc - from tests.helpers import connect + from tests.helpers import connect, wait_for_event async def main(): for _ in range(3): caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() channel = caller.create_data_channel('exit') + # the channel opens after the connection connects + opened = wait_for_event(channel, 'open') await connect(caller, callee) + await opened caller.sctp.transport.ice_transport.on('selectedcandidatepairchange', lambda event: None) channel.send('bye') print('connected')