diff --git a/docs/source/guides/connections.md b/docs/source/guides/connections.md index ce46a6e..b6700b1 100644 --- a/docs/source/guides/connections.md +++ b/docs/source/guides/connections.md @@ -95,8 +95,9 @@ for stats in report.values(): ## Closing -{meth}`~webrtc.RTCPeerConnection.close` stops the transports and the media at once. A closed connection can't be -reused, so create a new one to connect again. +{meth}`~webrtc.RTCPeerConnection.close` stops the transports and the media at once: its transceivers read stopped +right away, and remote tracks end with their `ended` event. A closed connection can't be reused, so create a new one +to connect again. Calls pending when the connection closes are cancelled: they raise {obj}`asyncio.CancelledError`. diff --git a/python-webrtc/cpp/NATIVE.md b/python-webrtc/cpp/NATIVE.md index 50ea603..50768c0 100644 --- a/python-webrtc/cpp/NATIVE.md +++ b/python-webrtc/cpp/NATIVE.md @@ -74,6 +74,8 @@ constructors. first binding holds until that loop is released. Children the connection creates natively inherit its binding, so their records queue behind the parent's event. Closing a connection unbinds its children (`Surfaced` reads `IsBound()`). `HeldEvents` only orders events inside libwebrtc callbacks. +- A description operation holds its events (`HeldOperationEvents`) until it completes, taking the hold in its first + signaling task, which runs after the previous operation released its own. - `DefineBinding(cls)` defines `_bind`, `_bound`, `_mailbox` and `_orphaned` (its mailbox closed) per class, not on a pybind base: a base method costs a derived-to-base cast per call (6–8 µs vs 0.1 µs). - `NativeObject` (`utils/native_object.h`) counts objects per `T::kName` (`AliveCounts()`) and exposes `_id`, @@ -113,7 +115,7 @@ alive while it can still fire an event that has a handler, following the WebRTC `_activity()`). Only the bound loop roots an object, so whatever makes it active binds it first, and whatever unbinds it ends its activity first (`close()`) or orphans it (a release): an active object is bound or orphaned at every instant. A closed or collected loop releases what it kept: its mailbox closes and its queued records are dropped, -pending operations are cancelled, a callback queued into it with `call_soon` runs its drop handler instead, and its +pending operations are cancelled, a callback posted into it with a drop handler runs that instead, and its handlers are removed. Its objects with handlers left on another loop are bound to that loop. Everything else is plain Python garbage. @@ -121,6 +123,8 @@ Python garbage. Python goes through `_changing()` (a spent `once` handler too), so roots checks on other loops wait for it. An unbind updates roots after unbinding, and `LoopState.update` re-reads the binding after storing a root (`_recheck`): whichever runs last sees the other. +- An object is kept for the events still owed to it, not for the state it shows (an announced data channel reads + open before its open event). - Each class's `_open()` is its delivery gate: once it's false, even queued events don't fire (a stopped track, a detached transform, a closed connection). The base gate is "its connection isn't closed". `GATES` in `tests/test_lifetime_model.py` lists every class's gate; a new class with events is added there. diff --git a/python-webrtc/cpp/src/interfaces/media_stream_track.cpp b/python-webrtc/cpp/src/interfaces/media_stream_track.cpp index 1b6adda..8cd8128 100644 --- a/python-webrtc/cpp/src/interfaces/media_stream_track.cpp +++ b/python-webrtc/cpp/src/interfaces/media_stream_track.cpp @@ -21,10 +21,15 @@ namespace python_webrtc { webrtc::scoped_refptr track) : _factory(std::move(factory)), _track(std::move(track)), _source(SourceControl::Find(_track.get(), _track->id())) { + // live until its ended event, unless read unbound + _surfacedEnded.Changed(true, false); _factory->signalingThread()->PostTask(Guard([this]() { _track->RegisterObserver(this); _observing = true; AttachMonitor(); + if (_track->state() == webrtc::MediaStreamTrackInterface::TrackState::kEnded) { + EndOnSignalingThread(); + } })); } @@ -261,11 +266,12 @@ namespace python_webrtc { } webrtc::MediaStreamTrackInterface::TrackState MediaStreamTrack::GetReadyState() { - const bool ended = _ended || _track->state() == webrtc::MediaStreamTrackInterface::TrackState::kEnded; - // unbound (outside of an event loop), no event is going to surface it - const bool surfaced = _surfacedEnded.Shown(IsBound(), ended); - return surfaced ? webrtc::MediaStreamTrackInterface::TrackState::kEnded - : webrtc::MediaStreamTrackInterface::TrackState::kLive; + // unbound (outside of an event loop), no event is going to surface it: libwebrtc's, on the signaling thread + const bool ended = _surfacedEnded.ShownOr(IsBound(), [this]() { + return _ended || _track->state() == webrtc::MediaStreamTrackInterface::TrackState::kEnded; + }); + return ended ? webrtc::MediaStreamTrackInterface::TrackState::kEnded + : webrtc::MediaStreamTrackInterface::TrackState::kLive; } bool MediaStreamTrack::GetMuted() { diff --git a/python-webrtc/cpp/src/interfaces/rtc_data_channel.cpp b/python-webrtc/cpp/src/interfaces/rtc_data_channel.cpp index 2362506..ce1b0e0 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_data_channel.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_data_channel.cpp @@ -97,6 +97,7 @@ namespace python_webrtc { pybind11::arg("data"), pybind11::arg("binary")) .def("close", &RTCDataChannel::Close, nogil()) .def("_surfaceState", &RTCDataChannel::SurfaceState, nogil(), pybind11::arg("state")) + .def_property_readonly("_openDelivered", nogil_fn(&RTCDataChannel::GetOpenDelivered)) .def("_decreaseBufferedAmount", &RTCDataChannel::DecreaseBufferedAmount, nogil(), pybind11::arg("sent")); } @@ -245,9 +246,15 @@ namespace python_webrtc { if (_closeRequested && state == DataState::kOpen) { return; } + _openDelivered = _openDelivered || state == DataState::kOpen; _surfacedState.Surface(state); } + bool RTCDataChannel::GetOpenDelivered() { + const std::scoped_lock lock(_closeMutex); + return _openDelivered; + } + uint64_t RTCDataChannel::GetBufferedAmount() { return _bufferedAmount; } diff --git a/python-webrtc/cpp/src/interfaces/rtc_data_channel.h b/python-webrtc/cpp/src/interfaces/rtc_data_channel.h index 7d0b95b..da35199 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_data_channel.h +++ b/python-webrtc/cpp/src/interfaces/rtc_data_channel.h @@ -102,6 +102,8 @@ namespace python_webrtc { void SurfaceState(DataState state); + bool GetOpenDelivered(); + uint64_t GetBufferedAmount(); uint64_t GetBufferedAmountLowThreshold(); @@ -145,6 +147,7 @@ namespace python_webrtc { // that depend on it std::mutex _closeMutex; bool _closeRequested = false; + bool _openDelivered = false; LockedFunction()> _maxMessageSizeGetter; LockedFunction _closedCallback; diff --git a/python-webrtc/cpp/src/interfaces/rtc_dtmf_sender.cpp b/python-webrtc/cpp/src/interfaces/rtc_dtmf_sender.cpp index bd7df74..15c0da7 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_dtmf_sender.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_dtmf_sender.cpp @@ -50,9 +50,8 @@ namespace python_webrtc { return registry.Get(); } - void - RTCDTMFSender::SetTransceiver(std::function()> transceiver) { - _transceiver.Set(std::move(transceiver)); + void RTCDTMFSender::SetTransceiverState(std::function state) { + _transceiverState.Set(std::move(state)); } void RTCDTMFSender::OnToneChange(const std::string &tone, const std::string &toneBuffer) { @@ -64,40 +63,54 @@ namespace python_webrtc { Emit("tonechange", tone, toneBuffer, insertion); } - void RTCDTMFSender::CheckCanSend() { - auto lookup = _transceiver.Get(); - auto transceiver = lookup ? lookup() : nullptr; - if (!transceiver || transceiver->stopping() || transceiver->stopped()) { - throw RTCException(webrtc::RTCErrorType::INVALID_STATE, "The transceiver of the sender is stopped"); + std::optional RTCDTMFSender::CannotSend() { + auto lookup = _transceiverState.Get(); + auto state = lookup ? lookup() : TransceiverState::Gone(); + if (state.stopping || state.stopped) { + return "The transceiver of the sender is stopped"; } - auto direction = transceiver->current_direction(); + auto direction = state.currentDirection; if (direction && (*direction == webrtc::RtpTransceiverDirection::kRecvOnly || *direction == webrtc::RtpTransceiverDirection::kInactive)) { - throw RTCException(webrtc::RTCErrorType::INVALID_STATE, "The transceiver of the sender doesn't send"); + return "The transceiver of the sender doesn't send"; } if (!_dtmf->CanInsertDtmf()) { - throw RTCException(webrtc::RTCErrorType::INVALID_STATE, "The DTMF sender can't send tones yet"); + return "The DTMF sender can't send tones yet"; } + return std::nullopt; } - void RTCDTMFSender::InsertDtmf(const std::string &tones, int duration, int interToneGap) { - CheckCanSend(); - if (tones.empty() && _dtmf->tones().empty()) { - // nothing to play, nor to cancel - return; + void RTCDTMFSender::CheckCanSend() { + if (auto reason = CannotSend()) { + throw RTCException(webrtc::RTCErrorType::INVALID_STATE, *reason); } - { - // tone changes of the tones inserted before don't change the buffer anymore + } + + void RTCDTMFSender::InsertDtmf(const std::string &tones, int duration, int interToneGap) { + // the proxies run inline there; nothing may throw through libwebrtc's task + auto reason = BlockingCallOn(_factory->signalingThread(), [&]() -> std::optional { + auto cannot = CannotSend(); + if (cannot || (tones.empty() && _dtmf->tones().empty())) { + // or nothing to play, nor to cancel + return cannot; + } + { + // tone changes of the tones inserted before don't change the buffer anymore + const std::scoped_lock lock(_bufferMutex); + ++_insertions; + } + if (!_dtmf->InsertDtmf(tones, duration, interToneGap) && !tones.empty()) { + return "The DTMF sender can't send tones yet"; + } const std::scoped_lock lock(_bufferMutex); - ++_insertions; - } - if (!_dtmf->InsertDtmf(tones, duration, interToneGap) && !tones.empty()) { - throw RTCException(webrtc::RTCErrorType::INVALID_STATE, "The DTMF sender can't send tones yet"); + _surfacedBuffer = tones; + // a cancel (no tones) ends with an empty tonechange too + _playing = true; + return std::nullopt; + }); + if (reason) { + throw RTCException(webrtc::RTCErrorType::INVALID_STATE, *reason); } - const std::scoped_lock lock(_bufferMutex); - _surfacedBuffer = tones; - // a cancel (no tones) ends with an empty tonechange too - _playing = true; } std::string RTCDTMFSender::GetToneBuffer() { diff --git a/python-webrtc/cpp/src/interfaces/rtc_dtmf_sender.h b/python-webrtc/cpp/src/interfaces/rtc_dtmf_sender.h index d4b0d6e..7e4719a 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_dtmf_sender.h +++ b/python-webrtc/cpp/src/interfaces/rtc_dtmf_sender.h @@ -15,7 +15,6 @@ #include #include -#include #include @@ -24,6 +23,7 @@ #include "../utils/native_object.h" #include "../utils/registry.h" #include "peer_connection_factory.h" +#include "transceiver_state.h" namespace python_webrtc { @@ -46,14 +46,16 @@ namespace python_webrtc { static Registry ®istry(); - // the transceiver of the sender, which tells whether tones can be sent; set by the sender - void SetTransceiver(std::function()> transceiver); + // the state of the sender's transceiver as Python sees it, which tells whether tones can be sent; set by the + // sender + void SetTransceiverState(std::function state); // DtmfSenderObserverInterface, on the signaling thread void OnToneChange(const std::string &tone, const std::string &toneBuffer) override; // "determine if DTMF can be sent" void CheckCanSend(); + void InsertDtmf(const std::string &tones, int duration, int interToneGap); // the tones not played yet, as Python sees them: set by insertDTMF(), shortened along with tonechange events @@ -67,9 +69,11 @@ namespace python_webrtc { bool GetCanInsertDtmf(); private: + std::optional CannotSend(); + std::shared_ptr _factory; webrtc::scoped_refptr _dtmf; - LockedFunction()> _transceiver; + LockedFunction _transceiverState; std::mutex _bufferMutex; std::optional _surfacedBuffer; diff --git a/python-webrtc/cpp/src/interfaces/rtc_ice_transport.cpp b/python-webrtc/cpp/src/interfaces/rtc_ice_transport.cpp index 3c5c3af..a93d3fa 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_ice_transport.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_ice_transport.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include @@ -23,6 +24,7 @@ #include "../exceptions.h" #include "../utils/gil.h" #include "../utils/libwebrtc_thread.h" +#include "../utils/parking.h" #include "../utils/port_allocator.h" namespace python_webrtc { @@ -83,6 +85,8 @@ namespace python_webrtc { } TakeSnapshot(); }); + // none until its first change, unless read unbound + _surfacedPair.Changed(true, std::nullopt); } RTCIceTransport::~RTCIceTransport() { @@ -127,6 +131,7 @@ namespace python_webrtc { .def_property_readonly("_standalone", &RTCIceTransport::IsStandalone) .def_property_readonly("_stopped", nogil_fn(&RTCIceTransport::Stopped)) .def("_surfaceState", &RTCIceTransport::SurfaceState, nogil(), pybind11::arg("state")) + .def("_surfaceSelectedPair", &RTCIceTransport::SurfaceSelectedPair, nogil(), pybind11::arg("pair")) .def("_surfaceGatheringState", &RTCIceTransport::SurfaceGatheringState, nogil(), pybind11::arg("state")) .def("_surfaceCandidate", &RTCIceTransport::SurfaceLocalCandidate, nogil()); } @@ -206,6 +211,7 @@ namespace python_webrtc { } _surfacedState.Reset(); _surfacedGatheringState.Reset(); + _surfacedPair.Reset(); } void RTCIceTransport::OnStateChanged(webrtc::IceTransportInternal * /*unused*/) { @@ -226,6 +232,10 @@ namespace python_webrtc { } void RTCIceTransport::EmitState(webrtc::IceTransportState previous, webrtc::IceTransportState state) { + if (state == webrtc::IceTransportState::kFailed || state == webrtc::IceTransportState::kClosed) { + const std::scoped_lock lock(_mutex); + _selectedPair.reset(); + } auto emitter = _stateEmitter.Get(); if (emitter && emitter(previous, state)) { return; @@ -301,14 +311,31 @@ namespace python_webrtc { return _surfacedState.Shown(IsBound(), state); } - std::optional> RTCIceTransport::GetSelectedCandidatePair() { + std::optional RTCIceTransport::GetSelectedCandidatePair() { { const std::scoped_lock lock(_mutex); if (_stopped) { return std::nullopt; } } - std::optional> result; + return _surfacedPair.ShownOr(IsBound(), [this]() { return ReadSelectedCandidatePair(); }); + } + + void RTCIceTransport::ResolveSignaled(CandidatePair &pair) { + auto &[local, remote, peerReflexive] = pair; + if (!peerReflexive) { + return; + } + webrtc::SdpParseError error; + auto parsed = webrtc::IceCandidate::Create(remote.sdpMid, remote.sdpMLineIndex, remote.candidate, &error); + if (auto signaled = parsed ? FindSignaledCandidate(parsed->candidate()) : std::nullopt) { + remote = *signaled; + peerReflexive = false; + } + } + + std::optional RTCIceTransport::ReadSelectedCandidatePair() { + std::optional result; BlockingCallOn(_factory->workerThread(), [&]() { auto *internal = _transport ? _transport->internal() : nullptr; auto pair = internal ? internal->GetSelectedCandidatePair() : std::nullopt; @@ -362,8 +389,15 @@ namespace python_webrtc { } void RTCIceTransport::AddRemoteCandidate(const IceCandidateInit &candidate) { - const std::scoped_lock lock(_mutex); - addCandidate(_remoteCandidates, candidate); + { + const std::scoped_lock lock(_mutex); + addCandidate(_remoteCandidates, candidate); + } + _surfacedPair.Update([this](std::optional &shown) { + if (shown) { + ResolveSignaled(*shown); + } + }); } std::vector RTCIceTransport::GetLocalCandidates() { @@ -555,6 +589,7 @@ namespace python_webrtc { Unbind(); _surfacedState.Reset(); _surfacedGatheringState.Reset(); + _surfacedPair.Reset(); } void RTCIceTransport::SurfaceCurrent() { @@ -579,23 +614,43 @@ namespace python_webrtc { Emit("gatheringstatechange", webrtc::IceGatheringState::kIceGatheringComplete); } + namespace { + + std::string keyOf(const std::optional &pair) { + return pair ? std::get<0>(*pair).candidate + "|" + std::get<1>(*pair).candidate : std::string(); + } + + } // namespace + void RTCIceTransport::CheckSelectedCandidatePair() { - auto pair = GetSelectedCandidatePair(); - auto key = pair ? std::get<0>(*pair).candidate + "|" + std::get<1>(*pair).candidate : std::string(); + auto pair = ReadSelectedCandidatePair(); + std::optional previous; { const std::scoped_lock lock(_mutex); - if (key == _selectedPair) { + if (keyOf(pair) == keyOf(_selectedPair)) { return; } - _selectedPair = key; + previous = std::exchange(_selectedPair, pair); } if (pair) { - Emit("selectedcandidatepairchange"); + _surfacedPair.Changed(IsBound(), previous); + Parking::At("ice.pairchanged"); + Emit("selectedcandidatepairchange", *pair); } } void RTCIceTransport::SurfaceState(webrtc::IceTransportState state) { _surfacedState.Surface(state); + if (state == webrtc::IceTransportState::kFailed || state == webrtc::IceTransportState::kClosed) { + _surfacedPair.Surface(std::nullopt); + } + } + + void RTCIceTransport::SurfaceSelectedPair(std::optional pair) { + if (pair) { + ResolveSignaled(*pair); + } + _surfacedPair.Surface(std::move(pair)); } void RTCIceTransport::SurfaceGatheringState(webrtc::IceGatheringState state) { diff --git a/python-webrtc/cpp/src/interfaces/rtc_ice_transport.h b/python-webrtc/cpp/src/interfaces/rtc_ice_transport.h index 28789c5..7d81a7f 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_ice_transport.h +++ b/python-webrtc/cpp/src/interfaces/rtc_ice_transport.h @@ -63,7 +63,9 @@ namespace python_webrtc { // the local and the remote candidate of the pair in use, and whether the remote one is peer-reflexive (known // from connectivity checks only, not signaled) - std::optional> GetSelectedCandidatePair(); + using CandidatePair = std::tuple; + + std::optional GetSelectedCandidatePair(); std::vector GetLocalCandidates(); @@ -74,9 +76,10 @@ namespace python_webrtc { std::optional> GetRemoteParameters(); - // see Surfaced void SurfaceState(webrtc::IceTransportState state); + void SurfaceSelectedPair(std::optional pair); + void SurfaceGatheringState(webrtc::IceGatheringState state); // The transport of a connection, which tells it about the descriptions and the candidates: @@ -182,10 +185,14 @@ namespace python_webrtc { std::optional> GetParameters(bool local); + std::optional ReadSelectedCandidatePair(); + // Libwebrtc hides the address of a peer-reflexive candidate: the remote peer may have signaled it since, // which is found by port and protocol std::optional FindSignaledCandidate(const webrtc::Candidate &candidate); + void ResolveSignaled(CandidatePair &pair); + bool Stopped(); std::shared_ptr _factory; @@ -205,8 +212,8 @@ namespace python_webrtc { bool _roleKnown = false; Surfaced _surfacedState; Surfaced _surfacedGatheringState; - // the selected pair seen last, as candidate-attributes - std::string _selectedPair; + Surfaced> _surfacedPair; + std::optional _selectedPair; std::vector _localCandidates; std::vector _remoteCandidates; LockedFunction _parametersGetter; 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 2b075ba..250668e 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_peer_connection/descriptions.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_peer_connection/descriptions.cpp @@ -17,6 +17,7 @@ #include #include "../../media/sframe_transform.h" +#include "../../utils/parking.h" #include "../peer_connection_factory.h" #include "../rtc_rtp_receiver.h" #include "create_session_description_observer.h" @@ -114,6 +115,10 @@ namespace python_webrtc { return (sender && isSFrame(sender->GetTransform())) || (receiver && isSFrame(receiver->GetTransform())); } + bool isImplicit(const std::optional &init) { + return !init || (init->sdp.empty() && init->type != webrtc::SdpType::kRollback); + } + bool hasSFrameVideo(const webrtc::scoped_refptr &pc) { return std::ranges::any_of(pc->GetTransceivers(), [](const auto &transceiver) { return sframeVideo(*transceiver); }); @@ -288,11 +293,13 @@ namespace python_webrtc { self->_remoteEndOfCandidatesDescription = nullptr; } // the descriptions as the operation left them, before candidates gathered later - self->_completionSnapshot = self->SnapshotDescriptions(); + const auto snapshot = self->SnapshotDescriptions(); + self->_completionSnapshot = snapshot; const auto sctp = self->SctpTransportsOf(self->connection()); auto created = self->WrapTransports(sctp); self->UpdateMaxMessageSize(sctp); self->MarkIceRolesKnown(); + self->EmitDeferredSignaling(snapshot); if (remote) { self->RecordRemoteDescriptionCandidates(); // before the operation resolves, as the track events of a description are @@ -304,8 +311,10 @@ namespace python_webrtc { iceTransport->CreatedByDescription(); } } else { + self->EmitDeferredSignaling(std::nullopt); completion->Fail(RTCCallbackException(std::move(error))); } + Parking::At("description.completed"); held->Release(); }; } @@ -313,6 +322,32 @@ namespace python_webrtc { void RTCPeerConnection::SetLocalDescription(std::shared_ptr mailbox, uint64_t token, const std::optional &init) { Completion completion(std::move(mailbox), token); + // parsed off the signaling thread, its errors reported after the state's + ParsedDescription description; + std::optional error; + bool modified = false; + if (init && !isImplicit(init)) { + if (init->type != webrtc::SdpType::kRollback) { + const std::scoped_lock lock(_createdMutex); + modified = fingerprintChanged(init->type == webrtc::SdpType::kOffer ? _lastOffer : _lastAnswer, init->sdp); + } + description = parseDescription(*init, error); + } + + _factory->signalingThread()->PostTask([weak = weak_from_this(), completion = std::move(completion), init, + description = std::move(description), error = std::move(error), + modified]() mutable { + if (auto self = weak.lock()) { + self->ApplyLocalDescription(std::move(completion), init, std::move(description), error, modified); + } + }); + } + + void RTCPeerConnection::ApplyLocalDescription(Completion completion, + const std::optional &init, + ParsedDescription description, + const std::optional &error, bool modified) { + _deferSignaling = false; auto pc = connection(); auto state = pc ? pc->signaling_state() : SignalingState::kClosed; if (state == SignalingState::kClosed) { @@ -327,7 +362,7 @@ namespace python_webrtc { return; } - if (!init || (init->sdp.empty() && init->type != webrtc::SdpType::kRollback)) { + if (isImplicit(init)) { // without a type, an answer in the states that wait for one, an offer otherwise const bool waitsForAnswer = state == SignalingState::kHaveRemoteOffer || state == SignalingState::kHaveLocalPrAnswer; @@ -337,6 +372,7 @@ namespace python_webrtc { auto complete = DescriptionCompletion(shared, pc, DescriptionKind::kLocal); const bool created = type == webrtc::SdpType::kOffer || (type == webrtc::SdpType::kAnswer && state == SignalingState::kHaveRemoteOffer); + _deferSignaling = true; if (created && hasSFrameVideo(pc)) { // libwebrtc's own implicit description can't get raw packetization SetCreatedDescription(pc, type, std::move(complete), shared); @@ -349,23 +385,18 @@ namespace python_webrtc { return; } - if (init->type != webrtc::SdpType::kRollback) { - const std::scoped_lock lock(_createdMutex); - if (fingerprintChanged(init->type == webrtc::SdpType::kOffer ? _lastOffer : _lastAnswer, init->sdp)) { - completion.Fail(RTCCallbackException( - webrtc::RTCErrorType::INVALID_MODIFICATION, - "Failed to execute 'setLocalDescription' on 'RTCPeerConnection': The SDP does not match the previously " - "generated SDP for this type")); - return; - } + if (modified) { + completion.Fail(RTCCallbackException( + webrtc::RTCErrorType::INVALID_MODIFICATION, + "Failed to execute 'setLocalDescription' on 'RTCPeerConnection': The SDP does not match the previously " + "generated SDP for this type")); + return; } - - std::optional error; - auto description = parseDescription(*init, error); if (error) { completion.Fail(*error); return; } + _deferSignaling = init && init->type != webrtc::SdpType::kRollback; pc->SetLocalDescription(std::move(description), webrtc::make_ref_counted(DescriptionCompletion( std::make_shared(std::move(completion)), pc, DescriptionKind::kLocal))); @@ -406,6 +437,24 @@ namespace python_webrtc { void RTCPeerConnection::SetRemoteDescription(std::shared_ptr mailbox, uint64_t token, const RTCSessionDescriptionInit &init) { Completion completion(std::move(mailbox), token); + std::optional error; + auto description = parseDescription(init, error); + if (description) { + removeBlockedCandidates(*description); + } + _factory->signalingThread()->PostTask([weak = weak_from_this(), completion = std::move(completion), + type = init.type, description = std::move(description), + error = std::move(error)]() mutable { + if (auto self = weak.lock()) { + self->ApplyRemoteDescription(std::move(completion), type, std::move(description), error); + } + }); + } + + void RTCPeerConnection::ApplyRemoteDescription(Completion completion, webrtc::SdpType type, + ParsedDescription description, + const std::optional &error) { + _deferSignaling = false; auto pc = connection(); auto state = pc ? pc->signaling_state() : SignalingState::kClosed; if (state == SignalingState::kClosed) { @@ -413,9 +462,9 @@ namespace python_webrtc { return; } - // the state is checked before the SDP is parsed - const bool answer = init.type == webrtc::SdpType::kAnswer || init.type == webrtc::SdpType::kPrAnswer; - const bool rollback = init.type == webrtc::SdpType::kRollback; + // the state's errors come before the SDP's + const bool answer = type == webrtc::SdpType::kAnswer || type == webrtc::SdpType::kPrAnswer; + const bool rollback = type == webrtc::SdpType::kRollback; if ((answer && state != SignalingState::kHaveLocalOffer && state != SignalingState::kHaveRemotePrAnswer) || (rollback && state != SignalingState::kHaveRemoteOffer)) { completion.Fail(RTCCallbackException( @@ -429,20 +478,20 @@ namespace python_webrtc { auto shared = std::make_shared(std::move(completion)); auto complete = std::make_shared>( DescriptionCompletion(shared, pc, DescriptionKind::kRemote)); - auto apply = [init, complete, shared](const webrtc::scoped_refptr &pc) { - std::optional error; - auto description = parseDescription(init, error); + auto parsed = std::make_shared(std::move(description)); + auto apply = [this, parsed, error, complete, shared, + rollback](const webrtc::scoped_refptr &pc) { if (error) { shared->Fail(*error); return; } - removeBlockedCandidates(*description); - pc->SetRemoteDescription(std::move(description), + _deferSignaling = !rollback; + pc->SetRemoteDescription(std::move(*parsed), webrtc::make_ref_counted( [complete](webrtc::RTCError error) { (*complete)(std::move(error)); })); }; - if (init.type == webrtc::SdpType::kOffer && state == SignalingState::kHaveLocalOffer) { + if (type == webrtc::SdpType::kOffer && state == SignalingState::kHaveLocalOffer) { // an offer in have-local-offer rolls the local one back first (perfect negotiation), // even if the offer turns out to be invalid auto rollbackObserver = webrtc::make_ref_counted( @@ -595,7 +644,12 @@ namespace python_webrtc { uint64_t RTCPeerConnection::SnapshotDescriptions() { auto pc = connection(); - if (!pc || !IsBound()) { + if (!pc) { + return 0; + } + if (!IsBound()) { + const std::scoped_lock lock(_descriptionsMutex); + _shown.reset(); return 0; } DescriptionsSnapshot snapshot; @@ -613,6 +667,10 @@ namespace python_webrtc { std::tuple(DescriptionKind::kRemote, DescriptionKind::kPendingRemote, DescriptionKind::kCurrentRemote)}) { view(kind) = view(pending).description != nullptr ? view(pending) : view(current); } + for (const auto &transceiver : pc->GetTransceivers()) { + snapshot.transceivers.push_back({.sender = RTCRtpSender::registry().GetOrCreate(_factory, transceiver->sender()), + .state = TransceiverState::Of(*transceiver)}); + } 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/rtc_peer_connection.cpp b/python-webrtc/cpp/src/interfaces/rtc_peer_connection/rtc_peer_connection.cpp index 1f9e6db..e86df62 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 @@ -48,6 +48,8 @@ namespace python_webrtc { BlockingCallOn(_factory->workerThread(), [allocator]() { allocator->set_step_delay(0); }); _jinglePeerConnection = result.MoveValue(); + // new until its first event, unless read unbound: libwebrtc's own state runs ahead of the events + _surfacedIceGatheringState.Changed(true, IceGatheringState::kIceGatheringNew); } RTCPeerConnection::~RTCPeerConnection() { @@ -132,6 +134,7 @@ namespace python_webrtc { .def("_surfaceIceGatheringState", &RTCPeerConnection::SurfaceIceGatheringState, nogil(), pybind11::arg("state")) .def("_surfaceConnectionState", &RTCPeerConnection::SurfaceConnectionState, nogil(), pybind11::arg("state")) .def("_refreshDescriptions", &RTCPeerConnection::RefreshDescriptions, nogil()) + .def("_expireParameters", &RTCPeerConnection::ExpireParameters, nogil()) .def_static("_connectionOf", &RTCPeerConnection::ConnectionOf, nogil(), pybind11::arg("sender")) .def("_applyDescriptions", &RTCPeerConnection::ApplyDescriptions, nogil(), pybind11::arg("snapshot") = std::nullopt); @@ -463,7 +466,9 @@ namespace python_webrtc { throw RTCException(result.error()); } - return Wrap(_senders, result.value()); + auto sender = Wrap(_senders, result.value()); + sender->SetKnownTrack(mediaStreamTrack.shared_from_this()); + return sender; } std::shared_ptr @@ -502,6 +507,7 @@ namespace python_webrtc { if (!error.ok()) { throw RTCException(error); } + sender.SetKnownTrack(nullptr); } std::shared_ptr @@ -511,7 +517,9 @@ namespace python_webrtc { if (!pc) { throw RTCException(closedError("addTransceiver")); } - return AddedTransceiver(init ? pc->AddTransceiver(kind, init->get()) : pc->AddTransceiver(kind)); + auto transceiver = AddedTransceiver(init ? pc->AddTransceiver(kind, init->get()) : pc->AddTransceiver(kind)); + transceiver->GetSender()->SetKnownTrack(nullptr); + return transceiver; } std::shared_ptr @@ -521,8 +529,10 @@ namespace python_webrtc { if (!pc) { throw RTCException(closedError("addTransceiver")); } - return AddedTransceiver(init ? pc->AddTransceiver(track.SentTrack(), init->get()) - : pc->AddTransceiver(track.SentTrack())); + auto transceiver = AddedTransceiver(init ? pc->AddTransceiver(track.SentTrack(), init->get()) + : pc->AddTransceiver(track.SentTrack())); + transceiver->GetSender()->SetKnownTrack(track.shared_from_this()); + return transceiver; } std::shared_ptr RTCPeerConnection::AddedTransceiver( @@ -728,6 +738,12 @@ namespace python_webrtc { } } + ShowTransceivers([](TransceiverView &view) { + view.state = {.mid = view.state.mid, + .currentDirection = webrtc::RtpTransceiverDirection::kStopped, + .stopping = true, + .stopped = true}; + }); Parking::At("connection.close"); // a closed connection, its channels and transports fire nothing more; remote tracks fire ended Unbind(); @@ -818,10 +834,26 @@ namespace python_webrtc { void RTCPeerConnection::OnSignalingChange(SignalingState newState) { _surfacedSignalingState.Changed(IsBound(), _lastSignalingState.load()); _lastSignalingState = newState; + if (_deferSignaling) { + // libwebrtc changes the state before the transceivers: the event comes with the completion + _deferredSignaling = newState; + return; + } // the descriptions change along with the event Emit("signalingstatechange", newState, SnapshotDescriptions()); } + void RTCPeerConnection::EmitDeferredSignaling(std::optional snapshot) { + _deferSignaling = false; + auto tracks = std::exchange(_deferredTracks, {}); + if (auto state = std::exchange(_deferredSignaling, std::nullopt)) { + Emit("signalingstatechange", *state, snapshot ? *snapshot : SnapshotDescriptions()); + } + for (const auto &transceiver : tracks) { + EmitTrack(transceiver); + } + } + void RTCPeerConnection::OnIceConnectionChange(IceConnectionState /*unused*/) { // the legacy state } @@ -896,6 +928,10 @@ namespace python_webrtc { const std::scoped_lock lock(_remoteStreamsMutex); _trackFired.insert(transceiver.get()); } + if (_deferSignaling) { + _deferredTracks.push_back(std::move(transceiver)); + return; + } EmitTrack(transceiver); } 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 2264ab5..5abfbd0 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 @@ -41,6 +41,7 @@ #include "../rtc_rtp_sender.h" #include "../rtc_rtp_transceiver.h" #include "../rtc_sctp_transport.h" +#include "../transceiver_state.h" namespace webrtc { struct PeerConnectionDependencies; @@ -171,6 +172,8 @@ namespace python_webrtc { // are delivered: these events refresh the descriptions void RefreshDescriptions(); + void ExpireParameters(); + // the connection of a sender, bound here as the sender is bound before the connection static std::optional> ConnectionOf(RTCRtpSender &sender); @@ -178,9 +181,11 @@ namespace python_webrtc { bool IsClosed(); - // the transceiver of a sender, while it's in the connection - webrtc::scoped_refptr - TransceiverOf(const webrtc::scoped_refptr &sender); + TransceiverState + ShownTransceiver(RTCRtpSender &sender, + const webrtc::scoped_refptr &transceiver = nullptr); + + void ShowStopping(const RTCRtpSender &sender); // The negotiated codecs this side can use: libwebrtc reports none for an inactive sender (receiver), and // lists remote codecs it doesn't know @@ -255,12 +260,21 @@ namespace python_webrtc { std::optional init; }; + struct TransceiverView { + std::shared_ptr sender; + TransceiverState state; + }; + struct DescriptionsSnapshot { std::array kinds; // the descriptions set, which any of the kinds is std::vector live; + // shown whenever bound: candidate events refresh the descriptions, not the transceivers + std::vector transceivers; }; + void ShowTransceivers(const std::function &change); + template using Wrappers = std::unordered_map>; @@ -330,6 +344,15 @@ namespace python_webrtc { std::function complete, const std::shared_ptr &completion); + using ParsedDescription = std::unique_ptr; + + void ApplyLocalDescription(Completion completion, const std::optional &init, + ParsedDescription description, const std::optional &error, + bool modified); + + void ApplyRemoteDescription(Completion completion, webrtc::SdpType type, ParsedDescription description, + const std::optional &error); + // What completes setLocalDescription (kLocal) or setRemoteDescription (kRemote); created right before the // operation starts, as it holds its events std::function @@ -410,6 +433,8 @@ namespace python_webrtc { void FireRemoteStreamChanges(); + void EmitDeferredSignaling(std::optional snapshot); + // wrappers' destructors use the threads of the factory: declared first, to be destroyed last std::shared_ptr _factory; @@ -446,7 +471,7 @@ namespace python_webrtc { std::array _descriptionsCachedGeneration{}; std::map _snapshots; uint64_t _lastSnapshot = 0; - std::optional _shown; + std::optional _shown{std::in_place}; uint64_t _shownGeneration = 0; // the snapshot of the operation that completed last std::atomic _completionSnapshot{0}; @@ -462,6 +487,10 @@ namespace python_webrtc { // held while a description is set HeldEvents _heldTransportStates; + bool _deferSignaling = false; + std::optional _deferredSignaling; + std::vector> _deferredTracks; + std::mutex _remoteStreamsMutex; std::map> _remoteStreamsBefore; std::set _trackFired; 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 2b8f33c..ed425a6 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_peer_connection/rtp.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_peer_connection/rtp.cpp @@ -16,6 +16,7 @@ #include #include +#include "../../utils/libwebrtc_thread.h" #include "../peer_connection_factory.h" #include "stats_collector_callback.h" @@ -95,10 +96,74 @@ namespace python_webrtc { } // namespace - webrtc::scoped_refptr - RTCPeerConnection::TransceiverOf(const webrtc::scoped_refptr &sender) { + TransceiverState + RTCPeerConnection::ShownTransceiver(RTCRtpSender &sender, + const webrtc::scoped_refptr &transceiver) { + bool bound = false; + { + const std::scoped_lock lock(_descriptionsMutex); + bound = IsBound() && _shown; + if (bound) { + auto it = std::ranges::find_if(_shown->transceivers, + [&](const TransceiverView &view) { return view.sender.get() == &sender; }); + if (it != _shown->transceivers.end()) { + return it->state; + } + } + } auto pc = connection(); - return pc ? transceiverOf(pc, sender) : nullptr; + auto current = BlockingCallOn(_factory->signalingThread(), [&]() { + auto found = transceiver; + if (!found && pc) { + found = transceiverOf(pc, sender.sender()); + } + return found ? TransceiverState::Of(*found) : TransceiverState::Gone(); + }); + if (bound && !current.stopped) { + // added since: only a description negotiates it, along with a snapshot + return {.stopping = current.stopping}; + } + // or gone, stopped for good + return current; + } + + void RTCPeerConnection::ExpireParameters() { + std::vector> senders; + { + const TrackedLock lock(_wrappersMutex); + for (const auto &entry : _senders) { + senders.push_back(entry.second); + } + for (const auto &entry : _transceivers) { + senders.push_back(entry.second->GetSender()); + } + } + for (const auto &sender : senders) { + sender->ExpireParameters(std::nullopt); + } + } + + void RTCPeerConnection::ShowTransceivers(const std::function &change) { + auto changeAll = [&](DescriptionsSnapshot &snapshot) { + for (auto &view : snapshot.transceivers) { + change(view); + } + }; + const std::scoped_lock lock(_descriptionsMutex); + if (_shown) { + changeAll(*_shown); + } + for (auto &entry : _snapshots) { + changeAll(entry.second); + } + } + + void RTCPeerConnection::ShowStopping(const RTCRtpSender &sender) { + ShowTransceivers([&](TransceiverView &view) { + if (view.sender.get() == &sender) { + view.state.stopping = true; + } + }); } template diff --git a/python-webrtc/cpp/src/interfaces/rtc_rtp_sender.cpp b/python-webrtc/cpp/src/interfaces/rtc_rtp_sender.cpp index 34d9cf7..fa07457 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_rtp_sender.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_rtp_sender.cpp @@ -73,7 +73,7 @@ namespace python_webrtc { const std::scoped_lock lock(_mutex); _connection = std::move(connection); if (_dtmf) { - _dtmf->SetTransceiver(TransceiverGetter()); + _dtmf->SetTransceiverState(TransceiverStateGetter()); } } @@ -82,14 +82,21 @@ namespace python_webrtc { return _connection.lock(); } - std::function()> RTCRtpSender::TransceiverGetter() { - return [connection = _connection, sender = _sender]() -> webrtc::scoped_refptr { + std::function RTCRtpSender::TransceiverStateGetter() { + return [connection = _connection, weak = weak_from_this()]() { auto pc = connection.lock(); - return pc ? pc->TransceiverOf(sender) : nullptr; + auto self = weak.lock(); + return pc && self ? pc->ShownTransceiver(*self) : TransceiverState::Gone(); }; } std::optional> RTCRtpSender::GetTrack() { + { + const std::scoped_lock lock(_mutex); + if (_knownTrack) { + return *_knownTrack ? _knownTrack : std::nullopt; + } + } auto track = _sender->track(); if (auto *relayed = dynamic_cast(track.get())) { track = relayed->track(); @@ -97,17 +104,17 @@ namespace python_webrtc { // wrapped out of the lock: wrapping may wait for the signaling thread auto wrapper = MediaStreamTrack::registry().GetOrCreate(_factory, track); - std::shared_ptr previous; const std::scoped_lock lock(_mutex); - if (!_track || _track->track() != track) { - previous = std::move(_track); - _track = std::move(wrapper); + if (!_knownTrack) { + _knownTrack = std::move(wrapper); } + return *_knownTrack ? _knownTrack : std::nullopt; + } - if (_track) { - return _track; - } - return {}; + void RTCRtpSender::SetKnownTrack(std::shared_ptr track) { + std::optional> known(std::move(track)); + const std::scoped_lock lock(_mutex); + std::swap(known, _knownTrack); } std::optional> RTCRtpSender::GetTransport() { @@ -139,7 +146,7 @@ namespace python_webrtc { const std::scoped_lock lock(_mutex); if (wrapper && _dtmf != wrapper) { _dtmf = std::move(wrapper); - _dtmf->SetTransceiver(TransceiverGetter()); + _dtmf->SetTransceiverState(TransceiverStateGetter()); } return dtmf ? _dtmf : nullptr; } @@ -227,7 +234,11 @@ namespace python_webrtc { } bool RTCRtpSender::ReplaceTrack(std::optional> track) { - return _sender->SetTrack(track ? track->get().SentTrack().get() : nullptr); + if (!_sender->SetTrack(track ? track->get().SentTrack().get() : nullptr)) { + return false; + } + SetKnownTrack(track ? track->get().shared_from_this() : nullptr); + return true; } void RTCRtpSender::SetStreams(const std::vector &streamIds) { @@ -250,8 +261,8 @@ namespace python_webrtc { bool RTCRtpSender::IsTransceiverStopped() { auto connection = GetConnection(); - auto transceiver = connection ? connection->TransceiverOf(_sender) : nullptr; - return !transceiver || transceiver->stopping() || transceiver->stopped(); + auto state = connection ? connection->ShownTransceiver(*this) : TransceiverState::Gone(); + return state.stopping || state.stopped; } FrameSource RTCRtpSender::TransformSource() { diff --git a/python-webrtc/cpp/src/interfaces/rtc_rtp_sender.h b/python-webrtc/cpp/src/interfaces/rtc_rtp_sender.h index dd44ad4..eb7b53f 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_rtp_sender.h +++ b/python-webrtc/cpp/src/interfaces/rtc_rtp_sender.h @@ -54,6 +54,8 @@ namespace python_webrtc { std::optional> GetTrack(); + void SetKnownTrack(std::shared_ptr track); + std::optional> GetTransport(); webrtc::MediaType GetKind(); @@ -95,16 +97,14 @@ namespace python_webrtc { private: FrameSource TransformSource(); - // what the DTMF sender finds the transceiver of the sender with - std::function()> TransceiverGetter(); + std::function TransceiverStateGetter(); std::shared_ptr _factory; webrtc::scoped_refptr _sender; std::mutex _mutex; std::weak_ptr _connection; - // the sender owns wrappers of its current track, transport and DTMF sender - std::shared_ptr _track; + std::optional> _knownTrack; std::shared_ptr _transport; std::shared_ptr _dtmf; std::optional _lastParameters; diff --git a/python-webrtc/cpp/src/interfaces/rtc_rtp_transceiver.cpp b/python-webrtc/cpp/src/interfaces/rtc_rtp_transceiver.cpp index 8f5f505..ff35673 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_rtp_transceiver.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_rtp_transceiver.cpp @@ -9,6 +9,7 @@ #include "../exceptions.h" #include "../utils/gil.h" +#include "../utils/libwebrtc_thread.h" #include "rtc_peer_connection/rtc_peer_connection.h" namespace python_webrtc { @@ -50,8 +51,20 @@ namespace python_webrtc { _connection = std::move(connection); } + TransceiverState RTCRtpTransceiver::Shown() { + std::shared_ptr connection; + { + const std::scoped_lock lock(_mutex); + connection = _connection.lock(); + } + if (!connection) { + return BlockingCallOn(_factory->signalingThread(), [this]() { return TransceiverState::Of(*_transceiver); }); + } + return connection->ShownTransceiver(*_sender, _transceiver); + } + std::optional RTCRtpTransceiver::GetMid() { - return _transceiver->mid(); + return Shown().mid; } std::shared_ptr RTCRtpTransceiver::GetSender() { @@ -67,11 +80,11 @@ namespace python_webrtc { } bool RTCRtpTransceiver::GetStopped() { - return _transceiver->stopped(); + return Shown().stopped; } bool RTCRtpTransceiver::GetStopping() { - return _transceiver->stopping(); + return Shown().stopping; } webrtc::RtpTransceiverDirection RTCRtpTransceiver::GetDirection() { @@ -86,7 +99,7 @@ namespace python_webrtc { } std::optional RTCRtpTransceiver::GetCurrentDirection() { - return _transceiver->current_direction(); + return Shown().currentDirection; } void RTCRtpTransceiver::Stop() { @@ -102,6 +115,7 @@ namespace python_webrtc { if (!result.ok()) { throw RTCException(result); } + connection->ShowStopping(*_sender); } void RTCRtpTransceiver::SetCodecPreferences(std::vector codecs) { diff --git a/python-webrtc/cpp/src/interfaces/rtc_rtp_transceiver.h b/python-webrtc/cpp/src/interfaces/rtc_rtp_transceiver.h index c7065bf..bd15b10 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_rtp_transceiver.h +++ b/python-webrtc/cpp/src/interfaces/rtc_rtp_transceiver.h @@ -25,6 +25,7 @@ #include "peer_connection_factory.h" #include "rtc_rtp_receiver.h" #include "rtc_rtp_sender.h" +#include "transceiver_state.h" namespace python_webrtc { @@ -74,6 +75,8 @@ namespace python_webrtc { std::vector GetNegotiatedHeaderExtensions(); private: + TransceiverState Shown(); + std::shared_ptr _factory; webrtc::scoped_refptr _transceiver; diff --git a/python-webrtc/cpp/src/interfaces/rtc_sctp_transport.cpp b/python-webrtc/cpp/src/interfaces/rtc_sctp_transport.cpp index 8297da8..ed1a000 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_sctp_transport.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_sctp_transport.cpp @@ -8,6 +8,7 @@ #include "rtc_sctp_transport.h" #include "../utils/gil.h" #include "../utils/libwebrtc_thread.h" +#include "../utils/parking.h" namespace python_webrtc { @@ -20,7 +21,12 @@ namespace python_webrtc { _transport->RegisterObserver(this); registry().SetObserver(_transport.get(), this); _observing = true; - _lastState = _transport->Information().state(); + const auto information = _transport->Information(); + _lastState = information.state(); + _lastMaxChannels = information.MaxChannels(); + // shown until its first change, unless read unbound + _surfacedState.Changed(true, _lastState); + _surfacedMaxChannels.Changed(true, _lastMaxChannels); if (_lastState == webrtc::SctpTransportState::kClosed) { Stop(); @@ -45,7 +51,8 @@ namespace python_webrtc { .def_property_readonly("state", nogil_fn(&RTCSctpTransport::GetState)) .def_property_readonly("maxMessageSize", nogil_fn(&RTCSctpTransport::GetMaxMessageSize)) .def_property_readonly("maxChannels", nogil_fn(&RTCSctpTransport::GetMaxChannels)) - .def("_surfaceState", &RTCSctpTransport::SurfaceState, nogil(), pybind11::arg("state")); + .def("_surfaceState", &RTCSctpTransport::SurfaceState, nogil(), pybind11::arg("state"), + pybind11::arg("maxChannels")); } Registry &RTCSctpTransport::registry() { @@ -64,8 +71,13 @@ namespace python_webrtc { void RTCSctpTransport::OnStateChange(webrtc::SctpTransportInformation info) { if (info.state() != _lastState) { _surfacedState.Changed(IsBound(), _lastState); + _surfacedMaxChannels.Changed(IsBound(), _lastMaxChannels); _lastState = info.state(); - Emit("statechange", info.state()); + _lastMaxChannels = info.MaxChannels(); + if (info.state() == webrtc::SctpTransportState::kConnected) { + Parking::At("sctp.connected"); + } + Emit("statechange", info.state(), info.MaxChannels()); } if (info.state() == webrtc::SctpTransportState::kClosed) { @@ -76,6 +88,7 @@ namespace python_webrtc { void RTCSctpTransport::OnPeerConnectionClosed() { Unbind(); _surfacedState.Reset(); + _surfacedMaxChannels.Reset(); } std::shared_ptr RTCSctpTransport::GetTransport() { @@ -91,8 +104,9 @@ namespace python_webrtc { return _surfacedState.ShownOr(IsBound(), [this]() { return Information().state(); }); } - void RTCSctpTransport::SurfaceState(webrtc::SctpTransportState state) { + void RTCSctpTransport::SurfaceState(webrtc::SctpTransportState state, std::optional maxChannels) { _surfacedState.Surface(state); + _surfacedMaxChannels.Surface(maxChannels); } std::optional RTCSctpTransport::GetMaxMessageSize() { @@ -110,7 +124,7 @@ namespace python_webrtc { if (state == webrtc::SctpTransportState::kNew || state == webrtc::SctpTransportState::kConnecting) { return {}; } - return Information().MaxChannels(); + return _surfacedMaxChannels.ShownOr(IsBound(), [this]() { return Information().MaxChannels(); }); } } // namespace python_webrtc diff --git a/python-webrtc/cpp/src/interfaces/rtc_sctp_transport.h b/python-webrtc/cpp/src/interfaces/rtc_sctp_transport.h index 3048487..7166817 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_sctp_transport.h +++ b/python-webrtc/cpp/src/interfaces/rtc_sctp_transport.h @@ -60,8 +60,7 @@ namespace python_webrtc { std::optional GetMaxChannels(); - // see Surfaced - void SurfaceState(webrtc::SctpTransportState state); + void SurfaceState(webrtc::SctpTransportState state, std::optional maxChannels); private: // on the network thread @@ -77,8 +76,10 @@ namespace python_webrtc { // accessed on the network thread only bool _observing = false; webrtc::SctpTransportState _lastState = webrtc::SctpTransportState::kNew; + std::optional _lastMaxChannels; Surfaced _surfacedState; + Surfaced> _surfacedMaxChannels; LockedFunction()> _maxMessageSizeGetter; }; diff --git a/python-webrtc/cpp/src/interfaces/transceiver_state.h b/python-webrtc/cpp/src/interfaces/transceiver_state.h new file mode 100644 index 0000000..c06c7de --- /dev/null +++ b/python-webrtc/cpp/src/interfaces/transceiver_state.h @@ -0,0 +1,36 @@ +// +// Copyright 2022-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. +// + +#ifndef PYTHON_WEBRTC_INTERFACES_TRANSCEIVER_STATE_H_ +#define PYTHON_WEBRTC_INTERFACES_TRANSCEIVER_STATE_H_ + +#include +#include + +#include + +namespace python_webrtc { + + struct TransceiverState { + std::optional mid; + std::optional currentDirection; + bool stopping = false; + bool stopped = false; + + static TransceiverState Of(const webrtc::RtpTransceiverInterface &transceiver) { + return {.mid = transceiver.mid(), + .currentDirection = transceiver.current_direction(), + .stopping = transceiver.stopping(), + .stopped = transceiver.stopped()}; + } + + static TransceiverState Gone() { return {.stopping = true, .stopped = true}; } + }; + +} // namespace python_webrtc + +#endif // PYTHON_WEBRTC_INTERFACES_TRANSCEIVER_STATE_H_ diff --git a/python-webrtc/cpp/src/utils/surfaced.h b/python-webrtc/cpp/src/utils/surfaced.h index 0533cb0..ccbeb96 100644 --- a/python-webrtc/cpp/src/utils/surfaced.h +++ b/python-webrtc/cpp/src/utils/surfaced.h @@ -10,6 +10,7 @@ #include #include +#include namespace python_webrtc { @@ -19,7 +20,7 @@ namespace python_webrtc { class Surfaced { public: // a change reported by libwebrtc, before its event is emitted - void Changed(bool bound, T previous) { + void Changed(bool bound, const T &previous) { const std::scoped_lock lock(_mutex); if (!bound) { _value.reset(); @@ -30,7 +31,15 @@ namespace python_webrtc { void Surface(T value) { const std::scoped_lock lock(_mutex); - _value = value; + _value = std::move(value); + } + + template + void Update(F update) { + const std::scoped_lock lock(_mutex); + if (_value) { + update(*_value); + } } // shows the current value again, like when events stop diff --git a/python-webrtc/python/webrtc/interfaces/rtc_data_channel.py b/python-webrtc/python/webrtc/interfaces/rtc_data_channel.py index c8c5cf5..ae8a044 100644 --- a/python-webrtc/python/webrtc/interfaces/rtc_data_channel.py +++ b/python-webrtc/python/webrtc/interfaces/rtc_data_channel.py @@ -139,7 +139,7 @@ class RTCDataChannel(WebRTCObject[wrtc.RTCDataChannel], EventTarget[_DataChannel __slots__ = () _class = wrtc.RTCDataChannel - # handlers that keep a channel alive in each state, per spec + # handlers that keep a channel alive in each state, for the events it can still fire, per spec _KEEPING: ClassVar[dict[RTCDataChannelState, frozenset[str]]] = { RTCDataChannelState.connecting: frozenset({'open', 'message', 'error', 'closing', 'close'}), RTCDataChannelState.open: frozenset({'message', 'error', 'closing', 'close'}), @@ -239,6 +239,9 @@ def _activity(self) -> str | None: # the native state follows the connection's close later if keeping is None or not self._open(): return None + if state == RTCDataChannelState.open and not self._native_obj._openDelivered: + # an announced channel reads open before its open event + keeping |= {'open'} handled = self.event_names() & keeping if len(handled) > 0: return f'{state.value} with {", ".join(sorted(handled))} handlers' diff --git a/python-webrtc/python/webrtc/interfaces/rtc_ice_transport.py b/python-webrtc/python/webrtc/interfaces/rtc_ice_transport.py index df67d29..655572d 100644 --- a/python-webrtc/python/webrtc/interfaces/rtc_ice_transport.py +++ b/python-webrtc/python/webrtc/interfaces/rtc_ice_transport.py @@ -142,6 +142,9 @@ def _on_event(self, name: str, *args: object) -> None: elif name == 'gatheringstatechange': (gathering_state,) = cast('tuple[RTCIceGathererState]', args) self._native_obj._surfaceGatheringState(gathering_state) + elif name == 'selectedcandidatepairchange': + (pair,) = cast('tuple[tuple[wrtc.IceCandidateInit, wrtc.IceCandidateInit, bool]]', args) + self._native_obj._surfaceSelectedPair(pair) elif name == 'icecandidate' and len(args) > 0 and args[0] is not None: self._native_obj._surfaceCandidate() diff --git a/python-webrtc/python/webrtc/interfaces/rtc_peer_connection.py b/python-webrtc/python/webrtc/interfaces/rtc_peer_connection.py index b9b9dce..c3761f4 100644 --- a/python-webrtc/python/webrtc/interfaces/rtc_peer_connection.py +++ b/python-webrtc/python/webrtc/interfaces/rtc_peer_connection.py @@ -479,9 +479,7 @@ def _completed_description(self) -> None: """The success task of setting a description.""" # the descriptions as the operation left them, candidates gathered since come with their events self._native_obj._applyDescriptions() - # getParameters() returns the negotiated parameters from now on - for sender in self._native_obj.getSenders(): - sender._expireParameters() + self._native_obj._expireParameters() async def create_offer(self, options: webrtc.RTCOfferOptions | None = None) -> webrtc.RTCSessionDescriptionInit: """Creates an offer that starts a negotiation or renegotiates one. diff --git a/python-webrtc/python/webrtc/interfaces/rtc_sctp_transport.py b/python-webrtc/python/webrtc/interfaces/rtc_sctp_transport.py index a2657db..380f0dc 100644 --- a/python-webrtc/python/webrtc/interfaces/rtc_sctp_transport.py +++ b/python-webrtc/python/webrtc/interfaces/rtc_sctp_transport.py @@ -38,9 +38,9 @@ class RTCSctpTransport(WebRTCObject[wrtc.RTCSctpTransport], UniformEventTarget[L @override def _on_event(self, name: str, *args: object) -> None: - (state,) = cast('tuple[webrtc.RTCSctpTransportState]', args) - # the state changes along with its event - self._native_obj._surfaceState(state) + state, max_channels = cast('tuple[webrtc.RTCSctpTransportState, int | None]', args) + # the state changes along with its event, and the channels it allows + self._native_obj._surfaceState(state, max_channels) @override def _activity(self) -> str | None: diff --git a/python-webrtc/python/webrtc/utils/loops.py b/python-webrtc/python/webrtc/utils/loops.py index bac4879..9f13b86 100644 --- a/python-webrtc/python/webrtc/utils/loops.py +++ b/python-webrtc/python/webrtc/utils/loops.py @@ -154,7 +154,7 @@ def __init__(self, loop: asyncio.AbstractEventLoop) -> None: self._read_at: weakref.WeakKeyDictionary[EventTarget[Never], list[int]] = weakref.WeakKeyDictionary() self._passes = 0 self._markers: dict[int, _Item] = {} - # drop handlers of call_soon callbacks not run yet, by key + # drop handlers of posted callbacks not run yet, by key self._soon: dict[int, Callable[[], object]] = {} self._next_key = 0 # re-entrant, like _states_lock @@ -242,51 +242,47 @@ def start(self, target: EventTarget[Never] | None) -> tuple[int, asyncio.Future[ self.pending[token] = _Call(future, weakref.ref(target) if target is not None else None) return token, future - def post(self, callback: Callable[[], object], *, resumes: bool = False, after_ready: bool = False) -> None: - """Schedules a callback in order with the native records (flags as in ``_Item``).""" - item = _Item(callback, resumes, after_ready) - with self._lock: - key = self._key() - # after allocating: gc may have released the state under the lock - if self._released: - return - self._markers[key] = item - self.mailbox.postMarker(key) - self._schedule() - - def call_soon(self, callback: Callable[[], object], *, dropped: Callable[[], object]) -> None: - """Runs a callback on the loop soon, from any thread, unordered with the native records. - - If the loop is released first, ``dropped`` runs instead: exactly one of them runs. + def post( + self, + callback: Callable[[], object], + *, + resumes: bool = False, + after_ready: bool = False, + dropped: Callable[[], object] | None = None, + ) -> None: + """Schedules a callback in order with the native records (flags as in ``_Item``). + + If the loop is released first, ``dropped`` runs instead, if given: exactly one of them runs. """ with self._lock: key = self._key() + # after allocating: gc may have released the state under the lock released = self._released if not released: - self._soon[key] = dropped + self._markers[key] = _Item(self._unless_dropped(key, callback), resumes, after_ready) + if dropped is not None: + self._soon[key] = dropped if released: - _ = dropped() + if dropped is not None: + _ = dropped() return + self.mailbox.postMarker(key) + self._schedule() + + def _unless_dropped(self, key: int, callback: Callable[[], object]) -> Callable[[], object]: + """The callback of a posted item, which a release that ran its drop handler (held or queued) skips.""" def run() -> None: with self._lock: - # gone once released: its drop handler ran - if self._soon.pop(key, None) is None: + if self._released: return + _ = self._soon.pop(key, None) _ = callback() - # closed or gone: the release runs the drop handler - loop = self._loop_ref() - if loop is None: - self.release() - return - try: - _ = loop.call_soon_threadsafe(run) - except RuntimeError: - self.release() + return run def _key(self) -> int: - """Next marker, completion or ``call_soon`` key; call under the lock.""" + """Next marker or completion key; call under the lock.""" self._next_key += 1 return self._next_key diff --git a/python-webrtc/python/webrtc/utils/operations.py b/python-webrtc/python/webrtc/utils/operations.py index 65fa825..b3599e9 100644 --- a/python-webrtc/python/webrtc/utils/operations.py +++ b/python-webrtc/python/webrtc/utils/operations.py @@ -104,8 +104,9 @@ def resolve() -> None: if not waiter.done(): waiter.set_result(None) - # if the loop is released first, this operation can't run, so it ends then - previous.add_done_callback(lambda _: state.call_soon(resolve, dropped=lambda: self._end(done))) + # in order with the native records, once the code the previous one resumed ran (see LoopState.drain); if the + # loop is released first, this operation can't run, so it ends then + previous.add_done_callback(lambda _: state.post(resolve, after_ready=True, dropped=lambda: self._end(done))) return waiter def _end(self, done: concurrent.futures.Future[None]) -> None: diff --git a/stubs/wrtc/__init__.pyi b/stubs/wrtc/__init__.pyi index 17bbb7f..b078fcf 100644 --- a/stubs/wrtc/__init__.pyi +++ b/stubs/wrtc/__init__.pyi @@ -425,6 +425,8 @@ class RTCIceTransport(Binding): ... def _surfaceGatheringState(self, state: webrtc.enums.RTCIceGathererState) -> None: ... + def _surfaceSelectedPair(self, pair: tuple[IceCandidateInit, IceCandidateInit, bool] | None) -> None: + ... def _surfaceState(self, state: webrtc.enums.RTCIceTransportState) -> None: ... def addRemoteCandidate(self, candidate: str, sdpMid: str, sdpMLineIndex: typing.SupportsInt | typing.SupportsIndex, usernameFragment: str | None) -> None: @@ -481,7 +483,7 @@ class RTCSctpTransport(Binding): @property def _id(self) -> int: ... - def _surfaceState(self, state: webrtc.enums.RTCSctpTransportState) -> None: + def _surfaceState(self, state: webrtc.enums.RTCSctpTransportState, maxChannels: typing.SupportsInt | typing.SupportsIndex | None) -> None: ... @property def maxChannels(self) -> int | None: @@ -647,6 +649,9 @@ class RTCDataChannel(Binding): def send(self, data: bytes, binary: bool) -> bool: ... @property + def _openDelivered(self) -> bool: + ... + @property def bufferedAmount(self) -> int: ... @property @@ -693,6 +698,8 @@ class RTCPeerConnection(Binding): ... def _applyDescriptions(self, snapshot: typing.SupportsInt | typing.SupportsIndex | None = None) -> None: ... + def _expireParameters(self) -> None: + ... def _refreshDescriptions(self) -> None: ... def _shouldFireNegotiationNeededEvent(self, eventId: typing.SupportsInt | typing.SupportsIndex) -> bool: diff --git a/tests/helpers.py b/tests/helpers.py index 9e749d8..3773a22 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -32,7 +32,7 @@ from webrtc.utils import loops if TYPE_CHECKING: - from collections.abc import AsyncGenerator, Awaitable + from collections.abc import AsyncGenerator, Awaitable, Generator #: The fixture creating connections with a configuration CreatePC = Callable[..., webrtc.RTCPeerConnection] @@ -216,6 +216,23 @@ async def next_task() -> None: await timer +@contextlib.contextmanager +def undelivered() -> Generator[None, None, None]: + """Keeps what libwebrtc's threads post in the mailboxes, by parking the Dispatcher's wake. + + Only loops woken through the Dispatcher wait (``LoopState.watches_pipe = False`` before their first drain). + Timers still run on them. + """ + wrtc._testing.park('dispatcher.wake') + try: + # a post into an empty mailbox wakes the Dispatcher, which parks: later wakes queue behind it + wrtc._testing.post(wrtc.Mailbox(), 'event', 1) + assert wrtc._testing.parked('dispatcher.wake', seconds(5)) + yield + finally: + wrtc._testing.release('dispatcher.wake') + + def exit_code_within(pid: int, seconds: float) -> int | None: """A forked child's exit code, or None if killed on timeout.""" deadline = time.monotonic() + seconds diff --git a/tests/test_data_channel.py b/tests/test_data_channel.py index 76ff652..1eadc0b 100644 --- a/tests/test_data_channel.py +++ b/tests/test_data_channel.py @@ -21,7 +21,11 @@ import webrtc import wrtc from tests.helpers import ( + collect, connect, + exchange_answer, + exchange_ice_candidates, + exchange_offer, mistyped, stats_of_type, wait_for_event, @@ -404,6 +408,27 @@ def on_datachannel(event: webrtc.RTCDataChannelEvent) -> None: assert events == [] +@pytest.mark.asyncio +async def test_announced_channel_kept_for_its_open_event( + caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection +) -> None: + """A remote channel reads open from its datachannel event on, and its open handler keeps it until the event.""" + states: list[webrtc.RTCDataChannelState] = [] + opened: asyncio.Future[None] = asyncio.get_running_loop().create_future() + + def on_channel(event: webrtc.RTCDataChannelEvent) -> None: + states.append(event.channel.ready_state) + event.channel.on('open', lambda _event: opened.set_result(None)) + # once this handler returns nothing holds the channel; the loop runs this before the next record + asyncio.get_running_loop().call_soon(collect) + + callee.on('datachannel', on_channel) + caller.create_data_channel('announced') + await connect(caller, callee) + await asyncio.wait_for(opened, seconds(10)) + assert states == [webrtc.RTCDataChannelState.open] + + @pytest.mark.asyncio async def test_messages_sent_on_open_reach_a_handler_added_on_datachannel( caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection @@ -624,6 +649,37 @@ def counted(state: loops.LoopState, method: Callable[..., None], *args: object) assert stats.messages_received == 1 +@pytest.mark.asyncio +async def test_max_channels_with_the_connected_statechange( + caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection +) -> None: + """The max number of channels is known once the connected statechange is delivered.""" + caller.create_data_channel('channels') + exchange_ice_candidates(caller, callee) + await exchange_offer(caller, callee) + transports = (caller.sctp, callee.sctp) + + def parked_max_channels() -> list[int | None]: + # off the loop, which may wait for the network thread while it's parked + try: + assert wrtc._testing.parked('sctp.connected', seconds(10)) + return [transport.max_channels for transport in transports if transport is not None] + finally: + wrtc._testing.release('sctp.connected') + + wrtc._testing.park('sctp.connected') + answering = asyncio.ensure_future(exchange_answer(caller, callee)) + # connected natively, its event not delivered + assert await asyncio.to_thread(parked_max_channels) == [None, None] + await answering + + def connected() -> bool: + return all(t is not None and t.state == webrtc.RTCSctpTransportState.connected for t in transports) + + await wait_until(connected, 'SCTP to connect') + assert all(t is not None and t.max_channels is not None and t.max_channels > 0 for t in transports) + + @pytest.mark.asyncio async def test_a_delivered_message_ends_the_stats_batch( caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection diff --git a/tests/test_events.py b/tests/test_events.py index b079204..cbeb0f2 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -15,17 +15,21 @@ import gc import threading import weakref -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, Literal, cast import pytest import webrtc +import wrtc from tests.helpers import ( called, collect, connect, delivered, + exchange_answer, + exchange_offer, exchange_offer_answer, + undelivered, wait_for_event, wait_for_ice_gathering_complete, wait_until, @@ -278,6 +282,148 @@ def on_change(_event: webrtc.Event) -> None: assert states == [webrtc.RTCSignalingState.have_local_offer, webrtc.RTCSignalingState.stable] +async def _set_undelivered(pc: webrtc.RTCPeerConnection) -> asyncio.Future[None]: + """Sets the local description, which libwebrtc applies while its events and completion wait in the mailbox.""" + wrtc._testing.park('description.completed') + try: + setting = asyncio.ensure_future(pc.set_local_description()) + # applied: the completion is posted + assert await asyncio.to_thread(wrtc._testing.parked, 'description.completed', seconds(5)) + finally: + wrtc._testing.release('description.completed') + return setting + + +def test_transceivers_change_with_the_descriptions(monkeypatch: pytest.MonkeyPatch) -> None: + """A transceiver's mid and current direction change once Python sees the description that set them.""" + monkeypatch.setattr(LoopState, 'watches_pipe', False) + + async def scenario() -> None: + caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + offered = caller.add_transceiver(webrtc.MediaType.audio) + await delivered() + with undelivered(): + setting = await _set_undelivered(caller) + assert (offered.mid, offered.current_direction, caller.local_description) == (None, None, None) + await setting + assert offered.mid == '0' + assert caller.local_description is not None + + await callee.set_remote_description(caller.local_description) + [answered] = callee.get_transceivers() + await delivered() + with undelivered(): + setting = await _set_undelivered(callee) + assert (answered.current_direction, callee.local_description) == (None, None) + await setting + assert answered.current_direction == webrtc.RTCRtpTransceiverDirection.recvonly + caller.close() + callee.close() + + asyncio.run(scenario()) + + +@pytest.mark.asyncio +async def test_close_stops_the_transceivers_at_once( + caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection +) -> None: + """close() stops every transceiver synchronously, though no description shows it.""" + caller.add_transceiver(webrtc.MediaType.audio) + await exchange_offer_answer(caller, callee) + callee.close() + # read through a wrapper made after close(), which binds the connection again + [transceiver] = callee.get_transceivers() + assert (transceiver.mid, transceiver.stopped) == ('0', True) + assert transceiver.current_direction == webrtc.RTCRtpTransceiverDirection.stopped + + +@pytest.mark.asyncio +async def test_answer_shows_its_transceivers_to_its_events( + caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection +) -> None: + """The signalingstatechange and track events of an answer see the transceivers as the answer left them.""" + audio = caller.add_transceiver(webrtc.MediaType.audio) + video = caller.add_transceiver(webrtc.MediaType.video) + await exchange_offer(caller, callee) + callee.get_transceivers()[0].direction = webrtc.RTCRtpTransceiverDirection.sendrecv + answer = await callee.create_answer() + await callee.set_local_description(answer) + seen: list[tuple[str, object, object, bool]] = [] + + def on_event(event: webrtc.Event) -> None: + seen.append((event.type, audio.current_direction, video.current_direction, video.stopped)) + + caller.on('signalingstatechange', on_event) + caller.on('track', on_event) + # the remote peer rejects the video section + await caller.set_remote_description( + webrtc.RTCSessionDescriptionInit('answer', answer.sdp.replace('m=video 9 ', 'm=video 0 ')) + ) + + sendrecv, stopped = webrtc.RTCRtpTransceiverDirection.sendrecv, webrtc.RTCRtpTransceiverDirection.stopped + assert seen == [('signalingstatechange', sendrecv, stopped, True), ('track', sendrecv, stopped, True)] + + +@pytest.mark.asyncio +async def test_rollback_shows_the_transceivers_it_restores( + caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection +) -> None: + """A rollback takes back the mids it gave, and the transceivers it removes read stopped.""" + added = caller.add_transceiver(webrtc.MediaType.audio) + await caller.set_local_description() + assert added.mid == '0' + await caller.set_local_description(webrtc.RTCSessionDescriptionInit('rollback')) + assert (added.mid, added.stopped) == (None, False) + + await caller.set_local_description() + await callee.set_remote_description(cast('webrtc.RTCSessionDescription', caller.local_description)) + [created] = callee.get_transceivers() + assert created.mid == added.mid + await callee.set_remote_description(webrtc.RTCSessionDescriptionInit('rollback')) + assert created.stopped + assert callee.get_transceivers() == [] + + +@pytest.mark.asyncio +async def test_stopped_transceiver_through_a_negotiation( + caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection +) -> None: + """stop() makes a transceiver stopping at once, the answer stops it, and it leaves the connection.""" + transceiver = caller.add_transceiver(webrtc.MediaType.audio) + await exchange_offer_answer(caller, callee) + transceiver.stop() + assert (transceiver._native_obj.stopping, transceiver.stopped) == (True, False) + await exchange_offer(caller, callee) + assert not transceiver.stopped + await exchange_answer(caller, callee) + assert (transceiver.stopped, transceiver.current_direction) == (True, webrtc.RTCRtpTransceiverDirection.stopped) + assert caller.get_transceivers() == [] + + +@pytest.mark.asyncio +async def test_close_while_a_description_waits_to_be_set(pc: webrtc.RTCPeerConnection) -> None: + """close() before an operation's signaling task runs aborts the operation; the transceivers read stopped.""" + transceiver = pc.add_transceiver(webrtc.MediaType.audio) + pending = LoopState.of(asyncio.get_running_loop()).pending + wrtc._testing.park('description.completed') + try: + await pc.set_local_description() + assert await asyncio.to_thread(wrtc._testing.parked, 'description.completed', seconds(5)) + setting = asyncio.ensure_future(pc.set_local_description()) + # its signaling task is posted, behind the parked one + await wait_until(lambda: len(pending) > 0, 'the operation') + # off the loop: close() waits for the signaling thread, after taking the connection + closing = asyncio.ensure_future(asyncio.to_thread(pc.close)) + await wait_until(lambda: pc.signaling_state == webrtc.RTCSignalingState.closed, 'close()') + finally: + wrtc._testing.release('description.completed') + await closing + # the specification aborts its steps + with pytest.raises(asyncio.CancelledError): + await setting + assert (transceiver.stopped, transceiver.current_direction) == (True, webrtc.RTCRtpTransceiverDirection.stopped) + + @pytest.mark.asyncio async def test_closed_connection_emits_nothing( caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection diff --git a/tests/test_ice_transport.py b/tests/test_ice_transport.py index 8bfdaf8..0111275 100644 --- a/tests/test_ice_transport.py +++ b/tests/test_ice_transport.py @@ -17,11 +17,20 @@ import re import socket import time +from typing import cast import pytest import webrtc -from tests.helpers import connect, exchange_answer, exchange_offer, wait_for_event, wait_until +import wrtc +from tests.helpers import ( + connect, + exchange_answer, + exchange_offer, + wait_for_event, + wait_for_ice_gathering_complete, + wait_until, +) from tests.timing import seconds @@ -168,6 +177,48 @@ def test_start_validation() -> None: transport.stop() +@pytest.mark.asyncio +async def test_selected_pair_changes_with_its_event( + caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection +) -> None: + """A transport's selected pair changes once Python sees its selectedcandidatepairchange event.""" + caller.create_data_channel('pair') + # the candidates go in the descriptions: none to trickle while the signaling thread is parked + await caller.set_local_description() + await wait_for_ice_gathering_complete(caller) + await callee.set_remote_description(cast('webrtc.RTCSessionDescription', caller.local_description)) + await callee.set_local_description() + await wait_for_ice_gathering_complete(callee) + assert caller.sctp is not None + assert callee.sctp is not None + transports = (caller.sctp.transport.ice_transport, callee.sctp.transport.ice_transport) + # the pair each transport showed last along with its event + shown = [transport.get_selected_candidate_pair() for transport in transports] + for index, transport in enumerate(transports): + transport.on( + 'selectedcandidatepairchange', + lambda _event, i=index, t=transport: shown.__setitem__(i, t.get_selected_candidate_pair()), + ) + + wrtc._testing.park('ice.pairchanged') + try: + answering = asyncio.ensure_future( + caller.set_remote_description(cast('webrtc.RTCSessionDescription', callee.local_description)) + ) + # a transport selected another pair, before its event + assert await asyncio.to_thread(wrtc._testing.parked, 'ice.pairchanged', seconds(5)) + assert [transport.get_selected_candidate_pair() for transport in transports] == shown + finally: + wrtc._testing.release('ice.pairchanged') + await answering + await wait_until(lambda: shown[0] is not None, 'the selected pair') + + ice = transports[0] + + caller.close() + assert ice.get_selected_candidate_pair() is None + + CANDIDATE = 'candidate:1 1 udp 2122260223 192.0.2.1 50000 typ host' @@ -396,6 +447,14 @@ def connected() -> bool: assert pair.remote.candidate == '' assert pair.remote.type == webrtc.RTCIceCandidateType.prflx assert pair.remote.address is None + + # signaled later, the remote candidate is the signaled one + for candidate in local.get_local_candidates(): + remote.add_remote_candidate(candidate) + pair = remote.get_selected_candidate_pair() + assert pair is not None + assert pair.remote.type == webrtc.RTCIceCandidateType.host + assert pair.remote.candidate in [c.candidate for c in local.get_local_candidates()] local.stop() remote.stop() diff --git a/tests/test_loops.py b/tests/test_loops.py index 2a3e84b..8cab39a 100644 --- a/tests/test_loops.py +++ b/tests/test_loops.py @@ -723,8 +723,8 @@ def register_elsewhere() -> None: pc.close() -def test_call_soon_runs_on_the_loop_from_another_thread() -> None: - """A callback scheduled from another thread runs on the loop's thread; its drop handler doesn't.""" +def test_post_from_another_thread_runs_on_the_loop() -> None: + """A callback posted from another thread runs on the loop's thread; its drop handler doesn't.""" ran: list[str] = [] async def schedule() -> None: @@ -736,27 +736,43 @@ def callback() -> None: done.set() loop_thread = threading.current_thread().name - _in_thread(lambda: state.call_soon(callback, dropped=lambda: ran.append('dropped'))) + _in_thread(lambda: state.post(callback, dropped=lambda: ran.append('dropped'))) await asyncio.wait_for(done.wait(), TIMEOUT) assert ran == [loop_thread] asyncio.run(schedule()) -def test_call_soon_of_a_released_loop_runs_the_drop_handler_instead() -> None: +def test_post_of_a_released_loop_runs_the_drop_handler_instead() -> None: """A callback queued when its loop is released never runs; its drop handler runs once, a later one drops at once.""" loop = asyncio.new_event_loop() state = LoopState.of(loop) ran: list[str] = [] # queued in the loop, which never runs again - state.call_soon(lambda: ran.append('queued'), dropped=lambda: ran.append('queued dropped')) + state.post(lambda: ran.append('queued'), dropped=lambda: ran.append('queued dropped')) loop.close() state.release() state.release() - state.call_soon(lambda: ran.append('late'), dropped=lambda: ran.append('late dropped')) + state.post(lambda: ran.append('late'), dropped=lambda: ran.append('late dropped')) assert ran == ['queued dropped', 'late dropped'] +def test_post_held_at_release_runs_its_drop_handler() -> None: + """A posted callback held behind a completion when its loop is released runs its drop handler, never itself.""" + loop = asyncio.new_event_loop() + state = LoopState.of(loop) + ran: list[str] = [] + token, _ = state.start(None) + wrtc._testing.complete(state.mailbox, token, 1) + state.post(lambda: ran.append('posted'), after_ready=True, dropped=lambda: ran.append('dropped')) + # runs the completion, then holds the callback for a later iteration + state.drain() + assert state._held is not None + loop.close() + state.release() + assert ran == ['dropped'] + + def test_update_roots_by_activity() -> None: loop = asyncio.new_event_loop() try: @@ -791,7 +807,7 @@ def is_closed(self) -> bool: return self.closed -@pytest.mark.parametrize('use', ['post', 'call_soon']) +@pytest.mark.parametrize('use', ['post', 'post with a drop handler']) def test_a_state_outliving_its_loop_is_released_by_its_next_use(use: str) -> None: """Posts and calls are dropped, and new calls fail.""" loop = _DictLoop() @@ -803,7 +819,7 @@ def test_a_state_outliving_its_loop_is_released_by_its_next_use(use: str) -> Non if use == 'post': state.post(lambda: ran.append('posted')) else: - state.call_soon(lambda: ran.append('soon'), dropped=lambda: ran.append('dropped')) + state.post(lambda: ran.append('posted'), dropped=lambda: ran.append('dropped')) expected: list[str] = [] if use == 'post' else ['dropped'] _swept(lambda: state.mailbox.closed and ran == expected) assert state._markers == {} @@ -811,7 +827,7 @@ def test_a_state_outliving_its_loop_is_released_by_its_next_use(use: str) -> Non state.start(None) -@pytest.mark.parametrize('use', ['post', 'call_soon']) +@pytest.mark.parametrize('use', ['post', 'post with a drop handler']) def test_a_post_to_a_closed_loop_releases_its_state(use: str) -> None: """Only the drop handler runs.""" loop = asyncio.new_event_loop() @@ -821,7 +837,7 @@ def test_a_post_to_a_closed_loop_releases_its_state(use: str) -> None: if use == 'post': state.post(lambda: ran.append('posted')) else: - state.call_soon(lambda: ran.append('soon'), dropped=lambda: ran.append('dropped')) + state.post(lambda: ran.append('posted'), dropped=lambda: ran.append('dropped')) expected: list[str] = [] if use == 'post' else ['dropped'] _swept(lambda: state.mailbox.closed and ran == expected) assert LoopState.find(state.mailbox.id) is None @@ -831,12 +847,12 @@ def test_a_post_to_a_closed_loop_releases_its_state(use: str) -> None: assert ran == expected -def test_call_soon_released_before_it_runs_runs_the_drop_handler_only() -> None: +def test_post_released_before_it_runs_runs_the_drop_handler_only() -> None: ran: list[str] = [] async def release() -> None: state = LoopState.of(asyncio.get_running_loop()) - state.call_soon(lambda: ran.append('soon'), dropped=lambda: ran.append('dropped')) + state.post(lambda: ran.append('posted'), dropped=lambda: ran.append('dropped')) state.release() await asyncio.sleep(0) await asyncio.sleep(0) diff --git a/tests/test_operations.py b/tests/test_operations.py index d5dc3a7..bb903a1 100644 --- a/tests/test_operations.py +++ b/tests/test_operations.py @@ -61,6 +61,39 @@ async def operation(name: str, *, hold: bool = False) -> None: assert not chain.busy +@pytest.mark.asyncio +async def test_next_operation_waits_for_the_code_the_previous_one_resumed() -> None: + """Code awaiting an operation resumes before the next one chained behind it, even over a few iterations. + + Like a browser's microtasks run before a task: a callback chain the completion started, as a JS bridge's promise + jobs are, starts its work first. + """ + chain = OperationsChain(lambda: None, lambda: False) + release = asyncio.Event() + order: list[str] = [] + + async def operation(name: str, *, hold: bool = False) -> None: + async with chain.operation(): + if hold: + await release.wait() + order.append(name) + + async def awaiting() -> None: + await operation('first', hold=True) + for _ in range(3): + # a step of the resumed code + await asyncio.sleep(0) + order.append('resumed') + + resumed = asyncio.ensure_future(awaiting()) + await asyncio.sleep(0) + second = asyncio.ensure_future(operation('second')) + await asyncio.sleep(0) + release.set() + await asyncio.wait_for(asyncio.gather(resumed, second), seconds(5)) + assert order == ['first', 'resumed', 'second'] + + @pytest.mark.asyncio async def test_operations_of_a_closed_chain_run_at_once() -> None: """Once closed, an operation doesn't wait for the running one.""" diff --git a/tests/test_robustness_threads.py b/tests/test_robustness_threads.py index bfe630a..d081d55 100644 --- a/tests/test_robustness_threads.py +++ b/tests/test_robustness_threads.py @@ -21,9 +21,10 @@ import webrtc import wrtc -from tests.helpers import connect, exchange_offer_answer, settled_alive, wait_for_event +from tests.helpers import connect, exchange_offer_answer, settled_alive, wait_for_event, wait_until from tests.isolation import isolated from tests.timing import seconds +from webrtc.utils.loops import LoopState def _join(threads: list[threading.Thread]) -> None: @@ -291,3 +292,86 @@ async def scenario() -> None: finally: wrtc._testing.release('dispatcher.wake') assert settled_alive() == baseline + + +async def _read_while_the_signaling_thread_is_parked() -> None: + caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + [track] = (await webrtc.media_devices.get_user_media(webrtc.MediaStreamConstraints(audio=True))).get_tracks() + sender = caller.add_track(track) + await _open_channel(caller, callee) + [transceiver] = caller.get_transceivers() + sctp = caller.sctp + assert sctp is not None + await wait_until(lambda: sctp.transport.ice_transport.get_selected_candidate_pair() is not None, 'the pair') + # bound, and never negotiated + fresh = webrtc.RTCPeerConnection() + remote = callee.get_transceivers()[0].receiver.track + + def read() -> tuple[object, ...]: + pair = sctp.transport.ice_transport.get_selected_candidate_pair() + return ( + fresh.ice_gathering_state, + remote.ready_state, + transceiver.mid, + transceiver.current_direction, + transceiver.stopped, + sender.track, + pair is not None, + sctp.max_channels, + ) + + expected = read() + wrtc._testing.park('connection.datachannel') + try: + # off the loop: creating it waits for the parked signaling thread too + creating = asyncio.ensure_future(asyncio.to_thread(caller.create_data_channel, 'parked')) + assert await asyncio.to_thread(wrtc._testing.parked, 'connection.datachannel', seconds(5)) + assert await asyncio.wait_for(asyncio.to_thread(read), seconds(5)) == expected + finally: + wrtc._testing.release('connection.datachannel') + await creating + track.stop() + fresh.close() + caller.close() + callee.close() + + +@isolated(timeout=seconds(60)) +def test_getters_do_not_wait_for_the_signaling_thread() -> None: + """Transceiver, sender, track, ICE and SCTP getters return what Python was shown while signaling is busy.""" + asyncio.run(_read_while_the_signaling_thread_is_parked()) + + +async def _start_while_the_previous_operation_releases() -> list[str]: + caller, callee = webrtc.RTCPeerConnection(), webrtc.RTCPeerConnection() + caller.add_transceiver(webrtc.MediaType.audio) + order: list[str] = [] + callee.on('icegatheringstatechange', lambda _event: order.append(callee.ice_gathering_state.value)) + await caller.set_local_description() + offer = caller.local_description + assert offer is not None + + pending = LoopState.of(asyncio.get_running_loop()).pending + wrtc._testing.park('description.completed') + try: + # resolved, while its signaling task is parked before it releases what it held + await callee.set_remote_description(offer) + assert await asyncio.to_thread(wrtc._testing.parked, 'description.completed', seconds(5)) + answering = asyncio.ensure_future(callee.set_local_description()) + # pending once its signaling task is posted, behind the parked one + await wait_until(lambda: len(pending) > 0, 'the next operation') + finally: + wrtc._testing.release('description.completed') + await answering + order.append('answered') + await wait_until(lambda: 'gathering' in order, 'gathering') + caller.close() + callee.close() + return order + + +@isolated(timeout=seconds(60)) +def test_operation_holds_its_events_after_the_previous_one_releases() -> None: + """The next operation starts as the one before releases its hold on events, and still holds its own.""" + order = asyncio.run(_start_while_the_previous_operation_releases()) + assert order.index('answered') < order.index('gathering'), order diff --git a/tests/test_rtp_sender_receiver.py b/tests/test_rtp_sender_receiver.py index c0de645..96cbb65 100644 --- a/tests/test_rtp_sender_receiver.py +++ b/tests/test_rtp_sender_receiver.py @@ -267,6 +267,37 @@ async def test_replace_track( assert sender.track is None +@pytest.mark.asyncio +async def test_sender_track_is_the_one_it_was_given( + caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection, audio_stream: webrtc.MediaStream +) -> None: + """addTrack, addTransceiver and replaceTrack give a sender its track, removeTrack takes it, close() keeps it.""" + [audio] = audio_stream.get_tracks() + other = audio.clone() + sender = caller.add_track(audio, audio_stream) + assert sender.track is audio + await sender.replace_track(None) + assert sender.track is None + await sender.replace_track(other) + assert sender.track is other + caller.remove_track(sender) + assert sender.track is None + assert caller.add_transceiver(webrtc.MediaType.audio).sender.track is None + assert caller.add_transceiver(other).sender.track is other + + # addTrack reuses the transceiver a remote offer made, whose sender had no track + await exchange_offer(caller, callee) + created = callee.get_transceivers()[0].sender + assert created.track is None + reused = callee.add_track(audio) + assert reused is created + assert reused.track is audio + + callee.close() + assert reused.track is audio + other.stop() + + @pytest.mark.asyncio async def test_replace_track_of_a_stopped_transceiver(pc: webrtc.RTCPeerConnection) -> None: """The track of a stopped transceiver's sender can't be replaced.""" @@ -535,6 +566,21 @@ def is_last(event: webrtc.Event) -> bool: assert dtmf.tone_buffer == '' +@pytest.mark.asyncio +async def test_dtmf_of_a_negotiated_transceiver_stopped_since( + caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection, audio_stream: webrtc.MediaStream +) -> None: + """stop() ends the tones of a negotiated sender at once, before any negotiation shows it.""" + sender = caller.add_track(audio_stream.get_tracks()[0], audio_stream) + await connect(caller, callee) + dtmf = sender.dtmf + assert dtmf is not None + assert dtmf.can_insert_dtmf + caller.get_transceivers()[0].stop() + with pytest.raises(webrtc.InvalidStateError, match='stopped'): + dtmf.insert_dtmf('1') + + def test_dtmf_of_a_stopped_transceiver_checks_the_state_first(pc: webrtc.RTCPeerConnection) -> None: """Stopped beats bad tones.""" transceiver = pc.add_transceiver(webrtc.MediaType.audio) diff --git a/tests/test_tracks.py b/tests/test_tracks.py index 4ddcbfa..86ba2ad 100644 --- a/tests/test_tracks.py +++ b/tests/test_tracks.py @@ -122,6 +122,23 @@ async def test_rollback_ends_the_track_of_a_removed_transceiver( assert track.ready_state == webrtc.MediaStreamTrackState.ended +@pytest.mark.asyncio +async def test_track_ended_before_its_wrapper_ends_with_its_event( + caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection +) -> None: + """A remote track that ended before Python first used it ends with its event, like any other.""" + # nothing receives, so no track event wraps the callee's transceiver + stopped = caller.add_transceiver(webrtc.MediaType.audio, webrtc.RTCRtpTransceiverInit(direction='recvonly')) + await exchange_offer_answer(caller, callee) + stopped.stop() + # rejects the section, which stops the callee's transceiver and ends its track + await exchange_offer(caller, callee) + [transceiver] = callee.get_transceivers() + track = transceiver.receiver.track + await wait_for_event(track, 'ended') + assert track.ready_state == webrtc.MediaStreamTrackState.ended + + def test_remove_track_of_another_connection(caller: webrtc.RTCPeerConnection, callee: webrtc.RTCPeerConnection) -> None: """A connection can't remove a sender of another one.""" sender = callee.add_transceiver(webrtc.MediaType.audio).sender diff --git a/tests/wpt/expectations.json b/tests/wpt/expectations.json index 9872dba..e4f3771 100644 --- a/tests/wpt/expectations.json +++ b/tests/wpt/expectations.json @@ -597,9 +597,6 @@ "[harness]": "TIMEOUT", "icecandidateerror fires for each failing server": "TIMEOUT" }, - "webrtc/RTCPeerConnection-operations.https.html?interop-2026": { - "replaceTrack uses operations chain": "FAIL" - }, "webrtc/RTCPeerConnection-remote-track-currentTime.https.html": { "currentTime advances for receive audio track of active sender": "FAIL", "currentTime advances for receive audio track of inactive sender": "FAIL",