diff --git a/python-webrtc/cpp/src/interfaces/rtc_peer_connection/create_session_description_observer.cpp b/python-webrtc/cpp/src/interfaces/rtc_peer_connection/create_session_description_observer.cpp index 61cc6d2..f107ff8 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_peer_connection/create_session_description_observer.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_peer_connection/create_session_description_observer.cpp @@ -13,11 +13,14 @@ namespace python_webrtc { void CreateSessionDescriptionObserver::OnSuccess(webrtc::SessionDescriptionInterface *description) { const std::unique_ptr owned(description); - if (auto peerConnection = _peerConnection.lock()) { + auto peerConnection = _peerConnection.lock(); + if (peerConnection) { peerConnection->PrepareCreatedDescription(*owned); - peerConnection->SaveCreatedDescription(RTCSessionDescriptionInit::Wrap(owned.get())); } auto created = RTCSessionDescription::Wrap(owned.get()); + if (peerConnection) { + peerConnection->SaveCreatedDescription(created.init()); + } if (_created) { _created(created); } else { diff --git a/python-webrtc/cpp/src/interfaces/rtc_peer_connection/descriptions.cpp b/python-webrtc/cpp/src/interfaces/rtc_peer_connection/descriptions.cpp index 58d7117..2b075ba 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_peer_connection/descriptions.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_peer_connection/descriptions.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include @@ -288,8 +289,9 @@ namespace python_webrtc { } // the descriptions as the operation left them, before candidates gathered later self->_completionSnapshot = self->SnapshotDescriptions(); - auto created = self->WrapTransports(); - self->UpdateMaxMessageSize(); + const auto sctp = self->SctpTransportsOf(self->connection()); + auto created = self->WrapTransports(sctp); + self->UpdateMaxMessageSize(sctp); self->MarkIceRolesKnown(); if (remote) { self->RecordRemoteDescriptionCandidates(); @@ -597,10 +599,20 @@ namespace python_webrtc { return 0; } DescriptionsSnapshot snapshot; - for (size_t kind = 0; kind < kDescriptionKinds; ++kind) { - snapshot.kinds[kind] = ReadDescription(pc, static_cast(kind)); + auto view = [&snapshot](DescriptionKind kind) -> DescriptionView & { + return snapshot.kinds[static_cast(kind)]; + }; + for (auto kind : {DescriptionKind::kCurrentLocal, DescriptionKind::kCurrentRemote, DescriptionKind::kPendingLocal, + DescriptionKind::kPendingRemote}) { + view(kind) = ReadDescription(pc, kind); + snapshot.live.push_back(view(kind).description); + } + // the pending description if there's one, else the current one (webrtc-pc) + for (auto [kind, pending, current] : + {std::tuple(DescriptionKind::kLocal, DescriptionKind::kPendingLocal, DescriptionKind::kCurrentLocal), + std::tuple(DescriptionKind::kRemote, DescriptionKind::kPendingRemote, DescriptionKind::kCurrentRemote)}) { + view(kind) = view(pending).description != nullptr ? view(pending) : view(current); } - snapshot.live = liveDescriptions(pc); const std::scoped_lock lock(_descriptionsMutex); auto id = ++_lastSnapshot; _snapshots.emplace(id, std::move(snapshot)); diff --git a/python-webrtc/cpp/src/interfaces/rtc_peer_connection/ice.cpp b/python-webrtc/cpp/src/interfaces/rtc_peer_connection/ice.cpp index 333289e..d83e894 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_peer_connection/ice.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_peer_connection/ice.cpp @@ -71,24 +71,46 @@ namespace python_webrtc { namespace { - // the DTLS transports of the media sections and of the data channels, some of them shared (bundled) - std::vector> - dtlsTransports(const webrtc::scoped_refptr &pc) { - std::vector> transports; + using DtlsTransport = webrtc::scoped_refptr; + using Transceivers = std::vector>; + + std::vector dtlsTransports(const webrtc::scoped_refptr &pc, + const DtlsTransport &sctp) { + std::vector transports; for (const auto &transceiver : pc->GetTransceivers()) { transports.push_back(transceiver->sender()->dtls_transport()); transports.push_back(transceiver->receiver()->dtls_transport()); } - if (auto sctp = pc->GetSctpTransport()) { - transports.push_back(sctp->dtls_transport()); - } + transports.push_back(sctp); std::erase(transports, nullptr); return transports; } + DtlsTransport dtlsTransportOf(const webrtc::scoped_refptr &pc, + const Transceivers &transceivers, const DtlsTransport &sctp, + const webrtc::ContentInfo &content) { + if (content.rejected) { + return nullptr; + } + DtlsTransport known; + if (content.type == webrtc::MediaProtocolType::kSctp) { + known = sctp; + } else if (auto transceiver = std::ranges::find_if( + transceivers, [&](const auto &candidate) { return candidate->mid() == content.mid(); }); + transceiver != transceivers.end()) { + if ((*transceiver)->stopped()) { + return nullptr; + } + known = (*transceiver)->sender()->dtls_transport(); + } + // no transceiver, or a rollback took its transport: the lookup waits for the network thread + return known ? known : pc->LookupDtlsTransportByMid(content.mid()); + } + std::map> - iceTransportsByMid(const webrtc::scoped_refptr &pc) { + iceTransportsByMid(const webrtc::scoped_refptr &pc, const DtlsTransport &sctp) { std::map> byMid; + auto transceivers = pc->GetTransceivers(); for (const auto *description : {pc->local_description(), pc->remote_description()}) { if (description == nullptr) { continue; @@ -97,7 +119,7 @@ namespace python_webrtc { if (byMid.contains(content.mid())) { continue; } - auto dtls = pc->LookupDtlsTransportByMid(content.mid()); + auto dtls = dtlsTransportOf(pc, transceivers, sctp, content); if (auto wrapper = dtls ? RTCDtlsTransport::registry().Find(dtls.get()) : nullptr) { byMid.emplace(content.mid(), wrapper->GetIceTransport()); } @@ -106,22 +128,47 @@ namespace python_webrtc { return byMid; } + std::vector> + iceTransportsOf(const std::vector> &dtlsTransports) { + std::vector> iceTransports; + for (const auto &dtls : dtlsTransports) { + auto ice = dtls ? dtls->GetIceTransport() : nullptr; + if (ice && std::ranges::find(iceTransports, ice) == iceTransports.end()) { + iceTransports.push_back(std::move(ice)); + } + } + return iceTransports; + } + } // namespace + RTCPeerConnection::SctpTransports + RTCPeerConnection::SctpTransportsOf(const webrtc::scoped_refptr &pc) { + SctpTransports transports; + if (!pc) { + return transports; + } + // the connection's getter waits for the network thread too, where the transport is read + BlockingCallOn(_factory->workerThread(), [&]() { + transports.sctp = pc->GetSctpTransport(); + transports.dtls = transports.sctp ? transports.sctp->dtls_transport() : nullptr; + }); + return transports; + } + std::vector> RTCPeerConnection::IceTransports() { - std::vector> iceTransports; auto pc = connection(); if (!pc) { - return iceTransports; + return {}; } - for (const auto &transport : dtlsTransports(pc)) { - auto dtls = RTCDtlsTransport::registry().Find(transport.get()); - auto ice = dtls ? dtls->GetIceTransport() : nullptr; - if (ice && std::ranges::find(iceTransports, ice) == iceTransports.end()) { - iceTransports.push_back(ice); - } + auto sctp = SctpTransportsOf(pc).dtls; + auto transports = BlockingCallOn(_factory->signalingThread(), [&]() { return dtlsTransports(pc, sctp); }); + std::vector> wrappers; + wrappers.reserve(transports.size()); + for (const auto &transport : transports) { + wrappers.push_back(RTCDtlsTransport::registry().Find(transport.get())); } - return iceTransports; + return iceTransportsOf(wrappers); } std::shared_ptr RTCPeerConnection::IceTransportByMid(const std::string &mid) { @@ -137,14 +184,14 @@ namespace python_webrtc { return wrapper ? wrapper->GetIceTransport() : nullptr; } - std::vector> RTCPeerConnection::WrapTransports() { + std::vector> RTCPeerConnection::WrapTransports(const SctpTransports &sctp) { std::vector> created; auto pc = connection(); if (!pc) { return created; } std::vector> wrappers; - for (const auto &transport : dtlsTransports(pc)) { + for (const auto &transport : dtlsTransports(pc, sctp.dtls)) { const bool existed = RTCDtlsTransport::registry().Find(transport.get()) != nullptr; auto wrapper = RTCDtlsTransport::registry().GetOrCreate(_factory, transport); Adopt(wrapper); @@ -166,7 +213,7 @@ namespace python_webrtc { } _negotiatedTransports.assign(wrappers.begin(), wrappers.end()); } - auto byMid = iceTransportsByMid(pc); + auto byMid = iceTransportsByMid(pc, sctp.dtls); { const TrackedLock lock(_wrappersMutex); std::swap(_dtlsTransports, wrappers); @@ -193,14 +240,11 @@ namespace python_webrtc { return; } - std::vector> transports; - webrtc::scoped_refptr sctpTransport; - BlockingCallOn(_factory->signalingThread(), [&]() { - transports = dtlsTransports(pc); - sctpTransport = pc->GetSctpTransport(); - }); + auto sctpTransports = SctpTransportsOf(pc); + auto transports = + BlockingCallOn(_factory->signalingThread(), [&]() { return dtlsTransports(pc, sctpTransports.dtls); }); - if (auto sctp = sctpTransport ? RTCSctpTransport::registry().Find(sctpTransport.get()) : nullptr) { + if (auto sctp = sctpTransports.sctp ? RTCSctpTransport::registry().Find(sctpTransports.sctp.get()) : nullptr) { sctp->OnPeerConnectionClosed(); } for (const auto &transport : transports) { @@ -219,7 +263,12 @@ namespace python_webrtc { auto state = pc->signaling_state(); if (state == SignalingState::kStable || state == SignalingState::kHaveLocalPrAnswer || state == SignalingState::kHaveRemotePrAnswer) { - for (const auto &iceTransport : IceTransports()) { + std::vector> wrapped; + { + const TrackedLock lock(_wrappersMutex); + wrapped = _dtlsTransports; + } + for (const auto &iceTransport : iceTransportsOf(wrapped)) { iceTransport->SetRoleKnown(); } } @@ -269,13 +318,15 @@ namespace python_webrtc { if (!pc) { return {}; } + auto sctp = SctpTransportsOf(pc).dtls; return BlockingCallOn(_factory->signalingThread(), [&]() -> std::optional> { const auto *description = local ? pc->local_description() : pc->remote_description(); if (!description) { return std::nullopt; } + auto transceivers = pc->GetTransceivers(); for (const auto &content : description->description()->contents()) { - auto dtls = pc->LookupDtlsTransportByMid(content.mid()); + auto dtls = dtlsTransportOf(pc, transceivers, sctp, content); const auto *info = description->description()->GetTransportInfoByName(content.mid()); if (dtls && dtls->ice_transport().get() == iceTransport && info) { return std::make_pair(info->description.ice_ufrag, info->description.ice_pwd); diff --git a/python-webrtc/cpp/src/interfaces/rtc_peer_connection/rtc_peer_connection.cpp b/python-webrtc/cpp/src/interfaces/rtc_peer_connection/rtc_peer_connection.cpp index 03cf622..1f9e6db 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_peer_connection/rtc_peer_connection.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_peer_connection/rtc_peer_connection.cpp @@ -463,7 +463,6 @@ namespace python_webrtc { throw RTCException(result.error()); } - QueueNegotiationNeeded(); return Wrap(_senders, result.value()); } @@ -503,7 +502,6 @@ namespace python_webrtc { if (!error.ok()) { throw RTCException(error); } - QueueNegotiationNeeded(); } std::shared_ptr @@ -532,7 +530,6 @@ namespace python_webrtc { if (!result.ok()) { throw RTCException(result.error()); } - QueueNegotiationNeeded(); return Wrap(_transceivers, result.value()); } @@ -588,7 +585,6 @@ namespace python_webrtc { : webrtc::RTCErrorType::UNSUPPORTED_OPERATION, error.message()); } - QueueNegotiationNeeded(); return Wrap(_channels, result.MoveValue()); } @@ -660,15 +656,13 @@ namespace python_webrtc { }); } - void RTCPeerConnection::UpdateMaxMessageSize() { + void RTCPeerConnection::UpdateMaxMessageSize(const SctpTransports &sctp) { auto pc = connection(); if (!pc) { return; } // read by send() without waiting for the signaling thread - _maxMessageSize = BlockingCallOn(_factory->signalingThread(), [&pc]() { - return pc->GetSctpTransport() ? NegotiatedMaxMessageSize(*pc) : std::numeric_limits::quiet_NaN(); - }); + _maxMessageSize = sctp.sctp ? NegotiatedMaxMessageSize(*pc) : std::numeric_limits::quiet_NaN(); } ConfigurationInit RTCPeerConnection::GetConfiguration() { @@ -705,17 +699,16 @@ namespace python_webrtc { void RTCPeerConnection::RestartIce() { auto pc = connection(); - // before the first local description, there are no credentials to replace: the first offer has new ones - if (pc && (pc->local_description() != nullptr)) { - pc->RestartIce(); - QueueNegotiationNeeded(); + if (!pc) { + return; } - } - void RTCPeerConnection::QueueNegotiationNeeded() { - // libwebrtc posts the negotiationneeded event of a change to the signaling thread: once the thread ran it, - // the event is queued during the call - BlockingCallOn(_factory->signalingThread(), []() {}); + BlockingCallOn(_factory->signalingThread(), [&pc]() { + // before the first local description, there are no credentials to replace: the first offer has new ones + if (pc->local_description() != nullptr) { + pc->RestartIce(); + } + }); } bool RTCPeerConnection::ShouldFireNegotiationNeededEvent(uint32_t eventId) { diff --git a/python-webrtc/cpp/src/interfaces/rtc_peer_connection/rtc_peer_connection.h b/python-webrtc/cpp/src/interfaces/rtc_peer_connection/rtc_peer_connection.h index 50cc5a2..2264ab5 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_peer_connection/rtc_peer_connection.h +++ b/python-webrtc/cpp/src/interfaces/rtc_peer_connection/rtc_peer_connection.h @@ -264,14 +264,18 @@ namespace python_webrtc { template using Wrappers = std::unordered_map>; + struct SctpTransports { + webrtc::scoped_refptr sctp; + webrtc::scoped_refptr dtls; + }; + // Python threads may call close() concurrently with other methods (the GIL is released) webrtc::scoped_refptr connection(); // a closed connection still has stats and transceivers webrtc::scoped_refptr closedConnection(); - // The connection to collect the stats of, even closed, with its stats cache cleared: libwebrtc reuses a report - // for 50 ms, the stats are the current ones. None fails the request. + // the connection to collect the stats of, even closed; none fails the request webrtc::scoped_refptr StatsConnection(Completion &completion); // the wrapper of a libwebrtc object of this connection, which the connection keeps while it's open @@ -309,11 +313,8 @@ namespace python_webrtc { // the SCTP transport's maxMessageSize, cached in kept while the connection still uses that transport std::optional MaxMessageSize(const webrtc::SctpTransportInterface *transport, std::optional &kept); - // the spec's "update the data max message size", for a transport Python may hold - void UpdateMaxMessageSize(); - - // waits for the negotiationneeded event a change may have caused to be emitted - void QueueNegotiationNeeded(); + // the spec's "update the data max message size", for a transport Python may hold; on the signaling thread + void UpdateMaxMessageSize(const SctpTransports &sctp); std::shared_ptr AddedTransceiver(webrtc::RTCErrorOr> result); @@ -351,6 +352,9 @@ namespace python_webrtc { // On the signaling thread. uint64_t SnapshotDescriptions(); + // read in one wait for the network thread, which libwebrtc's getter waits for anyway + SctpTransports SctpTransportsOf(const webrtc::scoped_refptr &pc); + // the ICE transports of the connection that Python has wrappers of std::vector> IceTransports(); @@ -359,7 +363,7 @@ namespace python_webrtc { // Wraps the transports a description creates, so their states are followed from the start. Returns the new // ICE transports, which get their gathering event once the description is set. On the signaling thread. - std::vector> WrapTransports(); + std::vector> WrapTransports(const SctpTransports &sctp); // the transports of a closed connection fire no events void UnbindTransports(const webrtc::scoped_refptr &pc); diff --git a/python-webrtc/cpp/src/interfaces/rtc_peer_connection/rtp.cpp b/python-webrtc/cpp/src/interfaces/rtc_peer_connection/rtp.cpp index a2c4300..2b8f33c 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_peer_connection/rtp.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_peer_connection/rtp.cpp @@ -149,33 +149,38 @@ namespace python_webrtc { } if (!pc) { completion.Fail(RTCCallbackException(closedError("getStats"))); - return nullptr; } - pc->ClearStatsCache(); return pc; } void RTCPeerConnection::GetStats(std::shared_ptr mailbox, uint64_t token) { Completion completion(std::move(mailbox), token); if (auto pc = StatsConnection(completion)) { - auto trackIds = remoteTrackIds(pc); - pc->GetStats(webrtc::make_ref_counted(std::move(completion), std::move(trackIds)).get()); + BlockingCallOn(_factory->signalingThread(), [&]() { + pc->ClearStatsCache(); + pc->GetStats(webrtc::make_ref_counted(std::move(completion), remoteTrackIds(pc)).get()); + }); } } void RTCPeerConnection::CollectStats(const webrtc::scoped_refptr &sender, Completion completion) { if (auto pc = StatsConnection(completion)) { - pc->GetStats(sender, webrtc::make_ref_counted(std::move(completion))); + BlockingCallOn(_factory->signalingThread(), [&]() { + pc->ClearStatsCache(); + pc->GetStats(sender, webrtc::make_ref_counted(std::move(completion))); + }); } } void RTCPeerConnection::CollectStats(const webrtc::scoped_refptr &receiver, Completion completion) { if (auto pc = StatsConnection(completion)) { - auto trackIds = remoteTrackIds(pc); - pc->GetStats(receiver, - webrtc::make_ref_counted(std::move(completion), std::move(trackIds))); + BlockingCallOn(_factory->signalingThread(), [&]() { + pc->ClearStatsCache(); + pc->GetStats(receiver, + webrtc::make_ref_counted(std::move(completion), remoteTrackIds(pc))); + }); } } diff --git a/python-webrtc/cpp/src/interfaces/rtc_peer_connection/stats_collector_callback.h b/python-webrtc/cpp/src/interfaces/rtc_peer_connection/stats_collector_callback.h index ea3322b..01891a4 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_peer_connection/stats_collector_callback.h +++ b/python-webrtc/cpp/src/interfaces/rtc_peer_connection/stats_collector_callback.h @@ -21,7 +21,7 @@ namespace python_webrtc { - // Delivers a stats report as JSON, which Python turns into webrtc.RTCStatsReport + // Delivers a stats report as JSON, made on the loop thread, which Python turns into webrtc.RTCStatsReport class StatsCollectorCallback : public webrtc::RTCStatsCollectorCallback { public: // remoteTrackIds by mid: libwebrtc drops trackIdentifier once a transceiver stops @@ -43,7 +43,11 @@ namespace python_webrtc { completed->Take(stats->id()); completed->AddStats(std::move(stats)); } - _completion.Succeed(completed ? completed->ToJson() : report->ToJson()); + webrtc::scoped_refptr delivered = report; + if (completed) { + delivered = completed; + } + _completion.SucceedWith([delivered]() { return delivered->ToJson(); }); } private: diff --git a/python-webrtc/cpp/src/utils/mailbox.h b/python-webrtc/cpp/src/utils/mailbox.h index d10d3b5..739415c 100644 --- a/python-webrtc/cpp/src/utils/mailbox.h +++ b/python-webrtc/cpp/src/utils/mailbox.h @@ -174,6 +174,11 @@ namespace python_webrtc { Settle(false, [result...]() { return pybind11::make_tuple(result...); }); } + template + void SucceedWith(Convert convert) { + Settle(false, [convert = std::move(convert)]() { return pybind11::make_tuple(convert()); }); + } + void Fail(RTCCallbackException error); private: diff --git a/python-webrtc/python/webrtc/interfaces/rtc_peer_connection.py b/python-webrtc/python/webrtc/interfaces/rtc_peer_connection.py index 86aa26d..b9b9dce 100644 --- a/python-webrtc/python/webrtc/interfaces/rtc_peer_connection.py +++ b/python-webrtc/python/webrtc/interfaces/rtc_peer_connection.py @@ -44,7 +44,7 @@ from webrtc.models.rtp_parameters import RTCRtpCodec from webrtc.utils import lifetime from webrtc.utils.events import AnyHandler, EventTarget, HandlerDecorator -from webrtc.utils.loops import call_native +from webrtc.utils.loops import call_native, call_native_batched from webrtc.utils.operations import OperationsChain, later from webrtc.utils.strings import usv_string @@ -769,6 +769,8 @@ def create_data_channel( async def get_stats(self, selector: webrtc.MediaStreamTrack | None = None) -> webrtc.RTCStatsReport: """Collects the stats of the connection, or of the sender or the receiver of a track. + Calls in the same loop iteration, like those one :func:`asyncio.gather` starts, share one collection. + See :mdn:`RTCPeerConnection/getStats`. Args: @@ -789,8 +791,12 @@ async def get_stats(self, selector: webrtc.MediaStreamTrack | None = None) -> we msg = f'{len(matches)} senders and receivers have the track, not exactly one' raise webrtc.InvalidAccessError(msg) return await matches[0].get_stats() - report = cast('str', await call_native(self._native_obj.getStats)) - return RTCStatsReport._from_native(report, self.get_receivers()) + report = await call_native_batched( + self, + self._native_obj.getStats, + lambda json: RTCStatsReport._from_native(cast('str', json), self.get_receivers()), + ) + return report._copy() @staticmethod async def generate_certificate(keygen_algorithm: AlgorithmIdentifier) -> webrtc.RTCCertificate: diff --git a/python-webrtc/python/webrtc/interfaces/rtc_rtp_receiver.py b/python-webrtc/python/webrtc/interfaces/rtc_rtp_receiver.py index 2c034b3..f73dfaf 100644 --- a/python-webrtc/python/webrtc/interfaces/rtc_rtp_receiver.py +++ b/python-webrtc/python/webrtc/interfaces/rtc_rtp_receiver.py @@ -20,7 +20,7 @@ from webrtc.models.rtc_stats import RTCStatsReport from webrtc.models.rtp_parameters import RTCRtpCapabilities, RTCRtpReceiveParameters from webrtc.models.rtp_source import RTCRtpContributingSource, RTCRtpSynchronizationSource -from webrtc.utils.loops import call_native +from webrtc.utils.loops import call_native_batched _SourceT = TypeVar('_SourceT', bound=RTCRtpContributingSource) @@ -137,6 +137,8 @@ def get_capabilities(kind: webrtc.MediaType | webrtc.MediaTypeValue) -> webrtc.R async def get_stats(self) -> webrtc.RTCStatsReport: """Collects the stats of the receiver and of the objects they refer to. + Calls in the same loop iteration, like those one :func:`asyncio.gather` starts, share one collection. + See :mdn:`RTCRtpReceiver/getStats`. Returns: @@ -145,7 +147,10 @@ async def get_stats(self) -> webrtc.RTCStatsReport: Raises: webrtc.InvalidStateError: If the connection is closed. """ - return RTCStatsReport._from_native(cast('str', await call_native(self._native_obj.getStats)), [self]) + report = await call_native_batched( + self, self._native_obj.getStats, lambda json: RTCStatsReport._from_native(cast('str', json), [self]) + ) + return report._copy() def get_synchronization_sources(self) -> list[webrtc.RTCRtpSynchronizationSource]: """Returns the synchronization sources (SSRCs) of the media received in the last 10 seconds. diff --git a/python-webrtc/python/webrtc/interfaces/rtc_rtp_sender.py b/python-webrtc/python/webrtc/interfaces/rtc_rtp_sender.py index 15b9fc8..85ca9ab 100644 --- a/python-webrtc/python/webrtc/interfaces/rtc_rtp_sender.py +++ b/python-webrtc/python/webrtc/interfaces/rtc_rtp_sender.py @@ -23,7 +23,7 @@ from webrtc.models.rtp_parameters import RTCRtpCapabilities, RTCRtpSendParameters from webrtc.utils import lifetime from webrtc.utils.events import EventTarget -from webrtc.utils.loops import LoopState, call_native +from webrtc.utils.loops import LoopState, call_native, call_native_batched from webrtc.utils.operations import later if TYPE_CHECKING: @@ -250,6 +250,8 @@ def get_capabilities(kind: webrtc.MediaType | webrtc.MediaTypeValue) -> webrtc.R async def get_stats(self) -> webrtc.RTCStatsReport: """Collects the stats of the sender and of the objects they refer to. + Calls in the same loop iteration, like those one :func:`asyncio.gather` starts, share one collection. + See :mdn:`RTCRtpSender/getStats`. Returns: @@ -258,7 +260,10 @@ async def get_stats(self) -> webrtc.RTCStatsReport: Raises: webrtc.InvalidStateError: If the connection is closed. """ - return RTCStatsReport._from_native(cast('str', await call_native(self._native_obj.getStats))) + report = await call_native_batched( + self, self._native_obj.getStats, lambda json: RTCStatsReport._from_native(cast('str', json)) + ) + return report._copy() #: Alias for :attr:`get_stats` getStats = get_stats diff --git a/python-webrtc/python/webrtc/models/rtc_stats.py b/python-webrtc/python/webrtc/models/rtc_stats.py index c2722fd..3046d3f 100644 --- a/python-webrtc/python/webrtc/models/rtc_stats.py +++ b/python-webrtc/python/webrtc/models/rtc_stats.py @@ -9,6 +9,8 @@ from __future__ import annotations +import copy +import functools import json import warnings from collections.abc import Iterable, Iterator, Mapping @@ -70,25 +72,35 @@ class RTCStats(Dictionary): def __init__(self, **members: object) -> None: cls = self.__class__ - unknown = sorted(members.keys() - {field.name for field in fields(cls)}) + names, defaults, enums = _layout(cls) + unknown = sorted(members.keys() - names) if len(unknown) > 0: msg = f'{cls.__name__} has no member {unknown[0]!r}' raise TypeError(msg) - enums: dict[str, type[enum.Enum]] = {} - for klass in cls.__mro__: - own: Mapping[str, type[enum.Enum]] = vars(klass).get('_enums', {}) - enums.update(own) - for field in fields(cls): - if field.name in members: - value = members[field.name] - elif field.default is not MISSING: - value = field.default + for name, default in defaults: + if name in members: + value = members[name] + elif default is not MISSING: + value = default else: - msg = f'{cls.__name__} is missing the required member {field.name!r}' + msg = f'{cls.__name__} is missing the required member {name!r}' raise TypeError(msg) - if field.name in enums and isinstance(value, str): - value = _enum_value(enums[field.name], value) - setattr(self, field.name, value) + if name in enums and isinstance(value, str): + value = _enum_value(enums[name], value) + setattr(self, name, value) + + +@functools.cache +def _layout( + cls: type[RTCStats], +) -> tuple[frozenset[str], tuple[tuple[str, object], ...], Mapping[str, type[enum.Enum]]]: + """The member names of a stats dictionary, each with its default (or MISSING), and the enums of its members.""" + enums: dict[str, type[enum.Enum]] = {} + for klass in cls.__mro__: + own: Mapping[str, type[enum.Enum]] = vars(klass).get('_enums', {}) + enums.update(own) + defaults = tuple((field.name, field.default) for field in fields(cls)) + return frozenset(name for name, _ in defaults), defaults, enums @dataclass(init=False) @@ -1312,6 +1324,19 @@ def _from_native(cls, report: str, receivers: Iterable[webrtc.RTCRtpReceiver] = stats = [_stats(entry) for entry in entries] return cls({entry.id: entry for entry in stats}) + def _copy(self) -> RTCStatsReport: + """A copy whose stats, and their dict members, change apart from these: one caller's of a shared report.""" + copied: dict[str, RTCStats] = {} + for stats_id, stats in self._stats.items(): + duplicate = copy.copy(stats) + for name, value in vars(duplicate).items(): + if isinstance(value, dict): + setattr(duplicate, name, dict(value)) + + copied[stats_id] = duplicate + + return RTCStatsReport(copied) + def __getitem__(self, stats_id: str) -> RTCStats: return self._stats[stats_id] diff --git a/python-webrtc/python/webrtc/utils/loops.py b/python-webrtc/python/webrtc/utils/loops.py index 1bd4088..bac4879 100644 --- a/python-webrtc/python/webrtc/utils/loops.py +++ b/python-webrtc/python/webrtc/utils/loops.py @@ -19,7 +19,7 @@ import sys import threading import weakref -from typing import TYPE_CHECKING, Callable, ClassVar, NamedTuple, cast +from typing import TYPE_CHECKING, Callable, ClassVar, NamedTuple, TypeVar, cast import wrtc from webrtc.exceptions import InvalidStateError @@ -34,6 +34,8 @@ _P = ParamSpec('_P') +_T = TypeVar('_T') + #: (event name or None, token, failed, args or None) _Record = tuple['str | None', int, bool, 'tuple[object, ...] | None'] @@ -137,6 +139,7 @@ def __init__(self, loop: asyncio.AbstractEventLoop) -> None: self._loop_ref = weakref.ref(loop) self.mailbox = wrtc.Mailbox() self.pending: dict[int, _Call] = {} + self.batches: dict[object, asyncio.Future[object]] = {} #: kept until done, or they'd be collected self.tasks: set[asyncio.Future[object]] = set() #: objects kept alive while they can fire a handled event, with the reason @@ -451,6 +454,8 @@ def _run(self) -> bool: if item.resumes: self._resumed = True _ = self._live_loop.call_soon(self._settle) + + self.batches.clear() _ = item.run() return item.resumes @@ -771,16 +776,41 @@ def _exit() -> None: state.release() -async def call_native( - method: Callable[Concatenate[wrtc.Mailbox, int, _P], None], *args: _P.args, **kwargs: _P.kwargs -) -> object: - """Awaits ``method(mailbox, token, ...)``, resuming after the events emitted before its completion.""" - state = LoopState.of(asyncio.get_running_loop()) +def _start_native( + state: LoopState, method: Callable[Concatenate[wrtc.Mailbox, int, _P], None], *args: _P.args, **kwargs: _P.kwargs +) -> asyncio.Future[object]: + """Calls ``method(mailbox, token, ...)``; returns the future its completion settles.""" native: lifetime.Native | None = getattr(method, '__self__', None) wrapper = lifetime.find(native) if native is not None else None token, future = state.start(wrapper._target() if wrapper is not None else None) method(state.mailbox, token, *args, **kwargs) - return await future + return future + + +async def call_native( + method: Callable[Concatenate[wrtc.Mailbox, int, _P], None], *args: _P.args, **kwargs: _P.kwargs +) -> object: + """Awaits ``method(mailbox, token, ...)``, resuming after the events emitted before its completion.""" + return await _start_native(LoopState.of(asyncio.get_running_loop()), method, *args, **kwargs) + + +async def _converted(future: asyncio.Future[object], convert: Callable[[object], _T]) -> _T: + return convert(await future) + + +async def call_native_batched( + owner: object, method: Callable[[wrtc.Mailbox, int], None], convert: Callable[[object], _T] +) -> _T: + """Awaits ``convert(result)`` of ``method(mailbox, token)``, shared by the calls on ``owner`` in one iteration.""" + loop = asyncio.get_running_loop() + state = LoopState.of(loop) + batch = cast('asyncio.Future[_T] | None', state.batches.get(owner)) + if batch is None: + batch = asyncio.ensure_future(_converted(_start_native(state, method), convert)) + state.batches[owner] = cast('asyncio.Future[object]', batch) + _ = loop.call_soon(state.batches.pop, owner, None) + + return await asyncio.shield(batch) gc.callbacks.append(_on_collection) diff --git a/python-webrtc/python/webrtc/utils/names.py b/python-webrtc/python/webrtc/utils/names.py index 35f5dc4..ae101b6 100644 --- a/python-webrtc/python/webrtc/utils/names.py +++ b/python-webrtc/python/webrtc/utils/names.py @@ -9,6 +9,7 @@ from __future__ import annotations +import functools import re from typing import TYPE_CHECKING, Generic, TypeVar, overload @@ -24,6 +25,7 @@ def camel_case(name: str) -> str: return _CAMEL.sub(lambda match: match.group(1).upper(), name) +@functools.lru_cache(maxsize=1024) def snake_case(name: str) -> str: """``'namedCurve'`` -> ``'named_curve'``.""" return ''.join(f'_{c.lower()}' if c.isupper() else c for c in name) @@ -32,7 +34,7 @@ def snake_case(name: str) -> str: def members(value: Mapping[str, object], names: Iterable[str]) -> dict[str, object]: """The members of a dictionary, with snake_case or camelCase names: unknown ones are ignored, as in WebIDL.""" wanted = set(names) - return {snake_case(name): member for name, member in value.items() if snake_case(name) in wanted} + return {snake: member for name, member in value.items() if (snake := snake_case(name)) in wanted} class Alias(Generic[_T]): diff --git a/tests/test_data_channel.py b/tests/test_data_channel.py index e724e0d..76ff652 100644 --- a/tests/test_data_channel.py +++ b/tests/test_data_channel.py @@ -13,7 +13,7 @@ import math import re import sys -from typing import cast +from typing import Callable, cast import pytest from typing_extensions import TypedDict, Unpack @@ -29,6 +29,7 @@ wait_until, ) from tests.timing import seconds +from webrtc.utils import loops #: The most a channel queues to send, in bytes SEND_QUEUE_LIMIT = 16 * 1024 * 1024 @@ -591,6 +592,59 @@ async def bytes_received() -> int | None: assert after == before + 5 +@pytest.mark.asyncio +async def test_concurrent_stats_share_a_collection( + caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection, monkeypatch: pytest.MonkeyPatch +) -> None: + """Stats requests of one loop iteration share a collection, one after an observed message starts another.""" + channel, remote = await open_pair(caller, callee) + started: list[str] = [] + start = loops._start_native + + def counted(state: loops.LoopState, method: Callable[..., None], *args: object) -> asyncio.Future[object]: + # late trickled candidates also start native calls + if method.__name__ == 'getStats': + started.append(method.__name__) + return start(state, method, *args) + + monkeypatch.setattr(loops, '_start_native', counted) + + reports = await asyncio.gather(*(callee.get_stats() for _ in range(5))) + assert started == ['getStats'] + # each caller gets its own report + assert len({id(report) for report in reports}) == len(reports) + assert len({id(stats) for report in reports for stats in report.values()}) == sum(map(len, reports)) + + received = wait_for_event(remote, 'message') + channel.send('hello') + await received + [stats] = stats_of_type(await callee.get_stats(), 'data-channel') + assert started == ['getStats', 'getStats'] + assert isinstance(stats, webrtc.RTCDataChannelStats) + assert stats.messages_received == 1 + + +@pytest.mark.asyncio +async def test_a_delivered_message_ends_the_stats_batch( + caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection +) -> None: + """A stats request made in a message handler doesn't join one started before the message, in any loop.""" + channel, remote = await open_pair(caller, callee) + state = loops.LoopState.of(asyncio.get_running_loop()) + batched: list[bool] = [] + + @remote.on('message') + def on_message(_: object) -> None: + batched.append(callee in state.batches) + + received = wait_for_event(remote, 'message') + # a batch still open when the message arrives, as on loops that drain records in the same iteration + state.batches[callee] = asyncio.get_running_loop().create_future() + channel.send('hello') + await received + assert batched == [False] + + @pytest.mark.asyncio async def test_max_channels_once_connected(caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection) -> None: """The max number of channels is known once SCTP is connected."""