From c200a743287bf43d8588c7f1f057d77cfbe26fc9 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Mon, 6 Jul 2026 15:37:36 -0600 Subject: [PATCH 1/8] SubscriptionThreadDispatcher: guard against duplicate room events, duplicate room events test SubscriptionThreadDispatcher: proper replacing of audio/video callbacks. Deprecate a setOn*Callback(), replace with trySetOn*Callback() fix thread detaching --- README.md | 7 + include/livekit/data_track_stream.h | 2 - include/livekit/remote_data_track.h | 8 +- include/livekit/room.h | 90 +++- .../livekit/subscription_thread_dispatcher.h | 145 +++++-- src/room.cpp | 121 +++++- src/subscription_thread_dispatcher.cpp | 155 +++++-- src/tests/CMakeLists.txt | 2 - .../common/remote_data_track_test_access.h | 46 ++ src/tests/integration/test_data_track.cpp | 103 ++++- src/tests/integration/test_platform_audio.cpp | 22 +- .../test_room_event_deduplication.cpp | 408 ++++++++++++++++++ .../integration/test_video_frame_metadata.cpp | 46 +- src/tests/unit/test_room_callbacks.cpp | 22 +- .../test_subscription_thread_dispatcher.cpp | 383 ++++++++++++++-- 15 files changed, 1410 insertions(+), 150 deletions(-) create mode 100644 src/tests/common/remote_data_track_test_access.h create mode 100644 src/tests/integration/test_room_event_deduplication.cpp diff --git a/README.md b/README.md index 459a0ecd..6c685a1c 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,13 @@ The following features are deprecated and will be removed in the next major rele - `PacketTrailerFeatures` is deprecated. Use `FrameMetadataFeatures` via `TrackPublishOptions::frame_metadata_features` instead. +- `Room::setOnAudioFrameCallback`, `Room::setOnVideoFrameCallback`, and + `Room::setOnVideoFrameEventCallback` are deprecated. Use the `[[nodiscard]]` + variants `trySetOnAudioFrameCallback`, `trySetOnVideoFrameCallback`, and + `trySetOnVideoFrameEventCallback` instead, which return `false` when a reader + is already active for the key (instead of silently replacing a running callback). + To replace an active callback, call `clearOn*FrameCallback` first. (The same rename + applies to the corresponding `SubscriptionThreadDispatcher` methods.) ### `v1.0.0` diff --git a/include/livekit/data_track_stream.h b/include/livekit/data_track_stream.h index f5f07a90..e7e27f10 100644 --- a/include/livekit/data_track_stream.h +++ b/include/livekit/data_track_stream.h @@ -92,9 +92,7 @@ class LIVEKIT_API DataTrackStream { private: friend class RemoteDataTrack; -#ifdef LIVEKIT_TEST_ACCESS friend class DataTrackStreamTest; -#endif DataTrackStream() = default; /// Internal init helper, called by RemoteDataTrack. diff --git a/include/livekit/remote_data_track.h b/include/livekit/remote_data_track.h index 196cde54..60aabfe5 100644 --- a/include/livekit/remote_data_track.h +++ b/include/livekit/remote_data_track.h @@ -92,11 +92,6 @@ class RemoteDataTrack { /// @param options Pipeline options to apply to this remote data track. LIVEKIT_API void setPipelineOptions(const DataTrackPipelineOptions& options); -#ifdef LIVEKIT_TEST_ACCESS - /// Test-only accessor for exercising lower-level FFI subscription paths. - uintptr_t testFfiHandleId() const noexcept { return ffiHandleId(); } -#endif - /// Subscribe to this remote data track. /// /// Returns a DataTrackStream that delivers frames via blocking @@ -106,8 +101,9 @@ class RemoteDataTrack { private: friend class Room; + friend struct RemoteDataTrackTestAccess; - explicit RemoteDataTrack(const proto::OwnedRemoteDataTrack& owned); + LIVEKIT_INTERNAL_API explicit RemoteDataTrack(const proto::OwnedRemoteDataTrack& owned); uintptr_t ffiHandleId() const noexcept { return handle_.get(); } /// RAII wrapper for the Rust-owned FFI resource. diff --git a/include/livekit/room.h b/include/livekit/room.h index faffa715..37e3647d 100644 --- a/include/livekit/room.h +++ b/include/livekit/room.h @@ -313,16 +313,92 @@ class LIVEKIT_API Room { // Frame callbacks // --------------------------------------------------------------- - /// @brief Sets the audio frame callback via SubscriptionThreadDispatcher. + /// Register an audio frame callback for a remote subscription. + /// + /// The callback is keyed by @p participant_identity and @p track_name. If the + /// matching remote audio track is already subscribed, a reader is started + /// immediately; otherwise the reader starts when the track is subscribed. + /// + /// To replace a callback whose reader is already running, call + /// @ref clearOnAudioFrameCallback first, then register again: + /// @code + /// room.clearOnAudioFrameCallback(identity, track_name); + /// if (!room.trySetOnAudioFrameCallback(identity, track_name, new_handler)) { + /// // registration was rejected (a reader is still active) + /// } + /// @endcode + /// + /// @param participant_identity Identity of the remote participant. + /// @param track_name Track name to match. + /// @param callback Function invoked for each decoded audio frame. + /// @param opts Options used when creating the backing + /// @ref AudioStream. + /// @return @c true if the callback was registered; @c false if a reader is + /// already active for the key (call @ref clearOnAudioFrameCallback + /// first) or the room has no dispatcher. + [[nodiscard]] bool trySetOnAudioFrameCallback(const std::string& participant_identity, const std::string& track_name, + AudioFrameCallback callback, const AudioStream::Options& opts = {}); + + /// Register a video frame callback for a remote subscription. + /// + /// The callback is keyed by @p participant_identity and @p track_name. If the + /// matching remote video track is already subscribed, a reader is started + /// immediately; otherwise the reader starts when the track is subscribed. + /// + /// To replace a callback whose reader is already running, call + /// @ref clearOnVideoFrameCallback first, then register again. + /// + /// @param participant_identity Identity of the remote participant. + /// @param track_name Track name to match. + /// @param callback Function invoked for each decoded video frame. + /// @param opts Options used when creating the backing + /// @ref VideoStream. + /// @return @c true if the callback was registered; @c false if a reader is + /// already active for the key (call @ref clearOnVideoFrameCallback + /// first) or the room has no dispatcher. + [[nodiscard]] bool trySetOnVideoFrameCallback(const std::string& participant_identity, const std::string& track_name, + VideoFrameCallback callback, const VideoStream::Options& opts = {}); + + /// Register a rich video frame event callback for a remote subscription. + /// + /// The callback is keyed by @p participant_identity and @p track_name. If the + /// matching remote video track is already subscribed, a reader is started + /// immediately; otherwise the reader starts when the track is subscribed. + /// + /// To replace a callback whose reader is already running, call + /// @ref clearOnVideoFrameCallback first, then register again. + /// + /// @param participant_identity Identity of the remote participant. + /// @param track_name Track name to match. + /// @param callback Function invoked for each decoded video frame + /// event, including optional metadata. + /// @param opts Options used when creating the backing + /// @ref VideoStream. + /// @return @c true if the callback was registered; @c false if a reader is + /// already active for the key (call @ref clearOnVideoFrameCallback + /// first) or the room has no dispatcher. + [[nodiscard]] bool trySetOnVideoFrameEventCallback(const std::string& participant_identity, + const std::string& track_name, VideoFrameEventCallback callback, + const VideoStream::Options& opts = {}); + + /// @deprecated Use trySetOnAudioFrameCallback() instead. + /// + /// Forwards to @ref trySetOnAudioFrameCallback and discards the result. + [[deprecated("Room::setOnAudioFrameCallback is deprecated; use trySetOnAudioFrameCallback instead")]] void setOnAudioFrameCallback(const std::string& participant_identity, const std::string& track_name, AudioFrameCallback callback, const AudioStream::Options& opts = {}); - /// @brief Sets the video frame callback via SubscriptionThreadDispatcher. + /// @deprecated Use trySetOnVideoFrameCallback() instead. + /// + /// Forwards to @ref trySetOnVideoFrameCallback and discards the result. + [[deprecated("Room::setOnVideoFrameCallback is deprecated; use trySetOnVideoFrameCallback instead")]] void setOnVideoFrameCallback(const std::string& participant_identity, const std::string& track_name, VideoFrameCallback callback, const VideoStream::Options& opts = {}); - /// @brief Sets the video frame event callback via - /// SubscriptionThreadDispatcher. + /// @deprecated Use trySetOnVideoFrameEventCallback() instead. + /// + /// Forwards to @ref trySetOnVideoFrameEventCallback and discards the result. + [[deprecated("Room::setOnVideoFrameEventCallback is deprecated; use trySetOnVideoFrameEventCallback instead")]] void setOnVideoFrameEventCallback(const std::string& participant_identity, const std::string& track_name, VideoFrameEventCallback callback, const VideoStream::Options& opts = {}); @@ -364,6 +440,12 @@ class LIVEKIT_API Room { // FfiClient listener ID (0 means no listener registered) int listener_id_{0}; + /// Find a currently subscribed remote track matching the given participant + /// identity and track name. Returns nullptr if no such subscribed track + /// exists. Acquires @ref lock_. + std::shared_ptr findSubscribedRemoteTrack(const std::string& participant_identity, + const std::string& track_name) const; + void onEvent(const proto::FfiEvent& event); // Shared shutdown path for explicit disconnect, server disconnect, EOS, and destruction. diff --git a/include/livekit/subscription_thread_dispatcher.h b/include/livekit/subscription_thread_dispatcher.h index 73c86684..a1cf9db6 100644 --- a/include/livekit/subscription_thread_dispatcher.h +++ b/include/livekit/subscription_thread_dispatcher.h @@ -16,6 +16,8 @@ #pragma once +#include +#include #include #include #include @@ -65,13 +67,15 @@ using DataFrameCallbackId = std::uint64_t; /// /// `SubscriptionThreadDispatcher` is the low-level companion to @ref Room's /// remote track subscription flow. `Room` forwards user-facing callback -/// registration requests here, and then calls @ref handleTrackSubscribed and -/// @ref handleTrackUnsubscribed as room events arrive. +/// registration requests here. For remote audio and video subscriptions it +/// calls @ref handleTrackSubscribed and @ref handleTrackUnsubscribed; for +/// data tracks it calls @ref handleDataTrackPublished and +/// @ref handleDataTrackUnpublished. /// -/// For each registered `(participant identity, track name)` pair, this class -/// may create a dedicated @ref AudioStream or @ref VideoStream and a matching -/// reader thread. That thread blocks on stream reads and invokes the -/// registered callback with decoded frames. +/// For each registered audio or video `(participant identity, track name)` +/// pair, this class may create a dedicated @ref AudioStream or @ref +/// VideoStream and a matching reader thread. That thread blocks on stream +/// reads and invokes the registered callback with decoded frames. /// /// This type is intentionally independent from @ref RoomDelegate. High-level /// room events such as `RoomDelegate::onTrackSubscribed()` remain in @ref Room, @@ -89,54 +93,105 @@ class LIVEKIT_API SubscriptionThreadDispatcher { /// Stops all active readers and clears all registered callbacks. ~SubscriptionThreadDispatcher(); - /// Register or replace an audio frame callback for a remote subscription. + /// Register an audio frame callback for a remote subscription. /// /// The callback is keyed by remote participant identity plus @p track_name. /// If the matching remote audio track is already subscribed, @ref Room may /// immediately call @ref handleTrackSubscribed to start a reader. /// + /// Registration only succeeds when no reader is currently active for the + /// key. To replace a callback whose reader is already running, call + /// @ref clearOnAudioFrameCallback first, then register again. + /// /// @param participant_identity Identity of the remote participant. /// @param track_name Track name to match. /// @param callback Function invoked for each decoded audio frame. /// @param opts Options used when creating the backing /// @ref AudioStream. - void setOnAudioFrameCallback(const std::string& participant_identity, const std::string& track_name, - AudioFrameCallback callback, const AudioStream::Options& opts = {}); + /// @return @c true if the callback was registered; @c false if a reader is + /// already active for the key (the registration is left unchanged). + [[nodiscard]] bool trySetOnAudioFrameCallback(const std::string& participant_identity, const std::string& track_name, + AudioFrameCallback callback, const AudioStream::Options& opts = {}); - /// Register or replace a video frame callback for a remote subscription. + /// Register a video frame callback for a remote subscription. /// /// The callback is keyed by remote participant identity plus @p track_name. /// If the matching remote video track is already subscribed, @ref Room may /// immediately call @ref handleTrackSubscribed to start a reader. /// + /// Registration only succeeds when no reader is currently active for the + /// key. To replace a callback whose reader is already running, call + /// @ref clearOnVideoFrameCallback first, then register again. + /// /// @param participant_identity Identity of the remote participant. /// @param track_name Track name to match. /// @param callback Function invoked for each decoded video frame. /// @param opts Options used when creating the backing /// @ref VideoStream. - void setOnVideoFrameCallback(const std::string& participant_identity, const std::string& track_name, - VideoFrameCallback callback, const VideoStream::Options& opts = {}); + /// @return @c true if the callback was registered; @c false if a reader is + /// already active for the key (the registration is left unchanged). + [[nodiscard]] bool trySetOnVideoFrameCallback(const std::string& participant_identity, const std::string& track_name, + VideoFrameCallback callback, const VideoStream::Options& opts = {}); - /// Register or replace a rich video frame event callback for a remote - /// subscription. + /// Register a rich video frame event callback for a remote subscription. /// /// The callback is keyed by remote participant identity plus @p track_name. /// If the matching remote video track is already subscribed, @ref Room may /// immediately call @ref handleTrackSubscribed to start a reader. /// + /// Registration only succeeds when no reader is currently active for the + /// key. To replace a callback whose reader is already running, call + /// @ref clearOnVideoFrameCallback first, then register again. + /// /// @param participant_identity Identity of the remote participant. /// @param track_name Track name to match. /// @param callback Function invoked for each decoded video frame /// event, including optional metadata. /// @param opts Options used when creating the backing /// @ref VideoStream. + /// @return @c true if the callback was registered; @c false if a reader is + /// already active for the key (the registration is left unchanged). + [[nodiscard]] bool trySetOnVideoFrameEventCallback(const std::string& participant_identity, + const std::string& track_name, VideoFrameEventCallback callback, + const VideoStream::Options& opts = {}); + + /// @deprecated Use trySetOnAudioFrameCallback() instead. + /// + /// Forwards to @ref trySetOnAudioFrameCallback and discards the result. + /// Replacing an active callback is not supported through this overload; call + /// @ref clearOnAudioFrameCallback first, then @ref trySetOnAudioFrameCallback. + [[deprecated( + "SubscriptionThreadDispatcher::setOnAudioFrameCallback is deprecated; use trySetOnAudioFrameCallback instead")]] + void setOnAudioFrameCallback(const std::string& participant_identity, const std::string& track_name, + AudioFrameCallback callback, const AudioStream::Options& opts = {}); + + /// @deprecated Use trySetOnVideoFrameCallback() instead. + /// + /// Forwards to @ref trySetOnVideoFrameCallback and discards the result. + /// Replacing an active callback is not supported through this overload; call + /// @ref clearOnVideoFrameCallback first, then @ref trySetOnVideoFrameCallback. + [[deprecated( + "SubscriptionThreadDispatcher::setOnVideoFrameCallback is deprecated; use trySetOnVideoFrameCallback instead")]] + void setOnVideoFrameCallback(const std::string& participant_identity, const std::string& track_name, + VideoFrameCallback callback, const VideoStream::Options& opts = {}); + + /// @deprecated Use trySetOnVideoFrameEventCallback() instead. + /// + /// Forwards to @ref trySetOnVideoFrameEventCallback and discards the result. + /// Replacing an active callback is not supported through this overload; call + /// @ref clearOnVideoFrameCallback first, then + /// @ref trySetOnVideoFrameEventCallback. + [[deprecated( + "SubscriptionThreadDispatcher::setOnVideoFrameEventCallback is deprecated; use " + "trySetOnVideoFrameEventCallback instead")]] void setOnVideoFrameEventCallback(const std::string& participant_identity, const std::string& track_name, VideoFrameEventCallback callback, const VideoStream::Options& opts = {}); /// Remove an audio callback registration and stop any active reader. /// /// If an audio reader thread is active for the given key, its stream is - /// closed and the thread is joined before this call returns. + /// closed and the thread is joined before this call returns. Call this + /// before @ref trySetOnAudioFrameCallback to replace an active callback. /// /// @param participant_identity Identity of the remote participant. /// @param track_name Track name to clear. @@ -145,33 +200,41 @@ class LIVEKIT_API SubscriptionThreadDispatcher { /// Remove a video callback registration and stop any active reader. /// /// If a video reader thread is active for the given key, its stream is - /// closed and the thread is joined before this call returns. + /// closed and the thread is joined before this call returns. Call this + /// before @ref trySetOnVideoFrameCallback (or + /// @ref trySetOnVideoFrameEventCallback) to replace an active callback. /// /// @param participant_identity Identity of the remote participant. /// @param track_name Track name to clear. void clearOnVideoFrameCallback(const std::string& participant_identity, const std::string& track_name); - /// Start or restart reader dispatch for a newly subscribed remote track. + /// Start or restart reader dispatch for a newly subscribed remote audio or + /// video track. /// /// @ref Room calls this after it has processed a track-subscription event and - /// updated its publication state. If a matching callback registration exists, - /// the dispatcher creates the appropriate stream type and launches a reader - /// thread for the `(participant, track_name)` key. + /// updated its publication state. If a matching audio or video callback + /// registration exists, the dispatcher creates the appropriate @ref + /// AudioStream or @ref VideoStream and launches a reader thread for the + /// `(participant, track_name)` key. /// - /// If no matching callback is registered, this is a no-op. + /// Remote data tracks are handled separately via @ref handleDataTrackPublished. + /// If @p track is not audio or video, or no matching callback is registered, + /// this is a no-op. /// /// @param participant_identity Identity of the remote participant. /// @param track_name Track name associated with the subscription. - /// @param track Subscribed remote track to read from. + /// @param track Subscribed remote audio or video track to read from. void handleTrackSubscribed(const std::string& participant_identity, const std::string& track_name, const std::shared_ptr& track); - /// Stop reader dispatch for an unsubscribed remote track. + /// Stop reader dispatch for an unsubscribed remote audio or video track. + /// + /// @ref Room calls this when a remote audio or video track is unsubscribed. + /// Any active reader stream for the given `(participant, track_name)` key is + /// closed and its thread is joined. Callback registration is preserved so + /// future re-subscription can start dispatch again automatically. /// - /// @ref Room calls this when a remote track is unsubscribed. Any active - /// reader stream for the given `(participant, track_name)` key is closed and its - /// thread is joined. Callback registration is preserved so future - /// re-subscription can start dispatch again automatically. + /// Remote data tracks are handled separately via @ref handleDataTrackUnpublished. /// /// @param participant_identity Identity of the remote participant. /// @param source Track source associated with the subscription. @@ -259,6 +322,9 @@ class LIVEKIT_API SubscriptionThreadDispatcher { std::shared_ptr audio_stream; std::shared_ptr video_stream; std::thread thread; + /// SID of the subscribed track backing this reader, used to skip redundant + /// reader restarts when the same publication is re-subscribed. + std::string track_sid; }; /// Compound lookup key for a remote participant identity and data track name. @@ -289,6 +355,12 @@ class LIVEKIT_API SubscriptionThreadDispatcher { /// Active read-side resources for one data track stream subscription. struct ActiveDataReader { std::shared_ptr remote_track; + /// Set true when this reader is being replaced or torn down so the reader + /// thread can abort a subscription that is still in flight. + std::atomic cancelled{false}; + /// Guarded by lock_. Reader threads may mark themselves finished, but only + /// dispatcher lifecycle paths erase the slot and join the thread. + bool finished = false; std::mutex sub_mutex; std::shared_ptr stream; // guarded by sub_mutex std::thread thread; @@ -313,7 +385,11 @@ class LIVEKIT_API SubscriptionThreadDispatcher { /// must be joined after releasing the lock. std::thread extractReaderThreadLocked(const CallbackKey& key); - /// Select the appropriate reader startup path for @p track. + /// Select the appropriate reader startup path for @p media track. + /// + /// This is called by @ref Room when a remote track is subscribed. If a reader for the same track SID is already + /// active, startup is skipped and a default-constructed thread is returned; otherwise any previous reader is + /// extracted and returned to the caller for joining outside the lock. /// /// Must be called with @ref lock_ held. std::thread startReaderLocked(const CallbackKey& key, const std::shared_ptr& track); @@ -333,18 +409,21 @@ class LIVEKIT_API SubscriptionThreadDispatcher { const RegisteredVideoCallback& callback); /// Extract and close the data reader for a given callback ID, returning its - /// thread. Must be called with @ref lock_ held. + /// thread. Marks the reader cancelled so a subscription still in flight is + /// aborted. Must be called with @ref lock_ held. std::thread extractDataReaderThreadLocked(DataFrameCallbackId id); - /// Extract and close the data reader for a given (participant, track_name) - /// key, returning its thread. Must be called with @ref lock_ held. - std::thread extractDataReaderThreadLocked(const DataCallbackKey& key); - /// Start a data reader thread for the given callback ID, key, and track. /// Must be called with @ref lock_ held. std::thread startDataReaderLocked(DataFrameCallbackId id, const DataCallbackKey& key, const std::shared_ptr& track, const DataFrameCallback& cb); + /// Mark @p reader finished if the slot for @p id still refers to it. + /// Called by the reader thread itself when it exits after a failed, + /// cancelled, or terminal subscription. Acquires @ref lock_. Reader threads + /// must not erase, detach, or join their own @ref std::thread. + void markDataReaderFinishedIfCurrent(DataFrameCallbackId id, const std::shared_ptr& reader); + /// Protects callback registration maps and active reader state. mutable std::mutex lock_; diff --git a/src/room.cpp b/src/room.cpp index 8277cf36..48e0fdde 100644 --- a/src/room.cpp +++ b/src/room.cpp @@ -400,27 +400,130 @@ void Room::unregisterByteStreamHandler(const std::string& topic) { // Frame callback registration // ------------------------------------------------------------------- +std::shared_ptr Room::findSubscribedRemoteTrack(const std::string& participant_identity, + const std::string& track_name) const { + const std::scoped_lock guard(lock_); + auto pit = remote_participants_.find(participant_identity); + if (pit == remote_participants_.end() || !pit->second) { + return nullptr; + } + for (const auto& [sid, publication] : pit->second->trackPublications()) { + (void)sid; + if (publication && publication->subscribed() && publication->name() == track_name) { + return publication->track(); + } + } + return nullptr; +} + +bool Room::trySetOnAudioFrameCallback(const std::string& participant_identity, const std::string& track_name, + AudioFrameCallback callback, const AudioStream::Options& opts) { + if (!subscription_thread_dispatcher_) { + LK_LOG_ERROR("Room::trySetOnAudioFrameCallback: subscription_thread_dispatcher_ is nullptr"); + return false; + } + if (!subscription_thread_dispatcher_->trySetOnAudioFrameCallback(participant_identity, track_name, + std::move(callback), opts)) { + return false; + } + + // If we've already subscribed to the track, handle it immediately + auto track = findSubscribedRemoteTrack(participant_identity, track_name); + if (track) { + subscription_thread_dispatcher_->handleTrackSubscribed(participant_identity, track_name, track); + } else { + // The track is not subscribed yet. The callback is registered; the reader + // starts when the track is subscribed (see kTrackSubscribed in onEvent). + LK_LOG_DEBUG( + "Room::trySetOnAudioFrameCallback: track not yet subscribed for participant={} track_name={}; " + "callback registered for deferred start", + participant_identity, track_name); + } + return true; +} + +bool Room::trySetOnVideoFrameCallback(const std::string& participant_identity, const std::string& track_name, + VideoFrameCallback callback, const VideoStream::Options& opts) { + if (!subscription_thread_dispatcher_) { + LK_LOG_ERROR("Room::trySetOnVideoFrameCallback: subscription_thread_dispatcher_ is nullptr"); + return false; + } + if (!subscription_thread_dispatcher_->trySetOnVideoFrameCallback(participant_identity, track_name, + std::move(callback), opts)) { + return false; + } + + // If we've already subscribed to the track, handle it immediately + auto track = findSubscribedRemoteTrack(participant_identity, track_name); + if (track) { + subscription_thread_dispatcher_->handleTrackSubscribed(participant_identity, track_name, track); + } else { + // The track is not subscribed yet. The callback is registered; the reader + // starts when the track is subscribed (see kTrackSubscribed in onEvent). + LK_LOG_DEBUG( + "Room::trySetOnVideoFrameCallback: track not yet subscribed for participant={} track_name={}; " + "callback registered for deferred start", + participant_identity, track_name); + } + return true; +} + +bool Room::trySetOnVideoFrameEventCallback(const std::string& participant_identity, const std::string& track_name, + VideoFrameEventCallback callback, const VideoStream::Options& opts) { + if (!subscription_thread_dispatcher_) { + LK_LOG_ERROR("Room::trySetOnVideoFrameEventCallback: subscription_thread_dispatcher_ is nullptr"); + return false; + } + if (!subscription_thread_dispatcher_->trySetOnVideoFrameEventCallback(participant_identity, track_name, + std::move(callback), opts)) { + return false; + } + + // If we've already subscribed to the track, handle it immediately + auto track = findSubscribedRemoteTrack(participant_identity, track_name); + if (track) { + subscription_thread_dispatcher_->handleTrackSubscribed(participant_identity, track_name, track); + } else { + // The track is not subscribed yet. The callback is registered; the reader + // starts when the track is subscribed (see kTrackSubscribed in onEvent). + LK_LOG_DEBUG( + "Room::trySetOnVideoFrameEventCallback: track not yet subscribed for participant={} track_name={}; " + "callback registered for deferred start", + participant_identity, track_name); + } + return true; +} + void Room::setOnAudioFrameCallback(const std::string& participant_identity, const std::string& track_name, AudioFrameCallback callback, const AudioStream::Options& opts) { - if (subscription_thread_dispatcher_) { - subscription_thread_dispatcher_->setOnAudioFrameCallback(participant_identity, track_name, std::move(callback), - opts); + bool const result = trySetOnAudioFrameCallback(participant_identity, track_name, std::move(callback), opts); + if (!result) { + LK_LOG_ERROR( + "Room::setOnAudioFrameCallback: failed to set callback for participant={} track_name={}. This function is " + "deprecated, instead use trySetOnAudioFrameCallback", + participant_identity, track_name); } } void Room::setOnVideoFrameCallback(const std::string& participant_identity, const std::string& track_name, VideoFrameCallback callback, const VideoStream::Options& opts) { - if (subscription_thread_dispatcher_) { - subscription_thread_dispatcher_->setOnVideoFrameCallback(participant_identity, track_name, std::move(callback), - opts); + bool const result = trySetOnVideoFrameCallback(participant_identity, track_name, std::move(callback), opts); + if (!result) { + LK_LOG_ERROR( + "Room::setOnVideoFrameCallback: failed to set callback for participant={} track_name={}. This function is " + "deprecated, instead use trySetOnVideoFrameCallback", + participant_identity, track_name); } } void Room::setOnVideoFrameEventCallback(const std::string& participant_identity, const std::string& track_name, VideoFrameEventCallback callback, const VideoStream::Options& opts) { - if (subscription_thread_dispatcher_) { - subscription_thread_dispatcher_->setOnVideoFrameEventCallback(participant_identity, track_name, std::move(callback), - opts); + bool const result = trySetOnVideoFrameEventCallback(participant_identity, track_name, std::move(callback), opts); + if (!result) { + LK_LOG_ERROR( + "Room::setOnVideoFrameEventCallback: failed to set callback for participant={} track_name={}. This function is " + "deprecated, instead use trySetOnVideoFrameEventCallback", + participant_identity, track_name); } } diff --git a/src/subscription_thread_dispatcher.cpp b/src/subscription_thread_dispatcher.cpp index ed77d0be..4bf412a9 100644 --- a/src/subscription_thread_dispatcher.cpp +++ b/src/subscription_thread_dispatcher.cpp @@ -57,25 +57,41 @@ SubscriptionThreadDispatcher::~SubscriptionThreadDispatcher() { } // NOLINTEND(bugprone-exception-escape) -void SubscriptionThreadDispatcher::setOnAudioFrameCallback(const std::string& participant_identity, - const std::string& track_name, AudioFrameCallback callback, - const AudioStream::Options& opts) { +bool SubscriptionThreadDispatcher::trySetOnAudioFrameCallback(const std::string& participant_identity, + const std::string& track_name, + AudioFrameCallback callback, + const AudioStream::Options& opts) { const CallbackKey key{participant_identity, track_name}; const std::scoped_lock lock(lock_); + if (active_readers_.find(key) != active_readers_.end()) { + LK_LOG_WARN( + "Cannot register audio frame callback for participant={} track_name={} " + "because a reader is already active; call clearOnAudioFrameCallback() first", + participant_identity, track_name); + return false; + } const bool replacing = audio_callbacks_.find(key) != audio_callbacks_.end(); audio_callbacks_[key] = RegisteredAudioCallback{std::move(callback), opts}; LK_LOG_DEBUG( "Registered audio frame callback for participant={} track_name={} " "replacing_existing={} total_audio_callbacks={}", participant_identity, track_name, replacing, audio_callbacks_.size()); + return true; } -void SubscriptionThreadDispatcher::setOnVideoFrameEventCallback(const std::string& participant_identity, - const std::string& track_name, - VideoFrameEventCallback callback, - const VideoStream::Options& opts) { +bool SubscriptionThreadDispatcher::trySetOnVideoFrameEventCallback(const std::string& participant_identity, + const std::string& track_name, + VideoFrameEventCallback callback, + const VideoStream::Options& opts) { const CallbackKey key{participant_identity, track_name}; const std::scoped_lock lock(lock_); + if (active_readers_.find(key) != active_readers_.end()) { + LK_LOG_WARN( + "Cannot register video frame event callback for participant={} track_name={} " + "because a reader is already active; call clearOnVideoFrameCallback() first", + participant_identity, track_name); + return false; + } const bool replacing = video_callbacks_.find(key) != video_callbacks_.end(); video_callbacks_[key] = RegisteredVideoCallback{ VideoFrameCallback{}, @@ -86,13 +102,22 @@ void SubscriptionThreadDispatcher::setOnVideoFrameEventCallback(const std::strin "Registered video frame event callback for participant={} track_name={} " "replacing_existing={} total_video_callbacks={}", participant_identity, track_name, replacing, video_callbacks_.size()); + return true; } -void SubscriptionThreadDispatcher::setOnVideoFrameCallback(const std::string& participant_identity, - const std::string& track_name, VideoFrameCallback callback, - const VideoStream::Options& opts) { +bool SubscriptionThreadDispatcher::trySetOnVideoFrameCallback(const std::string& participant_identity, + const std::string& track_name, + VideoFrameCallback callback, + const VideoStream::Options& opts) { const CallbackKey key{participant_identity, track_name}; const std::scoped_lock lock(lock_); + if (active_readers_.find(key) != active_readers_.end()) { + LK_LOG_WARN( + "Cannot register video frame callback for participant={} track_name={} " + "because a reader is already active; call clearOnVideoFrameCallback() first", + participant_identity, track_name); + return false; + } const bool replacing = video_callbacks_.find(key) != video_callbacks_.end(); video_callbacks_[key] = RegisteredVideoCallback{ std::move(callback), @@ -103,6 +128,26 @@ void SubscriptionThreadDispatcher::setOnVideoFrameCallback(const std::string& pa "Registered video frame callback for participant={} track_name={} " "replacing_existing={} total_video_callbacks={}", participant_identity, track_name, replacing, video_callbacks_.size()); + return true; +} + +void SubscriptionThreadDispatcher::setOnAudioFrameCallback(const std::string& participant_identity, + const std::string& track_name, AudioFrameCallback callback, + const AudioStream::Options& opts) { + (void)trySetOnAudioFrameCallback(participant_identity, track_name, std::move(callback), opts); +} + +void SubscriptionThreadDispatcher::setOnVideoFrameEventCallback(const std::string& participant_identity, + const std::string& track_name, + VideoFrameEventCallback callback, + const VideoStream::Options& opts) { + (void)trySetOnVideoFrameEventCallback(participant_identity, track_name, std::move(callback), opts); +} + +void SubscriptionThreadDispatcher::setOnVideoFrameCallback(const std::string& participant_identity, + const std::string& track_name, VideoFrameCallback callback, + const VideoStream::Options& opts) { + (void)trySetOnVideoFrameCallback(participant_identity, track_name, std::move(callback), opts); } void SubscriptionThreadDispatcher::clearOnAudioFrameCallback(const std::string& participant_identity, @@ -259,6 +304,8 @@ void SubscriptionThreadDispatcher::handleDataTrackUnpublished(const std::string& for (auto it = active_data_readers_.begin(); it != active_data_readers_.end();) { auto& reader = it->second; if (reader->remote_track && reader->remote_track->info().sid == sid) { + // Mark cancelled before closing to guard in flight subscriptions + reader->cancelled = true; { const std::scoped_lock sub_guard(reader->sub_mutex); if (reader->stream) { @@ -312,6 +359,8 @@ void SubscriptionThreadDispatcher::stopAll() { video_callbacks_.clear(); for (auto& [id, reader] : active_data_readers_) { + // Mark cancelled before closing to guard in flight subscriptions + reader->cancelled = true; { const std::scoped_lock sub_guard(reader->sub_mutex); if (reader->stream) { @@ -397,6 +446,16 @@ std::thread SubscriptionThreadDispatcher::startAudioReaderLocked(const CallbackK const AudioFrameCallback& cb, const AudioStream::Options& opts) { LK_LOG_DEBUG("Starting audio reader for participant={} track_name={}", key.participant_identity, key.track_name); + + auto existing = active_readers_.find(key); + if (existing != active_readers_.end() && existing->second.track_sid == track->sid()) { + LK_LOG_DEBUG( + "Skipping audio reader start for participant={} track_name={} because a " + "reader for sid={} is already active", + key.participant_identity, key.track_name, track->sid()); + return {}; + } + auto old_thread = extractReaderThreadLocked(key); if (static_cast(active_readers_.size()) >= kMaxActiveReaders) { @@ -415,6 +474,7 @@ std::thread SubscriptionThreadDispatcher::startAudioReaderLocked(const CallbackK ActiveReader reader; reader.audio_stream = stream; + reader.track_sid = track->sid(); const std::string participant_identity = key.participant_identity; const std::string track_name = key.track_name; // NOLINTBEGIN(bugprone-lambda-function-name,bugprone-exception-escape) @@ -454,6 +514,16 @@ std::thread SubscriptionThreadDispatcher::startVideoReaderLocked(const CallbackK const std::shared_ptr& track, const RegisteredVideoCallback& callback) { LK_LOG_DEBUG("Starting video reader for participant={} track_name={}", key.participant_identity, key.track_name); + + auto existing = active_readers_.find(key); + if (existing != active_readers_.end() && existing->second.track_sid == track->sid()) { + LK_LOG_DEBUG( + "Skipping video reader start for participant={} track_name={} because a " + "reader for sid={} is already active", + key.participant_identity, key.track_name, track->sid()); + return {}; + } + auto old_thread = extractReaderThreadLocked(key); if (static_cast(active_readers_.size()) >= kMaxActiveReaders) { @@ -472,6 +542,7 @@ std::thread SubscriptionThreadDispatcher::startVideoReaderLocked(const CallbackK ActiveReader reader; reader.video_stream = stream; + reader.track_sid = track->sid(); auto legacy_cb = callback.legacy_callback; auto event_cb = callback.event_callback; const std::string participant_identity = key.participant_identity; @@ -522,6 +593,8 @@ std::thread SubscriptionThreadDispatcher::extractDataReaderThreadLocked(DataFram } auto reader = std::move(it->second); active_data_readers_.erase(it); + // Mark cancelled before closing to guard in flight subscriptions + reader->cancelled = true; { const std::scoped_lock guard(reader->sub_mutex); if (reader->stream) { @@ -531,28 +604,34 @@ std::thread SubscriptionThreadDispatcher::extractDataReaderThreadLocked(DataFram return std::move(reader->thread); } -std::thread SubscriptionThreadDispatcher::extractDataReaderThreadLocked(const DataCallbackKey& key) { - for (auto it = active_data_readers_.begin(); it != active_data_readers_.end(); ++it) { - if (it->second && it->second->remote_track && - it->second->remote_track->publisherIdentity() == key.participant_identity && - it->second->remote_track->info().name == key.track_name) { - auto reader = std::move(it->second); - active_data_readers_.erase(it); - { - const std::scoped_lock guard(reader->sub_mutex); - if (reader->stream) { - reader->stream->close(); - } - } - return std::move(reader->thread); - } +void SubscriptionThreadDispatcher::markDataReaderFinishedIfCurrent(DataFrameCallbackId id, + const std::shared_ptr& reader) { + const std::scoped_lock lock(lock_); + auto it = active_data_readers_.find(id); + if (it == active_data_readers_.end() || it->second != reader) { + // The slot was already extracted or replaced; the owner joins that thread. + return; + } + reader->finished = true; + { + const std::scoped_lock guard(reader->sub_mutex); + reader->stream.reset(); } - return {}; } std::thread SubscriptionThreadDispatcher::startDataReaderLocked(DataFrameCallbackId id, const DataCallbackKey& key, const std::shared_ptr& track, const DataFrameCallback& cb) { + auto existing = active_data_readers_.find(id); + if (existing != active_data_readers_.end() && !existing->second->finished && existing->second->remote_track && + existing->second->remote_track->info().sid == track->info().sid) { + LK_LOG_DEBUG( + "Skipping data reader start for \"{}\" track=\"{}\" because a reader for " + "sid={} is already active", + key.participant_identity, key.track_name, track->info().sid); + return {}; + } + auto old_thread = extractDataReaderThreadLocked(id); const int total_active = static_cast(active_readers_.size()) + static_cast(active_data_readers_.size()); @@ -571,7 +650,7 @@ std::thread SubscriptionThreadDispatcher::startDataReaderLocked(DataFrameCallbac auto identity = key.participant_identity; auto track_name = key.track_name; // NOLINTBEGIN(bugprone-lambda-function-name) - reader->thread = std::thread([reader, track, cb, identity, track_name]() { + reader->thread = std::thread([this, id, reader, track, cb, identity, track_name]() { LK_LOG_INFO("Data reader thread: subscribing to \"{}\" track=\"{}\"", identity, track_name); std::shared_ptr stream; auto subscribe_result = track->subscribe(); @@ -581,14 +660,31 @@ std::thread SubscriptionThreadDispatcher::startDataReaderLocked(DataFrameCallbac "Failed to subscribe to data track \"{}\" from \"{}\": code={} " "message={}", track_name, identity, static_cast(error.code), error.message); + markDataReaderFinishedIfCurrent(id, reader); return; } stream = subscribe_result.value(); LK_LOG_INFO("Data reader thread: subscribed to \"{}\" track=\"{}\"", identity, track_name); + bool cancelled = false; { const std::scoped_lock guard(reader->sub_mutex); - reader->stream = stream; + // A replacement or teardown may have cancelled this reader while the + // subscribe was in flight. Close the fresh stream so we do not leave a + // second live subscription behind. + if (reader->cancelled.load()) { + cancelled = true; + stream->close(); + } else { + reader->stream = stream; + } + } + if (cancelled) { + // Mirror the normal-exit cleanup below. Done outside sub_mutex to keep the + // lock_ -> sub_mutex order and avoid inversion; a no-op unless this reader + // still owns its slot. + markDataReaderFinishedIfCurrent(id, reader); + return; } DataTrackFrame frame; @@ -606,6 +702,9 @@ std::thread SubscriptionThreadDispatcher::startDataReaderLocked(DataFrameCallbac "\"{}\": code={} message={}", track_name, identity, static_cast(error->code), error->message); } + // Mark our own slot finished if the stream ended on its own (server EOS) + // and no extract/teardown already claimed it. A no-op when we were extracted. + markDataReaderFinishedIfCurrent(id, reader); LK_LOG_INFO("Data reader thread exiting for \"{}\" track=\"{}\"", identity, track_name); }); // NOLINTEND(bugprone-lambda-function-name) diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index 9583af73..29a3ece6 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -108,7 +108,6 @@ if(UNIT_TEST_SOURCES) target_compile_definitions(livekit_unit_tests PRIVATE - LIVEKIT_TEST_ACCESS LIVEKIT_ROOT_DIR="${LIVEKIT_ROOT_DIR}" SPDLOG_ACTIVE_LEVEL=${_SPDLOG_ACTIVE_LEVEL} $<$:_USE_MATH_DEFINES> @@ -200,7 +199,6 @@ if(INTEGRATION_TEST_SOURCES) target_compile_definitions(livekit_integration_tests PRIVATE - LIVEKIT_TEST_ACCESS LIVEKIT_ROOT_DIR="${LIVEKIT_ROOT_DIR}" SPDLOG_ACTIVE_LEVEL=${_SPDLOG_ACTIVE_LEVEL} $<$:_USE_MATH_DEFINES> diff --git a/src/tests/common/remote_data_track_test_access.h b/src/tests/common/remote_data_track_test_access.h new file mode 100644 index 00000000..619d1d62 --- /dev/null +++ b/src/tests/common/remote_data_track_test_access.h @@ -0,0 +1,46 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include +#include +#include +#include + +#include "data_track.pb.h" + +namespace livekit { + +struct RemoteDataTrackTestAccess { + static uintptr_t ffiHandleId(const RemoteDataTrack& track) noexcept { return track.ffiHandleId(); } + + static std::shared_ptr create(DataTrackInfo info, std::string publisher_identity) { + proto::OwnedRemoteDataTrack owned; + owned.mutable_handle()->set_id(0); + auto* proto_info = owned.mutable_info(); + proto_info->set_name(std::move(info.name)); + proto_info->set_sid(std::move(info.sid)); + proto_info->set_uses_e2ee(info.uses_e2ee); + owned.set_publisher_identity(std::move(publisher_identity)); + return std::shared_ptr(new RemoteDataTrack(owned)); + } +}; + +} // namespace livekit diff --git a/src/tests/integration/test_data_track.cpp b/src/tests/integration/test_data_track.cpp index d584c551..912b882c 100644 --- a/src/tests/integration/test_data_track.cpp +++ b/src/tests/integration/test_data_track.cpp @@ -25,6 +25,7 @@ #include #include +#include "../common/remote_data_track_test_access.h" #include "../common/test_common.h" #include "ffi_client.h" @@ -465,6 +466,104 @@ TEST_F(DataTrackE2ETest, UnpublishUpdatesPublishedStateEndToEnd) { << "Remote track did not report unpublished state"; } +// Verifies that an auto-wired data callback (Room::addOnDataFrameCallback) +// follows a republished track: after unpublish + republish under the same +// (participant, track name) but a new SID, the previous reader is torn down and +// a fresh reader delivers frames from the new publication. +TEST_F(DataTrackE2ETest, RepublishRewiresDataCallbackToNewPublication) { + const auto track_name = makeTrackName("republish"); + + std::vector room_configs(2); + room_configs[0].room_options.single_peer_connection = false; + room_configs[1].room_options.single_peer_connection = false; + + DataTrackPublishedDelegate subscriber_delegate; + room_configs[1].delegate = &subscriber_delegate; + + auto rooms = testRooms(room_configs); + auto& publisher_room = rooms[0]; + auto& subscriber_room = rooms[1]; + const auto publisher_identity = lockLocalParticipant(*publisher_room)->identity(); + + std::atomic frames_received{0}; + std::mutex payload_mutex; + std::vector last_payload; + subscriber_room->addOnDataFrameCallback(publisher_identity, track_name, + [&](const std::vector& payload, std::optional) { + { + const std::scoped_lock lock(payload_mutex); + last_payload = payload; + } + frames_received.fetch_add(1); + }); + + auto publish_with_retry = [&](const std::string& name) -> std::shared_ptr { + std::shared_ptr track; + waitForCondition( + [&]() { + auto result = lockLocalParticipant(*publisher_room)->publishDataTrack(name); + if (result) { + track = result.value(); + return true; + } + return false; + }, + kTrackWaitTimeout); + return track; + }; + + // First publication. + auto first_track = publish_with_retry(track_name); + ASSERT_NE(first_track, nullptr) << "Failed to publish first data track"; + auto first_remote = subscriber_delegate.waitForTrack(kTrackWaitTimeout); + ASSERT_NE(first_remote, nullptr) << "Timed out waiting for first remote data track"; + const std::string first_sid = first_remote->info().sid; + + DataTrackFrame first_frame; + first_frame.payload.assign(64, 0xA1); + ASSERT_TRUE(waitForCondition( + [&]() { + requirePushSuccess(first_track->tryPush(first_frame), "Failed to push first-publication frame"); + return frames_received.load() > 0; + }, + kTransportFrameTimeout)) + << "Auto-wired callback never received a frame from the first publication"; + + // Unpublish: the reader for the first publication must be torn down. + first_track->unpublishDataTrack(); + ASSERT_TRUE(waitForCondition([&]() { return !first_remote->isPublished(); }, kTrackWaitTimeout)) + << "First remote track did not report unpublished state"; + const int frames_before_republish = frames_received.load(); + + // Republish under the same name; the server assigns a new SID. + auto second_track = publish_with_retry(track_name); + ASSERT_NE(second_track, nullptr) << "Failed to republish data track"; + auto remotes = subscriber_delegate.waitForTracks(2, kTrackWaitTimeout); + ASSERT_EQ(remotes.size(), 2u) << "Timed out waiting for republished remote data track"; + auto second_remote = remotes.back(); + const std::string second_sid = second_remote->info().sid; + EXPECT_NE(first_sid, second_sid) << "Republish should produce a new SID"; + + DataTrackFrame second_frame; + second_frame.payload.assign(64, 0xB2); + ASSERT_TRUE(waitForCondition( + [&]() { + requirePushSuccess(second_track->tryPush(second_frame), "Failed to push republished frame"); + return frames_received.load() > frames_before_republish; + }, + kTransportFrameTimeout)) + << "Auto-wired callback did not re-wire to the republished track"; + + { + const std::scoped_lock lock(payload_mutex); + EXPECT_EQ(last_payload, second_frame.payload) << "Callback delivered stale payload after republish"; + } + + second_track->unpublishDataTrack(); + ASSERT_TRUE(waitForCondition([&]() { return !second_remote->isPublished(); }, kTrackWaitTimeout)) + << "Second remote track did not report unpublished state"; +} + TEST_F(DataTrackE2ETest, SubscribeAfterUnpublishReportsTerminalError) { const auto track_name = makeTrackName("subscribe_after_unpublish"); @@ -854,8 +953,8 @@ TEST_F(DataTrackE2ETest, FfiClientSubscribeDataTrackReturnsSyncResult) { EXPECT_EQ(remote_track->info().name, expected_name); const auto subscribe_start = std::chrono::steady_clock::now(); - auto subscribe_result = - FfiClient::instance().subscribeDataTrack(static_cast(remote_track->testFfiHandleId())); + auto subscribe_result = FfiClient::instance().subscribeDataTrack( + static_cast(RemoteDataTrackTestAccess::ffiHandleId(*remote_track))); const auto subscribe_elapsed = std::chrono::steady_clock::now() - subscribe_start; const auto subscribe_elapsed_ns = std::chrono::duration_cast(subscribe_elapsed).count(); diff --git a/src/tests/integration/test_platform_audio.cpp b/src/tests/integration/test_platform_audio.cpp index c6e86596..647956c3 100644 --- a/src/tests/integration/test_platform_audio.cpp +++ b/src/tests/integration/test_platform_audio.cpp @@ -287,16 +287,18 @@ TEST_F(PlatformAudioIntegrationTest, PlatformAudioFramesReachRemote) { // The reader thread is only started when the subscription event fires and a // matching callback is already registered, so register before publishing. - receiver_room->setOnAudioFrameCallback(sender_identity, track_name, [&](const AudioFrame& frame) { - if (frame.totalSamples() == 0) { - return; - } - { - std::lock_guard lock(frame_mutex); - ++received_frames; - } - frame_cv.notify_all(); - }); + const bool audio_callback_registered = + receiver_room->trySetOnAudioFrameCallback(sender_identity, track_name, [&](const AudioFrame& frame) { + if (frame.totalSamples() == 0) { + return; + } + { + std::lock_guard lock(frame_mutex); + ++received_frames; + } + frame_cv.notify_all(); + }); + ASSERT_TRUE(audio_callback_registered); TrackPublishOptions publish_options; publish_options.source = TrackSource::SOURCE_MICROPHONE; diff --git a/src/tests/integration/test_room_event_deduplication.cpp b/src/tests/integration/test_room_event_deduplication.cpp new file mode 100644 index 00000000..a355afa7 --- /dev/null +++ b/src/tests/integration/test_room_event_deduplication.cpp @@ -0,0 +1,408 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../common/audio_utils.h" +#include "../common/test_common.h" +#include "../common/video_utils.h" + +namespace livekit::test { + +using namespace std::chrono_literals; + +namespace { + +constexpr auto kEventWaitTimeout = 20s; +constexpr auto kDuplicateGracePeriod = 500ms; + +struct RoomEventCounts { + std::mutex mutex; + std::condition_variable cv; + std::map participant_connected; + std::map participant_disconnected; + std::map track_published; + std::map track_subscribed; + std::map track_unsubscribed; + std::map track_unpublished; + int disconnected = 0; +}; + +struct RoomEventCountsSnapshot { + std::map participant_connected; + std::map participant_disconnected; + std::map track_published; + std::map track_subscribed; + std::map track_unsubscribed; + std::map track_unpublished; + int disconnected = 0; +}; + +RoomEventCountsSnapshot snapshotCounts(RoomEventCounts& counts) { + const std::scoped_lock lock(counts.mutex); + RoomEventCountsSnapshot snapshot; + snapshot.participant_connected = counts.participant_connected; + snapshot.participant_disconnected = counts.participant_disconnected; + snapshot.track_published = counts.track_published; + snapshot.track_subscribed = counts.track_subscribed; + snapshot.track_unsubscribed = counts.track_unsubscribed; + snapshot.track_unpublished = counts.track_unpublished; + snapshot.disconnected = counts.disconnected; + return snapshot; +} + +void incrementMap(std::map& counts, const std::string& key) { ++counts[key]; } + +class RoomEventCounterDelegate : public RoomDelegate { +public: + explicit RoomEventCounterDelegate(RoomEventCounts& counts) : counts_(counts) {} + + void onParticipantConnected(Room&, const ParticipantConnectedEvent& event) override { + if (event.participant == nullptr) { + return; + } + notify([&]() { incrementMap(counts_.participant_connected, event.participant->identity()); }); + } + + void onParticipantDisconnected(Room&, const ParticipantDisconnectedEvent& event) override { + if (event.participant == nullptr) { + return; + } + notify([&]() { incrementMap(counts_.participant_disconnected, event.participant->identity()); }); + } + + void onTrackPublished(Room&, const TrackPublishedEvent& event) override { + if (event.publication == nullptr) { + return; + } + notify([&]() { incrementMap(counts_.track_published, event.publication->name()); }); + } + + void onTrackSubscribed(Room&, const TrackSubscribedEvent& event) override { + if (event.publication == nullptr) { + return; + } + notify([&]() { incrementMap(counts_.track_subscribed, event.publication->name()); }); + } + + void onTrackUnsubscribed(Room&, const TrackUnsubscribedEvent& event) override { + if (event.publication == nullptr) { + return; + } + notify([&]() { incrementMap(counts_.track_unsubscribed, event.publication->name()); }); + } + + void onTrackUnpublished(Room&, const TrackUnpublishedEvent& event) override { + if (event.publication == nullptr) { + return; + } + notify([&]() { incrementMap(counts_.track_unpublished, event.publication->name()); }); + } + + void onDisconnected(Room&, const DisconnectedEvent&) override { + notify([&]() { ++counts_.disconnected; }); + } + +private: + template + void notify(Fn&& update) { + { + const std::scoped_lock lock(counts_.mutex); + update(); + } + counts_.cv.notify_all(); + } + + RoomEventCounts& counts_; +}; + +bool waitForMapCountAtLeast(RoomEventCounts& counts, const std::map& keys, int minimum_count, + std::chrono::milliseconds timeout) { + std::unique_lock lock(counts.mutex); + return counts.cv.wait_for(lock, timeout, [&]() { + for (const auto& [key, _] : keys) { + (void)_; + const auto it = counts.participant_connected.find(key); + if (it == counts.participant_connected.end() || it->second < minimum_count) { + return false; + } + } + return true; + }); +} + +bool waitForMapCountAtLeastTrack(RoomEventCounts& counts, const std::map& expected, + std::map RoomEventCounts::* member, + std::chrono::milliseconds timeout) { + std::unique_lock lock(counts.mutex); + return counts.cv.wait_for(lock, timeout, [&]() { + const auto& actual = counts.*member; + for (const auto& [key, minimum_count] : expected) { + const auto it = actual.find(key); + if (it == actual.end() || it->second < minimum_count) { + return false; + } + } + return true; + }); +} + +void expectMapCountsExact(const std::map& actual, const std::map& expected, + const char* label) { + for (const auto& [key, expected_count] : expected) { + const auto it = actual.find(key); + const int actual_count = it == actual.end() ? 0 : it->second; + EXPECT_EQ(actual_count, expected_count) << label << " count mismatch for key: " << key; + } +} + +void expectCountsUnchangedAfterGrace(RoomEventCounts& counts, const RoomEventCountsSnapshot& before, + const char* phase) { + std::this_thread::sleep_for(kDuplicateGracePeriod); + const RoomEventCountsSnapshot after = snapshotCounts(counts); + + expectMapCountsExact(after.participant_connected, before.participant_connected, + (std::string(phase) + " participant_connected duplicate").c_str()); + expectMapCountsExact(after.participant_disconnected, before.participant_disconnected, + (std::string(phase) + " participant_disconnected duplicate").c_str()); + expectMapCountsExact(after.track_published, before.track_published, + (std::string(phase) + " track_published duplicate").c_str()); + expectMapCountsExact(after.track_subscribed, before.track_subscribed, + (std::string(phase) + " track_subscribed duplicate").c_str()); + expectMapCountsExact(after.track_unsubscribed, before.track_unsubscribed, + (std::string(phase) + " track_unsubscribed duplicate").c_str()); + expectMapCountsExact(after.track_unpublished, before.track_unpublished, + (std::string(phase) + " track_unpublished duplicate").c_str()); + EXPECT_EQ(after.disconnected, before.disconnected) << phase << " onDisconnected duplicate"; +} + +std::string makeUniqueTrackName(const std::string& prefix) { return prefix + "-" + std::to_string(getTimestampUs()); } + +std::string describeCounts(const std::map& counts) { + std::ostringstream out; + bool first = true; + out << "{"; + for (const auto& [key, count] : counts) { + if (!first) { + out << ", "; + } + first = false; + out << key << ": " << count; + } + out << "}"; + return out.str(); +} + +class MediaLoopGuard { +public: + MediaLoopGuard() = default; + MediaLoopGuard(const MediaLoopGuard&) = delete; + MediaLoopGuard& operator=(const MediaLoopGuard&) = delete; + + ~MediaLoopGuard() { stop(); } + + void addAudioSource(const std::shared_ptr& source) { + threads_.emplace_back([this, source]() { + runToneLoop(source, running_, 440.0, false, kDefaultAudioSampleRate, kDefaultAudioChannels); + }); + } + + void addVideoSource(const std::shared_ptr& source) { + threads_.emplace_back([this, source]() { runVideoLoop(source, running_, fillWebcamWrapper); }); + } + + void stop() { + running_.store(false, std::memory_order_relaxed); + for (auto& thread : threads_) { + if (thread.joinable()) { + thread.join(); + } + } + } + +private: + std::atomic running_{true}; + std::vector threads_; +}; + +class PublishedTrackGuard { +public: + explicit PublishedTrackGuard(LocalParticipant* participant) : participant_(participant) {} + PublishedTrackGuard(const PublishedTrackGuard&) = delete; + PublishedTrackGuard& operator=(const PublishedTrackGuard&) = delete; + + ~PublishedTrackGuard() noexcept { + try { + unpublishAll(); + } catch (...) { + } + } + + void addTrackSid(const std::string& sid) { + if (!sid.empty()) { + track_sids_.push_back(sid); + } + } + + void unpublishAll() { + if (participant_ != nullptr) { + for (const auto& sid : track_sids_) { + if (!sid.empty()) { + participant_->unpublishTrack(sid); + } + } + } + track_sids_.clear(); + } + +private: + LocalParticipant* participant_ = nullptr; + std::vector track_sids_; +}; + +} // namespace + +class RoomEventDeduplicationIntegrationTest : public LiveKitTestBase, public ::testing::WithParamInterface { +protected: + void SetUp() override { + LiveKitTestBase::SetUp(); + if (!config_.available) { + GTEST_SKIP() << "LIVEKIT_URL, LIVEKIT_TOKEN_A, and LIVEKIT_TOKEN_B not set"; + } + } +}; + +TEST_P(RoomEventDeduplicationIntegrationTest, RoomLifecycleDelegateCallbacksFireExactlyOnce) { + const bool single_peer_connection = GetParam(); + + RoomOptions options; + options.auto_subscribe = true; + options.single_peer_connection = single_peer_connection; + + RoomEventCounts observer_counts; + RoomEventCounterDelegate observer_delegate(observer_counts); + + Room observer_room; + observer_room.setDelegate(&observer_delegate); + ASSERT_TRUE(observer_room.connect(config_.url, config_.token_b, options)) << "Observer failed to connect"; + ASSERT_FALSE(observer_room.localParticipant().expired()); + + Room peer_room; + ASSERT_TRUE(peer_room.connect(config_.url, config_.token_a, options)) << "Peer failed to connect"; + ASSERT_FALSE(peer_room.localParticipant().expired()); + + const std::string peer_identity = lockLocalParticipant(peer_room)->identity(); + ASSERT_FALSE(peer_identity.empty()); + + const std::map peer_identity_expected{{peer_identity, 1}}; + ASSERT_TRUE(waitForMapCountAtLeast(observer_counts, peer_identity_expected, 1, kEventWaitTimeout)) + << "Timed out waiting for onParticipantConnected"; + ASSERT_TRUE(waitForParticipant(&observer_room, peer_identity, 10s)) << "Peer not visible to observer room"; + { + const RoomEventCountsSnapshot snapshot = snapshotCounts(observer_counts); + expectMapCountsExact(snapshot.participant_connected, peer_identity_expected, "onParticipantConnected"); + expectCountsUnchangedAfterGrace(observer_counts, snapshot, "after participant connected"); + } + + const std::string audio_track_name = makeUniqueTrackName("dedupe-audio"); + const std::string video_track_name = makeUniqueTrackName("dedupe-video"); + + auto audio_source = std::make_shared(kDefaultAudioSampleRate, kDefaultAudioChannels, 0); + auto video_source = std::make_shared(kDefaultVideoWidth, kDefaultVideoHeight); + auto audio_track = LocalAudioTrack::createLocalAudioTrack(audio_track_name, audio_source); + auto video_track = LocalVideoTrack::createLocalVideoTrack(video_track_name, video_source); + + TrackPublishOptions audio_opts; + audio_opts.source = TrackSource::SOURCE_MICROPHONE; + TrackPublishOptions video_opts; + video_opts.source = TrackSource::SOURCE_CAMERA; + + auto peer_participant = lockLocalParticipant(peer_room); + PublishedTrackGuard published_tracks(peer_participant.get()); + MediaLoopGuard media_loops; + + ASSERT_NO_THROW(peer_participant->publishTrack(audio_track, audio_opts)); + ASSERT_NE(audio_track->publication(), nullptr); + published_tracks.addTrackSid(audio_track->publication()->sid()); + media_loops.addAudioSource(audio_source); + + ASSERT_NO_THROW(peer_participant->publishTrack(video_track, video_opts)); + ASSERT_NE(video_track->publication(), nullptr); + published_tracks.addTrackSid(video_track->publication()->sid()); + media_loops.addVideoSource(video_source); + + const std::map expected_subscribed_counts{{audio_track_name, 1}, {video_track_name, 1}}; + + ASSERT_TRUE(waitForMapCountAtLeastTrack(observer_counts, expected_subscribed_counts, + &RoomEventCounts::track_subscribed, kEventWaitTimeout)) + << "Timed out waiting for onTrackSubscribed; observed track_subscribed=" + << describeCounts(snapshotCounts(observer_counts).track_subscribed); + + { + const RoomEventCountsSnapshot snapshot = snapshotCounts(observer_counts); + expectMapCountsExact(snapshot.track_subscribed, expected_subscribed_counts, "onTrackSubscribed"); + expectCountsUnchangedAfterGrace(observer_counts, snapshot, "after track subscribe"); + } + + media_loops.stop(); + published_tracks.unpublishAll(); + + const std::map expected_unsubscribed_counts{{audio_track_name, 1}, {video_track_name, 1}}; + ASSERT_TRUE(waitForMapCountAtLeastTrack(observer_counts, expected_unsubscribed_counts, + &RoomEventCounts::track_unsubscribed, kEventWaitTimeout)) + << "Timed out waiting for onTrackUnsubscribed; observed track_unsubscribed=" + << describeCounts(snapshotCounts(observer_counts).track_unsubscribed); + + { + const RoomEventCountsSnapshot snapshot = snapshotCounts(observer_counts); + expectMapCountsExact(snapshot.track_unsubscribed, expected_unsubscribed_counts, "onTrackUnsubscribed"); + expectCountsUnchangedAfterGrace(observer_counts, snapshot, "after track unsubscribed"); + } + + peer_room.disconnect(); + + ASSERT_TRUE(waitForMapCountAtLeastTrack(observer_counts, peer_identity_expected, + &RoomEventCounts::participant_disconnected, kEventWaitTimeout)) + << "Timed out waiting for onParticipantDisconnected"; + + { + const RoomEventCountsSnapshot snapshot = snapshotCounts(observer_counts); + expectMapCountsExact(snapshot.participant_disconnected, peer_identity_expected, "onParticipantDisconnected"); + expectCountsUnchangedAfterGrace(observer_counts, snapshot, "after participant disconnected"); + } + + ASSERT_TRUE(observer_room.disconnect()) << "Observer disconnect failed"; + EXPECT_EQ(snapshotCounts(observer_counts).disconnected, 1) << "onDisconnected should fire exactly once"; + + const RoomEventCountsSnapshot after_disconnect = snapshotCounts(observer_counts); + expectCountsUnchangedAfterGrace(observer_counts, after_disconnect, "after observer disconnect"); + + EXPECT_FALSE(observer_room.disconnect()) << "Second disconnect should be a no-op"; + EXPECT_EQ(snapshotCounts(observer_counts).disconnected, 1) << "onDisconnected must not double-fire"; +} + +INSTANTIATE_TEST_SUITE_P(SingleAndDualPeerConnection, RoomEventDeduplicationIntegrationTest, ::testing::Bool()); + +} // namespace livekit::test diff --git a/src/tests/integration/test_video_frame_metadata.cpp b/src/tests/integration/test_video_frame_metadata.cpp index 4c309b05..63b48664 100644 --- a/src/tests/integration/test_video_frame_metadata.cpp +++ b/src/tests/integration/test_video_frame_metadata.cpp @@ -51,18 +51,18 @@ TEST_F(VideoFrameMetadataServerTest, UserTimestampRoundTripsToReceiverEventCallb std::optional received_user_timestamp_us; const std::string track_name = "metadata-track"; - receiver_room.setOnVideoFrameEventCallback(sender_identity, track_name, - [&mutex, &cv, &received_user_timestamp_us](const VideoFrameEvent& event) { - std::lock_guard lock(mutex); - if (!event.metadata) { - return; - } - const auto& user_timestamp_us = event.metadata->user_timestamp_us; - if (user_timestamp_us.has_value() && *user_timestamp_us != 0) { - received_user_timestamp_us = user_timestamp_us; - cv.notify_all(); - } - }); + ASSERT_TRUE(receiver_room.trySetOnVideoFrameEventCallback( + sender_identity, track_name, [&mutex, &cv, &received_user_timestamp_us](const VideoFrameEvent& event) { + std::lock_guard lock(mutex); + if (!event.metadata) { + return; + } + const auto& user_timestamp_us = event.metadata->user_timestamp_us; + if (user_timestamp_us.has_value() && *user_timestamp_us != 0) { + received_user_timestamp_us = user_timestamp_us; + cv.notify_all(); + } + })); auto source = std::make_shared(16, 16); auto track = LocalVideoTrack::createLocalVideoTrack(track_name, source); @@ -180,17 +180,17 @@ TEST_F(VideoFrameMetadataServerTest, UserDataRoundTripsToReceiverEventCallback) const std::string track_name = "userdata-track"; const std::vector expected_user_data{0x01, 0x02, 0xab, 0xcd, 0xef}; - receiver_room.setOnVideoFrameEventCallback(sender_identity, track_name, - [&mutex, &cv, &received_user_data](const VideoFrameEvent& event) { - std::lock_guard lock(mutex); - if (!event.metadata || !event.metadata->user_data.has_value()) { - return; - } - if (!event.metadata->user_data->empty()) { - received_user_data = event.metadata->user_data; - cv.notify_all(); - } - }); + ASSERT_TRUE(receiver_room.trySetOnVideoFrameEventCallback( + sender_identity, track_name, [&mutex, &cv, &received_user_data](const VideoFrameEvent& event) { + std::lock_guard lock(mutex); + if (!event.metadata || !event.metadata->user_data.has_value()) { + return; + } + if (!event.metadata->user_data->empty()) { + received_user_data = event.metadata->user_data; + cv.notify_all(); + } + })); auto source = std::make_shared(16, 16); auto track = LocalVideoTrack::createLocalVideoTrack(track_name, source); diff --git a/src/tests/unit/test_room_callbacks.cpp b/src/tests/unit/test_room_callbacks.cpp index 71349d5b..f74524dc 100644 --- a/src/tests/unit/test_room_callbacks.cpp +++ b/src/tests/unit/test_room_callbacks.cpp @@ -37,12 +37,20 @@ class RoomCallbackTest : public ::testing::Test { TEST_F(RoomCallbackTest, FrameCallbackRegistrationByTrackNameIsAccepted) { Room room; - EXPECT_NO_THROW(room.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {})); - EXPECT_NO_THROW(room.setOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {})); + EXPECT_TRUE(room.trySetOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {})); + EXPECT_TRUE(room.trySetOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {})); EXPECT_NO_THROW(room.clearOnAudioFrameCallback("alice", "mic-main")); EXPECT_NO_THROW(room.clearOnVideoFrameCallback("alice", "cam-main")); } +TEST_F(RoomCallbackTest, TrySetOnAudioReturnsTrueWithoutSubscription) { + // Without a subscribed track, registration succeeds and no reader starts. + Room room; + EXPECT_TRUE(room.trySetOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {})); + // Re-registering the same key while no reader is active is allowed. + EXPECT_TRUE(room.trySetOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {})); +} + TEST_F(RoomCallbackTest, DataCallbackRegistrationReturnsUsableIds) { Room room; @@ -68,8 +76,8 @@ TEST_F(RoomCallbackTest, RemovingUnknownDataCallbackIsNoOp) { TEST_F(RoomCallbackTest, DestroyRoomWithRegisteredCallbacksIsSafe) { EXPECT_NO_THROW({ Room room; - room.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); - room.setOnVideoFrameCallback("bob", "cam-main", [](const VideoFrame&, std::int64_t) {}); + (void)room.trySetOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); + (void)room.trySetOnVideoFrameCallback("bob", "cam-main", [](const VideoFrame&, std::int64_t) {}); room.addOnDataFrameCallback("carol", "track", [](const std::vector&, std::optional) {}); }); @@ -78,7 +86,7 @@ TEST_F(RoomCallbackTest, DestroyRoomWithRegisteredCallbacksIsSafe) { TEST_F(RoomCallbackTest, DestroyRoomAfterClearingCallbacksIsSafe) { EXPECT_NO_THROW({ Room room; - room.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); + (void)room.trySetOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); room.clearOnAudioFrameCallback("alice", "mic-main"); const auto id = room.addOnDataFrameCallback("alice", "track", @@ -95,8 +103,8 @@ TEST_F(RoomCallbackTest, DefaultConnectionStateIsDisconnected) { TEST_F(RoomCallbackTest, ConnectionStateRemainsDisconnectedWithoutConnect) { // Register callbacks, do other operations — state must stay Disconnected. Room room; - room.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); - room.setOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {}); + (void)room.trySetOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); + (void)room.trySetOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {}); room.addOnDataFrameCallback("alice", "track", [](const std::vector&, std::optional) {}); room.registerTextStreamHandler("topic", [](const std::shared_ptr&, const std::string&) {}); EXPECT_EQ(room.connectionState(), ConnectionState::Disconnected); diff --git a/src/tests/unit/test_subscription_thread_dispatcher.cpp b/src/tests/unit/test_subscription_thread_dispatcher.cpp index 80b52120..d2a34cd1 100644 --- a/src/tests/unit/test_subscription_thread_dispatcher.cpp +++ b/src/tests/unit/test_subscription_thread_dispatcher.cpp @@ -18,14 +18,49 @@ #include #include +#include #include +#include +#include +#include +#include +#include #include #include #include +#include "../common/remote_data_track_test_access.h" + namespace livekit { +namespace { + +using namespace std::chrono_literals; + +/// Minimal Track used to drive audio/video reader startup decisions without a +/// live FFI handle. The SID-skip check runs before any FFI call, so an invalid +/// handle is sufficient to exercise it deterministically. +class FakeMediaTrack : public Track { +public: + FakeMediaTrack(std::string sid, TrackKind kind) + : Track(FfiHandle(0), std::move(sid), "track", kind, StreamState::STATE_ACTIVE, false, true) {} +}; + +template +bool waitFor(Predicate predicate, std::chrono::milliseconds timeout) { + const auto start = std::chrono::steady_clock::now(); + while (std::chrono::steady_clock::now() - start < timeout) { + if (predicate()) { + return true; + } + std::this_thread::sleep_for(5ms); + } + return predicate(); +} + +} // namespace + class SubscriptionThreadDispatcherTest : public ::testing::Test { protected: void SetUp() override { livekit::initialize(livekit::LogLevel::Info); } @@ -37,6 +72,8 @@ class SubscriptionThreadDispatcherTest : public ::testing::Test { using DataCallbackKey = SubscriptionThreadDispatcher::DataCallbackKey; using DataCallbackKeyHash = SubscriptionThreadDispatcher::DataCallbackKeyHash; + using ActiveDataReader = SubscriptionThreadDispatcher::ActiveDataReader; + static auto& audioCallbacks(SubscriptionThreadDispatcher& dispatcher) { return dispatcher.audio_callbacks_; } static auto& videoCallbacks(SubscriptionThreadDispatcher& dispatcher) { return dispatcher.video_callbacks_; } static auto& activeReaders(SubscriptionThreadDispatcher& dispatcher) { return dispatcher.active_readers_; } @@ -44,6 +81,31 @@ class SubscriptionThreadDispatcherTest : public ::testing::Test { static auto& activeDataReaders(SubscriptionThreadDispatcher& dispatcher) { return dispatcher.active_data_readers_; } static auto& remoteDataTracks(SubscriptionThreadDispatcher& dispatcher) { return dispatcher.remote_data_tracks_; } static int maxActiveReaders() { return SubscriptionThreadDispatcher::kMaxActiveReaders; } + static std::size_t activeReaderCount(SubscriptionThreadDispatcher& dispatcher) { + const std::scoped_lock lock(dispatcher.lock_); + return dispatcher.active_readers_.size(); + } + static std::size_t activeDataReaderCount(SubscriptionThreadDispatcher& dispatcher) { + const std::scoped_lock lock(dispatcher.lock_); + return dispatcher.active_data_readers_.size(); + } + + static std::thread extractDataReader(SubscriptionThreadDispatcher& dispatcher, DataFrameCallbackId id) { + const std::scoped_lock lock(dispatcher.lock_); + return dispatcher.extractDataReaderThreadLocked(id); + } + + static void markDataReaderFinishedIfCurrent(SubscriptionThreadDispatcher& dispatcher, DataFrameCallbackId id, + const std::shared_ptr& reader) { + dispatcher.markDataReaderFinishedIfCurrent(id, reader); + } + + static std::thread startDataReader(SubscriptionThreadDispatcher& dispatcher, DataFrameCallbackId id, + const DataCallbackKey& key, const std::shared_ptr& track) { + const std::scoped_lock lock(dispatcher.lock_); + return dispatcher.startDataReaderLocked(id, key, track, + [](const std::vector&, std::optional) {}); + } }; // ============================================================================ @@ -134,21 +196,21 @@ TEST_F(SubscriptionThreadDispatcherTest, MaxActiveReadersIs20) { EXPECT_EQ(maxAc TEST_F(SubscriptionThreadDispatcherTest, SetAudioCallbackStoresRegistration) { SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); + EXPECT_TRUE(dispatcher.trySetOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {})); EXPECT_EQ(audioCallbacks(dispatcher).size(), 1u); } TEST_F(SubscriptionThreadDispatcherTest, SetVideoCallbackStoresRegistration) { SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {}); + EXPECT_TRUE(dispatcher.trySetOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {})); EXPECT_EQ(videoCallbacks(dispatcher).size(), 1u); } TEST_F(SubscriptionThreadDispatcherTest, ClearAudioCallbackRemovesRegistration) { SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); + (void)dispatcher.trySetOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); ASSERT_EQ(audioCallbacks(dispatcher).size(), 1u); dispatcher.clearOnAudioFrameCallback("alice", "mic-main"); @@ -157,7 +219,7 @@ TEST_F(SubscriptionThreadDispatcherTest, ClearAudioCallbackRemovesRegistration) TEST_F(SubscriptionThreadDispatcherTest, ClearVideoCallbackRemovesRegistration) { SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {}); + (void)dispatcher.trySetOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {}); ASSERT_EQ(videoCallbacks(dispatcher).size(), 1u); dispatcher.clearOnVideoFrameCallback("alice", "cam-main"); @@ -175,26 +237,26 @@ TEST_F(SubscriptionThreadDispatcherTest, OverwriteAudioCallbackKeepsSingleEntry) std::atomic counter1{0}; std::atomic counter2{0}; - dispatcher.setOnAudioFrameCallback("alice", "mic-main", [&counter1](const AudioFrame&) { counter1++; }); - dispatcher.setOnAudioFrameCallback("alice", "mic-main", [&counter2](const AudioFrame&) { counter2++; }); + (void)dispatcher.trySetOnAudioFrameCallback("alice", "mic-main", [&counter1](const AudioFrame&) { counter1++; }); + (void)dispatcher.trySetOnAudioFrameCallback("alice", "mic-main", [&counter2](const AudioFrame&) { counter2++; }); EXPECT_EQ(audioCallbacks(dispatcher).size(), 1u) << "Re-registering with the same key should overwrite, not add"; } TEST_F(SubscriptionThreadDispatcherTest, OverwriteVideoCallbackKeepsSingleEntry) { SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {}); - dispatcher.setOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {}); + (void)dispatcher.trySetOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {}); + (void)dispatcher.trySetOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {}); EXPECT_EQ(videoCallbacks(dispatcher).size(), 1u); } TEST_F(SubscriptionThreadDispatcherTest, MultipleDistinctCallbacksAreIndependent) { SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); - dispatcher.setOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {}); - dispatcher.setOnAudioFrameCallback("bob", "mic-main", [](const AudioFrame&) {}); - dispatcher.setOnVideoFrameCallback("bob", "cam-main", [](const VideoFrame&, std::int64_t) {}); + (void)dispatcher.trySetOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); + (void)dispatcher.trySetOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {}); + (void)dispatcher.trySetOnAudioFrameCallback("bob", "mic-main", [](const AudioFrame&) {}); + (void)dispatcher.trySetOnVideoFrameCallback("bob", "cam-main", [](const VideoFrame&, std::int64_t) {}); EXPECT_EQ(audioCallbacks(dispatcher).size(), 2u); EXPECT_EQ(videoCallbacks(dispatcher).size(), 2u); @@ -206,8 +268,8 @@ TEST_F(SubscriptionThreadDispatcherTest, MultipleDistinctCallbacksAreIndependent TEST_F(SubscriptionThreadDispatcherTest, ClearingOneTrackNameDoesNotAffectOther) { SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); - dispatcher.setOnAudioFrameCallback("alice", "screenshare-main", [](const AudioFrame&) {}); + (void)dispatcher.trySetOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); + (void)dispatcher.trySetOnAudioFrameCallback("alice", "screenshare-main", [](const AudioFrame&) {}); ASSERT_EQ(audioCallbacks(dispatcher).size(), 2u); dispatcher.clearOnAudioFrameCallback("alice", "mic-main"); @@ -228,7 +290,7 @@ TEST_F(SubscriptionThreadDispatcherTest, NoActiveReadersInitially) { TEST_F(SubscriptionThreadDispatcherTest, ActiveReadersEmptyAfterCallbackRegistration) { SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); + (void)dispatcher.trySetOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); EXPECT_TRUE(activeReaders(dispatcher).empty()) << "Registering a callback without a subscribed track should not spawn " "readers"; @@ -241,15 +303,15 @@ TEST_F(SubscriptionThreadDispatcherTest, ActiveReadersEmptyAfterCallbackRegistra TEST_F(SubscriptionThreadDispatcherTest, DestroyDispatcherWithRegisteredCallbacksIsSafe) { EXPECT_NO_THROW({ SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); - dispatcher.setOnVideoFrameCallback("bob", "cam-main", [](const VideoFrame&, std::int64_t) {}); + (void)dispatcher.trySetOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); + (void)dispatcher.trySetOnVideoFrameCallback("bob", "cam-main", [](const VideoFrame&, std::int64_t) {}); }); } TEST_F(SubscriptionThreadDispatcherTest, DestroyDispatcherAfterClearingCallbacksIsSafe) { EXPECT_NO_THROW({ SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); + (void)dispatcher.trySetOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); dispatcher.clearOnAudioFrameCallback("alice", "mic-main"); }); } @@ -270,7 +332,7 @@ TEST_F(SubscriptionThreadDispatcherTest, ConcurrentRegistrationDoesNotCrash) { threads.emplace_back([&dispatcher, t, kIterations]() { for (int i = 0; i < kIterations; ++i) { const std::string id = "participant-" + std::to_string(t); - dispatcher.setOnAudioFrameCallback(id, "mic-main", [](const AudioFrame&) {}); + (void)dispatcher.trySetOnAudioFrameCallback(id, "mic-main", [](const AudioFrame&) {}); dispatcher.clearOnAudioFrameCallback(id, "mic-main"); } }); @@ -295,8 +357,8 @@ TEST_F(SubscriptionThreadDispatcherTest, ConcurrentMixedAudioVideoRegistration) threads.emplace_back([&dispatcher, t, kIterations]() { const std::string id = "p-" + std::to_string(t); for (int i = 0; i < kIterations; ++i) { - dispatcher.setOnAudioFrameCallback(id, "mic-main", [](const AudioFrame&) {}); - dispatcher.setOnVideoFrameCallback(id, "cam-main", [](const VideoFrame&, std::int64_t) {}); + (void)dispatcher.trySetOnAudioFrameCallback(id, "mic-main", [](const AudioFrame&) {}); + (void)dispatcher.trySetOnVideoFrameCallback(id, "cam-main", [](const VideoFrame&, std::int64_t) {}); } }); } @@ -318,7 +380,8 @@ TEST_F(SubscriptionThreadDispatcherTest, ManyDistinctCallbacksCanBeRegistered) { constexpr int kCount = 50; for (int i = 0; i < kCount; ++i) { - dispatcher.setOnAudioFrameCallback("participant-" + std::to_string(i), "mic-main", [](const AudioFrame&) {}); + (void)dispatcher.trySetOnAudioFrameCallback("participant-" + std::to_string(i), "mic-main", + [](const AudioFrame&) {}); } EXPECT_EQ(audioCallbacks(dispatcher).size(), static_cast(kCount)); @@ -478,6 +541,278 @@ TEST_F(SubscriptionThreadDispatcherTest, NoRemoteDataTracksInitially) { EXPECT_TRUE(remoteDataTracks(dispatcher).empty()); } +// ============================================================================ +// Data reader replacement: cancellation and finished-state ownership +// ============================================================================ + +TEST_F(SubscriptionThreadDispatcherTest, ActiveDataReaderNotCancelledByDefault) { + auto reader = std::make_shared(); + EXPECT_FALSE(reader->cancelled.load()); + EXPECT_FALSE(reader->finished); +} + +TEST_F(SubscriptionThreadDispatcherTest, ExtractDataReaderMarksCancelledAndRemovesEntry) { + SubscriptionThreadDispatcher dispatcher; + auto reader = std::make_shared(); + activeDataReaders(dispatcher)[0] = reader; + + auto extracted = extractDataReader(dispatcher, 0); + + EXPECT_TRUE(reader->cancelled.load()) << "Extract must cancel so an in-flight subscribe aborts"; + EXPECT_FALSE(extracted.joinable()) << "No real thread was attached to the seeded reader"; + EXPECT_TRUE(activeDataReaders(dispatcher).empty()); +} + +TEST_F(SubscriptionThreadDispatcherTest, ExtractMissingDataReaderIsNoOp) { + SubscriptionThreadDispatcher dispatcher; + auto extracted = extractDataReader(dispatcher, 42); + EXPECT_FALSE(extracted.joinable()); +} + +TEST_F(SubscriptionThreadDispatcherTest, MarkDataReaderFinishedIfCurrentKeepsMatchingEntry) { + SubscriptionThreadDispatcher dispatcher; + auto reader = std::make_shared(); + activeDataReaders(dispatcher)[0] = reader; + + markDataReaderFinishedIfCurrent(dispatcher, 0, reader); + + ASSERT_EQ(activeDataReaders(dispatcher).size(), 1u); + EXPECT_EQ(activeDataReaders(dispatcher)[0], reader); + EXPECT_TRUE(reader->finished); +} + +TEST_F(SubscriptionThreadDispatcherTest, MarkDataReaderFinishedIfCurrentLeavesReplacedEntry) { + SubscriptionThreadDispatcher dispatcher; + auto original = std::make_shared(); + auto replacement = std::make_shared(); + activeDataReaders(dispatcher)[0] = replacement; + + // The original reader exited after being replaced; it must not mark the + // newer reader that now owns the same callback id. + markDataReaderFinishedIfCurrent(dispatcher, 0, original); + + ASSERT_EQ(activeDataReaders(dispatcher).size(), 1u); + EXPECT_EQ(activeDataReaders(dispatcher)[0], replacement); + EXPECT_FALSE(replacement->finished); +} + +TEST_F(SubscriptionThreadDispatcherTest, ExtractFinishedDataReaderRemovesEntryAndReturnsJoinableThread) { + SubscriptionThreadDispatcher dispatcher; + auto reader = std::make_shared(); + reader->finished = true; + reader->thread = std::thread([]() {}); + activeDataReaders(dispatcher)[0] = reader; + + auto extracted = extractDataReader(dispatcher, 0); + + EXPECT_TRUE(reader->cancelled.load()); + EXPECT_TRUE(extracted.joinable()); + EXPECT_TRUE(activeDataReaders(dispatcher).empty()); + extracted.join(); +} + +// ============================================================================ +// SID deduplication: audio/video reader start is skipped for the same SID +// ============================================================================ + +TEST_F(SubscriptionThreadDispatcherTest, DuplicateSubscribeWithSameAudioSidDoesNotRestartReader) { + SubscriptionThreadDispatcher dispatcher; + (void)dispatcher.trySetOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {}); + + // Simulate an already-running reader for this subscription. + const CallbackKey key{"alice", "mic"}; + activeReaders(dispatcher)[key].track_sid = "TR_audio_1"; + ASSERT_EQ(activeReaderCount(dispatcher), 1u); + + // A duplicate track_subscribed carrying the same SID must be a no-op: no + // extract, no new stream/thread. + auto track = std::make_shared("TR_audio_1", TrackKind::KIND_AUDIO); + dispatcher.handleTrackSubscribed("alice", "mic", track); + + EXPECT_EQ(activeReaderCount(dispatcher), 1u); + EXPECT_EQ(activeReaders(dispatcher)[key].track_sid, "TR_audio_1"); + EXPECT_EQ(activeReaders(dispatcher)[key].audio_stream, nullptr) << "Reader must not have been rebuilt"; +} + +TEST_F(SubscriptionThreadDispatcherTest, DuplicateSubscribeWithSameVideoSidDoesNotRestartReader) { + SubscriptionThreadDispatcher dispatcher; + (void)dispatcher.trySetOnVideoFrameCallback("alice", "cam", [](const VideoFrame&, std::int64_t) {}); + + const CallbackKey key{"alice", "cam"}; + activeReaders(dispatcher)[key].track_sid = "TR_video_1"; + ASSERT_EQ(activeReaderCount(dispatcher), 1u); + + auto track = std::make_shared("TR_video_1", TrackKind::KIND_VIDEO); + dispatcher.handleTrackSubscribed("alice", "cam", track); + + EXPECT_EQ(activeReaderCount(dispatcher), 1u); + EXPECT_EQ(activeReaders(dispatcher)[key].track_sid, "TR_video_1"); + EXPECT_EQ(activeReaders(dispatcher)[key].video_stream, nullptr) << "Reader must not have been rebuilt"; +} + +// ============================================================================ +// trySetOn* replacement semantics: registration is rejected while a reader is +// active; clearing first allows re-registration. +// ============================================================================ + +TEST_F(SubscriptionThreadDispatcherTest, TrySetOnAudioWhileReaderActiveIsRejected) { + SubscriptionThreadDispatcher dispatcher; + ASSERT_TRUE(dispatcher.trySetOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {})); + + // Simulate an already-running reader for this subscription. + const CallbackKey key{"alice", "mic"}; + activeReaders(dispatcher)[key].track_sid = "TR_audio_1"; + ASSERT_EQ(activeReaderCount(dispatcher), 1u); + + EXPECT_FALSE(dispatcher.trySetOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {})) + << "Replacing a callback while its reader is active must be rejected"; +} + +TEST_F(SubscriptionThreadDispatcherTest, TrySetOnVideoWhileReaderActiveIsRejected) { + SubscriptionThreadDispatcher dispatcher; + ASSERT_TRUE(dispatcher.trySetOnVideoFrameCallback("alice", "cam", [](const VideoFrame&, std::int64_t) {})); + + const CallbackKey key{"alice", "cam"}; + activeReaders(dispatcher)[key].track_sid = "TR_video_1"; + ASSERT_EQ(activeReaderCount(dispatcher), 1u); + + EXPECT_FALSE(dispatcher.trySetOnVideoFrameCallback("alice", "cam", [](const VideoFrame&, std::int64_t) {})); + EXPECT_FALSE(dispatcher.trySetOnVideoFrameEventCallback("alice", "cam", [](const VideoFrameEvent&) {})); +} + +TEST_F(SubscriptionThreadDispatcherTest, ClearThenTrySetOnAudioRegistersNewCallback) { + SubscriptionThreadDispatcher dispatcher; + ASSERT_TRUE(dispatcher.trySetOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {})); + + const CallbackKey key{"alice", "mic"}; + activeReaders(dispatcher)[key].track_sid = "TR_audio_1"; + ASSERT_EQ(activeReaderCount(dispatcher), 1u); + + // Rejected while active, accepted once the reader is cleared. + ASSERT_FALSE(dispatcher.trySetOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {})); + dispatcher.clearOnAudioFrameCallback("alice", "mic"); + EXPECT_EQ(activeReaderCount(dispatcher), 0u); + EXPECT_TRUE(dispatcher.trySetOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {})); + EXPECT_EQ(audioCallbacks(dispatcher).size(), 1u); +} + +TEST_F(SubscriptionThreadDispatcherTest, ClearThenDeprecatedSetOnAudioRegistersNewCallback) { + SubscriptionThreadDispatcher dispatcher; +#if defined(__clang__) +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" +#elif defined(__GNUC__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +#endif + dispatcher.setOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {}); +#if defined(__clang__) +#pragma clang diagnostic pop +#elif defined(__GNUC__) +#pragma GCC diagnostic pop +#endif + + const CallbackKey key{"alice", "mic"}; + activeReaders(dispatcher)[key].track_sid = "TR_audio_1"; + ASSERT_EQ(activeReaderCount(dispatcher), 1u); + + dispatcher.clearOnAudioFrameCallback("alice", "mic"); + EXPECT_EQ(activeReaderCount(dispatcher), 0u); +#if defined(__clang__) +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" +#elif defined(__GNUC__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +#endif + dispatcher.setOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {}); +#if defined(__clang__) +#pragma clang diagnostic pop +#elif defined(__GNUC__) +#pragma GCC diagnostic pop +#endif + EXPECT_EQ(audioCallbacks(dispatcher).size(), 1u); +} + +TEST_F(SubscriptionThreadDispatcherTest, TrySetOnAudioWithoutActiveReaderOverwritesRegistration) { + SubscriptionThreadDispatcher dispatcher; + EXPECT_TRUE(dispatcher.trySetOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {})); + // No reader is active, so re-registering the same key is allowed and simply + // overwrites the stored callback. + EXPECT_TRUE(dispatcher.trySetOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {})); + EXPECT_EQ(audioCallbacks(dispatcher).size(), 1u); +} + +// ============================================================================ +// SID deduplication: data reader start is skipped for the same SID and +// replaced (stopping the previous reader) for a new SID +// ============================================================================ + +TEST_F(SubscriptionThreadDispatcherTest, DuplicateDataPublishWithSameSidDoesNotRestartReader) { + SubscriptionThreadDispatcher dispatcher; + auto reader = std::make_shared(); + reader->remote_track = RemoteDataTrackTestAccess::create({"foo", "TR_data_1", false}, "alice"); + activeDataReaders(dispatcher)[7] = reader; + + auto incoming = RemoteDataTrackTestAccess::create({"foo", "TR_data_1", false}, "alice"); + auto old_thread = startDataReader(dispatcher, 7, DataCallbackKey{"alice", "foo"}, incoming); + + EXPECT_FALSE(old_thread.joinable()); + EXPECT_EQ(activeDataReaderCount(dispatcher), 1u); + EXPECT_EQ(activeDataReaders(dispatcher)[7], reader) << "Same-SID publish must not replace the reader"; + EXPECT_FALSE(reader->cancelled.load()) << "A skipped reader must not be cancelled"; +} + +TEST_F(SubscriptionThreadDispatcherTest, FinishedDataReaderWithSameSidIsReplaced) { + SubscriptionThreadDispatcher dispatcher; + auto reader = std::make_shared(); + reader->remote_track = RemoteDataTrackTestAccess::create({"foo", "TR_data_1", false}, "alice"); + reader->finished = true; + activeDataReaders(dispatcher)[7] = reader; + + auto incoming = RemoteDataTrackTestAccess::create({"foo", "TR_data_1", false}, "alice"); + auto old_thread = startDataReader(dispatcher, 7, DataCallbackKey{"alice", "foo"}, incoming); + if (old_thread.joinable()) { + old_thread.join(); + } + + EXPECT_TRUE(reader->cancelled.load()) << "Finished reader must be extracted before replacement"; + EXPECT_TRUE(waitFor( + [&] { + return activeDataReaderCount(dispatcher) == 1u && activeDataReaders(dispatcher)[7] != reader && + activeDataReaders(dispatcher)[7]->finished; + }, + 2s)); + + dispatcher.stopAll(); + EXPECT_TRUE(activeDataReaders(dispatcher).empty()); +} + +TEST_F(SubscriptionThreadDispatcherTest, RepublishWithNewDataSidStopsPreviousReader) { + SubscriptionThreadDispatcher dispatcher; + auto previous = std::make_shared(); + previous->remote_track = RemoteDataTrackTestAccess::create({"foo", "TR_data_1", false}, "alice"); + activeDataReaders(dispatcher)[7] = previous; + + // A republish under the same (participant, name) but a NEW SID must stop the + // previous reader and start a fresh one. + auto republished = RemoteDataTrackTestAccess::create({"foo", "TR_data_2", false}, "alice"); + auto old_thread = startDataReader(dispatcher, 7, DataCallbackKey{"alice", "foo"}, republished); + if (old_thread.joinable()) { + old_thread.join(); + } + + EXPECT_TRUE(previous->cancelled.load()) << "Previous reader must be cancelled on republish"; + + // The replacement reader has an invalid FFI handle, so its subscribe fails + // fast and marks itself finished while the dispatcher keeps ownership. + EXPECT_TRUE(waitFor( + [&] { return activeDataReaderCount(dispatcher) == 1u && activeDataReaders(dispatcher)[7]->finished; }, 2s)); + + dispatcher.stopAll(); + EXPECT_TRUE(activeDataReaders(dispatcher).empty()); +} + // ============================================================================ // Data track destruction safety // ============================================================================ @@ -507,8 +842,8 @@ TEST_F(SubscriptionThreadDispatcherTest, DestroyDispatcherAfterRemovingDataCallb TEST_F(SubscriptionThreadDispatcherTest, MixedAudioVideoDataCallbacksAreIndependent) { SubscriptionThreadDispatcher dispatcher; - dispatcher.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); - dispatcher.setOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {}); + (void)dispatcher.trySetOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); + (void)dispatcher.trySetOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {}); dispatcher.addOnDataFrameCallback("alice", "data-track", [](const std::vector&, std::optional) {}); From 2f16279d5066cb85fa9338187947bbd3a0c1c4ba Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Fri, 28 Aug 2026 14:46:36 -0600 Subject: [PATCH 2/8] setOn* replaces the callback in place --- README.md | 24 +- include/livekit/room.h | 83 +- .../livekit/subscription_thread_dispatcher.h | 159 ++-- src/room.cpp | 84 +- src/subscription_thread_dispatcher.cpp | 206 ++--- src/tests/common/room_test_access.h | 84 ++ .../test_frame_callback_replacement.cpp | 757 ++++++++++++++++++ src/tests/integration/test_platform_audio.cpp | 22 +- .../integration/test_video_frame_metadata.cpp | 46 +- src/tests/unit/test_room.cpp | 29 +- src/tests/unit/test_room_callbacks.cpp | 18 +- .../test_subscription_thread_dispatcher.cpp | 326 ++++++-- 12 files changed, 1377 insertions(+), 461 deletions(-) create mode 100644 src/tests/common/room_test_access.h create mode 100644 src/tests/integration/test_frame_callback_replacement.cpp diff --git a/README.md b/README.md index 6c685a1c..1d6dfb1e 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,23 @@ room->addOnDataFrameCallback(sender_identity, "app-data", }); ``` +Calling `setOnAudioFrameCallback` / `setOnVideoFrameCallback` / +`setOnVideoFrameEventCallback` again for the same +`(participant_identity, track_name)` **replaces** the callback in place. The +previous reader is stopped and its thread joined before the call returns, then a +fresh reader is started bound to the new callback — there is no need to call +`clearOn*FrameCallback` first. Two consequences worth knowing: + +- **These calls block** until any in-flight invocation of the previous callback + returns. When the call returns, the old callback is guaranteed to have + finished and been destroyed. A callback that blocks forever blocks + registration forever. +- **Do not register or clear from inside a frame callback.** Doing so would make + the join a self-join. The SDK detects this, logs an error, and detaches the + reader (media) or leaves it in place to be reaped at teardown (data), but the + registration does not behave as intended. Drive callback changes from another + thread. + For end-to-end samples and a fuller set of demos, see the [cpp-example-collection repo](https://github.com/livekit-examples/cpp-example-collection). ### Generating tokens @@ -275,13 +292,6 @@ The following features are deprecated and will be removed in the next major rele - `PacketTrailerFeatures` is deprecated. Use `FrameMetadataFeatures` via `TrackPublishOptions::frame_metadata_features` instead. -- `Room::setOnAudioFrameCallback`, `Room::setOnVideoFrameCallback`, and - `Room::setOnVideoFrameEventCallback` are deprecated. Use the `[[nodiscard]]` - variants `trySetOnAudioFrameCallback`, `trySetOnVideoFrameCallback`, and - `trySetOnVideoFrameEventCallback` instead, which return `false` when a reader - is already active for the key (instead of silently replacing a running callback). - To replace an active callback, call `clearOn*FrameCallback` first. (The same rename - applies to the corresponding `SubscriptionThreadDispatcher` methods.) ### `v1.0.0` diff --git a/include/livekit/room.h b/include/livekit/room.h index 37e3647d..5404985a 100644 --- a/include/livekit/room.h +++ b/include/livekit/room.h @@ -313,92 +313,15 @@ class LIVEKIT_API Room { // Frame callbacks // --------------------------------------------------------------- - /// Register an audio frame callback for a remote subscription. - /// - /// The callback is keyed by @p participant_identity and @p track_name. If the - /// matching remote audio track is already subscribed, a reader is started - /// immediately; otherwise the reader starts when the track is subscribed. - /// - /// To replace a callback whose reader is already running, call - /// @ref clearOnAudioFrameCallback first, then register again: - /// @code - /// room.clearOnAudioFrameCallback(identity, track_name); - /// if (!room.trySetOnAudioFrameCallback(identity, track_name, new_handler)) { - /// // registration was rejected (a reader is still active) - /// } - /// @endcode - /// - /// @param participant_identity Identity of the remote participant. - /// @param track_name Track name to match. - /// @param callback Function invoked for each decoded audio frame. - /// @param opts Options used when creating the backing - /// @ref AudioStream. - /// @return @c true if the callback was registered; @c false if a reader is - /// already active for the key (call @ref clearOnAudioFrameCallback - /// first) or the room has no dispatcher. - [[nodiscard]] bool trySetOnAudioFrameCallback(const std::string& participant_identity, const std::string& track_name, - AudioFrameCallback callback, const AudioStream::Options& opts = {}); - - /// Register a video frame callback for a remote subscription. - /// - /// The callback is keyed by @p participant_identity and @p track_name. If the - /// matching remote video track is already subscribed, a reader is started - /// immediately; otherwise the reader starts when the track is subscribed. - /// - /// To replace a callback whose reader is already running, call - /// @ref clearOnVideoFrameCallback first, then register again. - /// - /// @param participant_identity Identity of the remote participant. - /// @param track_name Track name to match. - /// @param callback Function invoked for each decoded video frame. - /// @param opts Options used when creating the backing - /// @ref VideoStream. - /// @return @c true if the callback was registered; @c false if a reader is - /// already active for the key (call @ref clearOnVideoFrameCallback - /// first) or the room has no dispatcher. - [[nodiscard]] bool trySetOnVideoFrameCallback(const std::string& participant_identity, const std::string& track_name, - VideoFrameCallback callback, const VideoStream::Options& opts = {}); - - /// Register a rich video frame event callback for a remote subscription. - /// - /// The callback is keyed by @p participant_identity and @p track_name. If the - /// matching remote video track is already subscribed, a reader is started - /// immediately; otherwise the reader starts when the track is subscribed. - /// - /// To replace a callback whose reader is already running, call - /// @ref clearOnVideoFrameCallback first, then register again. - /// - /// @param participant_identity Identity of the remote participant. - /// @param track_name Track name to match. - /// @param callback Function invoked for each decoded video frame - /// event, including optional metadata. - /// @param opts Options used when creating the backing - /// @ref VideoStream. - /// @return @c true if the callback was registered; @c false if a reader is - /// already active for the key (call @ref clearOnVideoFrameCallback - /// first) or the room has no dispatcher. - [[nodiscard]] bool trySetOnVideoFrameEventCallback(const std::string& participant_identity, - const std::string& track_name, VideoFrameEventCallback callback, - const VideoStream::Options& opts = {}); - - /// @deprecated Use trySetOnAudioFrameCallback() instead. - /// - /// Forwards to @ref trySetOnAudioFrameCallback and discards the result. - [[deprecated("Room::setOnAudioFrameCallback is deprecated; use trySetOnAudioFrameCallback instead")]] + /// Register or replace an audio frame callback for a remote subscription via SubscriptionThreadDispatcher. void setOnAudioFrameCallback(const std::string& participant_identity, const std::string& track_name, AudioFrameCallback callback, const AudioStream::Options& opts = {}); - /// @deprecated Use trySetOnVideoFrameCallback() instead. - /// - /// Forwards to @ref trySetOnVideoFrameCallback and discards the result. - [[deprecated("Room::setOnVideoFrameCallback is deprecated; use trySetOnVideoFrameCallback instead")]] + /// Register or replace a video frame callback for a remote subscription via SubscriptionThreadDispatcher. void setOnVideoFrameCallback(const std::string& participant_identity, const std::string& track_name, VideoFrameCallback callback, const VideoStream::Options& opts = {}); - /// @deprecated Use trySetOnVideoFrameEventCallback() instead. - /// - /// Forwards to @ref trySetOnVideoFrameEventCallback and discards the result. - [[deprecated("Room::setOnVideoFrameEventCallback is deprecated; use trySetOnVideoFrameEventCallback instead")]] + /// Register or replace a video frame event callback for a remote subscription via SubscriptionThreadDispatcher. void setOnVideoFrameEventCallback(const std::string& participant_identity, const std::string& track_name, VideoFrameEventCallback callback, const VideoStream::Options& opts = {}); diff --git a/include/livekit/subscription_thread_dispatcher.h b/include/livekit/subscription_thread_dispatcher.h index a1cf9db6..68d228ed 100644 --- a/include/livekit/subscription_thread_dispatcher.h +++ b/include/livekit/subscription_thread_dispatcher.h @@ -85,7 +85,7 @@ using DataFrameCallbackId = std::uint64_t; /// The design keeps track-type-specific startup isolated so additional track /// kinds can be added later without pushing more thread state back into /// @ref Room. -class LIVEKIT_API SubscriptionThreadDispatcher { +class LIVEKIT_INTERNAL_API SubscriptionThreadDispatcher { public: /// Constructs an empty dispatcher with no registered callbacks or readers. SubscriptionThreadDispatcher(); @@ -93,55 +93,67 @@ class LIVEKIT_API SubscriptionThreadDispatcher { /// Stops all active readers and clears all registered callbacks. ~SubscriptionThreadDispatcher(); - /// Register an audio frame callback for a remote subscription. + /// Register or replace an audio frame callback for a remote subscription. /// /// The callback is keyed by remote participant identity plus @p track_name. /// If the matching remote audio track is already subscribed, @ref Room may /// immediately call @ref handleTrackSubscribed to start a reader. /// - /// Registration only succeeds when no reader is currently active for the - /// key. To replace a callback whose reader is already running, call - /// @ref clearOnAudioFrameCallback first, then register again. + /// Registering again for a key that already has an active reader replaces the + /// callback in place: the previous reader's stream is closed and its thread + /// is joined before this call returns, and @ref Room then starts a fresh + /// reader bound to the new callback. When this call returns, the previous + /// callback has finished executing and its copy has been destroyed. + /// + /// @warning This call blocks until any in-flight invocation of the previous + /// callback returns. A slow callback makes registration slow; a + /// callback that never returns blocks this call indefinitely. + /// + /// @warning Calling this from inside a frame callback for the same key is not + /// supported. The dispatcher detects the re-entrant call, logs an + /// error, and detaches the reader instead of self-joining. /// /// @param participant_identity Identity of the remote participant. /// @param track_name Track name to match. /// @param callback Function invoked for each decoded audio frame. /// @param opts Options used when creating the backing /// @ref AudioStream. - /// @return @c true if the callback was registered; @c false if a reader is - /// already active for the key (the registration is left unchanged). - [[nodiscard]] bool trySetOnAudioFrameCallback(const std::string& participant_identity, const std::string& track_name, - AudioFrameCallback callback, const AudioStream::Options& opts = {}); + void setOnAudioFrameCallback(const std::string& participant_identity, const std::string& track_name, + AudioFrameCallback callback, const AudioStream::Options& opts = {}); - /// Register a video frame callback for a remote subscription. + /// Register or replace a video frame callback for a remote subscription. /// /// The callback is keyed by remote participant identity plus @p track_name. /// If the matching remote video track is already subscribed, @ref Room may /// immediately call @ref handleTrackSubscribed to start a reader. /// - /// Registration only succeeds when no reader is currently active for the - /// key. To replace a callback whose reader is already running, call - /// @ref clearOnVideoFrameCallback first, then register again. + /// Registering again for a key that already has an active reader replaces the + /// callback in place; see @ref setOnAudioFrameCallback for the full + /// replacement semantics, blocking behavior, and re-entrancy caveat. Note + /// that this shares its registration slot with + /// @ref setOnVideoFrameEventCallback -- registering either one replaces the + /// other for the same key. /// /// @param participant_identity Identity of the remote participant. /// @param track_name Track name to match. /// @param callback Function invoked for each decoded video frame. /// @param opts Options used when creating the backing /// @ref VideoStream. - /// @return @c true if the callback was registered; @c false if a reader is - /// already active for the key (the registration is left unchanged). - [[nodiscard]] bool trySetOnVideoFrameCallback(const std::string& participant_identity, const std::string& track_name, - VideoFrameCallback callback, const VideoStream::Options& opts = {}); + void setOnVideoFrameCallback(const std::string& participant_identity, const std::string& track_name, + VideoFrameCallback callback, const VideoStream::Options& opts = {}); - /// Register a rich video frame event callback for a remote subscription. + /// Register or replace a rich video frame event callback for a remote + /// subscription. /// /// The callback is keyed by remote participant identity plus @p track_name. /// If the matching remote video track is already subscribed, @ref Room may /// immediately call @ref handleTrackSubscribed to start a reader. /// - /// Registration only succeeds when no reader is currently active for the - /// key. To replace a callback whose reader is already running, call - /// @ref clearOnVideoFrameCallback first, then register again. + /// Registering again for a key that already has an active reader replaces the + /// callback in place; see @ref setOnAudioFrameCallback for the full + /// replacement semantics, blocking behavior, and re-entrancy caveat. Note + /// that this shares its registration slot with @ref setOnVideoFrameCallback + /// -- registering either one replaces the other for the same key. /// /// @param participant_identity Identity of the remote participant. /// @param track_name Track name to match. @@ -149,49 +161,19 @@ class LIVEKIT_API SubscriptionThreadDispatcher { /// event, including optional metadata. /// @param opts Options used when creating the backing /// @ref VideoStream. - /// @return @c true if the callback was registered; @c false if a reader is - /// already active for the key (the registration is left unchanged). - [[nodiscard]] bool trySetOnVideoFrameEventCallback(const std::string& participant_identity, - const std::string& track_name, VideoFrameEventCallback callback, - const VideoStream::Options& opts = {}); - - /// @deprecated Use trySetOnAudioFrameCallback() instead. - /// - /// Forwards to @ref trySetOnAudioFrameCallback and discards the result. - /// Replacing an active callback is not supported through this overload; call - /// @ref clearOnAudioFrameCallback first, then @ref trySetOnAudioFrameCallback. - [[deprecated( - "SubscriptionThreadDispatcher::setOnAudioFrameCallback is deprecated; use trySetOnAudioFrameCallback instead")]] - void setOnAudioFrameCallback(const std::string& participant_identity, const std::string& track_name, - AudioFrameCallback callback, const AudioStream::Options& opts = {}); - - /// @deprecated Use trySetOnVideoFrameCallback() instead. - /// - /// Forwards to @ref trySetOnVideoFrameCallback and discards the result. - /// Replacing an active callback is not supported through this overload; call - /// @ref clearOnVideoFrameCallback first, then @ref trySetOnVideoFrameCallback. - [[deprecated( - "SubscriptionThreadDispatcher::setOnVideoFrameCallback is deprecated; use trySetOnVideoFrameCallback instead")]] - void setOnVideoFrameCallback(const std::string& participant_identity, const std::string& track_name, - VideoFrameCallback callback, const VideoStream::Options& opts = {}); - - /// @deprecated Use trySetOnVideoFrameEventCallback() instead. - /// - /// Forwards to @ref trySetOnVideoFrameEventCallback and discards the result. - /// Replacing an active callback is not supported through this overload; call - /// @ref clearOnVideoFrameCallback first, then - /// @ref trySetOnVideoFrameEventCallback. - [[deprecated( - "SubscriptionThreadDispatcher::setOnVideoFrameEventCallback is deprecated; use " - "trySetOnVideoFrameEventCallback instead")]] void setOnVideoFrameEventCallback(const std::string& participant_identity, const std::string& track_name, VideoFrameEventCallback callback, const VideoStream::Options& opts = {}); /// Remove an audio callback registration and stop any active reader. /// /// If an audio reader thread is active for the given key, its stream is - /// closed and the thread is joined before this call returns. Call this - /// before @ref trySetOnAudioFrameCallback to replace an active callback. + /// closed and the thread is joined before this call returns. Replacing a + /// callback does not require clearing first -- see + /// @ref setOnAudioFrameCallback. + /// + /// @warning Blocks until any in-flight callback invocation returns, and is + /// not supported from inside a frame callback for the same key. See + /// @ref setOnAudioFrameCallback. /// /// @param participant_identity Identity of the remote participant. /// @param track_name Track name to clear. @@ -200,9 +182,13 @@ class LIVEKIT_API SubscriptionThreadDispatcher { /// Remove a video callback registration and stop any active reader. /// /// If a video reader thread is active for the given key, its stream is - /// closed and the thread is joined before this call returns. Call this - /// before @ref trySetOnVideoFrameCallback (or - /// @ref trySetOnVideoFrameEventCallback) to replace an active callback. + /// closed and the thread is joined before this call returns. Replacing a + /// callback does not require clearing first -- see + /// @ref setOnVideoFrameCallback. + /// + /// @warning Blocks until any in-flight callback invocation returns, and is + /// not supported from inside a frame callback for the same key. See + /// @ref setOnAudioFrameCallback. /// /// @param participant_identity Identity of the remote participant. /// @param track_name Track name to clear. @@ -217,13 +203,14 @@ class LIVEKIT_API SubscriptionThreadDispatcher { /// AudioStream or @ref VideoStream and launches a reader thread for the /// `(participant, track_name)` key. /// - /// Remote data tracks are handled separately via @ref handleDataTrackPublished. - /// If @p track is not audio or video, or no matching callback is registered, - /// this is a no-op. + /// Remote data tracks are handled separately via @ref + /// handleDataTrackPublished. If @p track is not audio or video, or no + /// matching callback is registered, this is a no-op. /// /// @param participant_identity Identity of the remote participant. /// @param track_name Track name associated with the subscription. - /// @param track Subscribed remote audio or video track to read from. + /// @param track Subscribed remote audio or video track to read + /// from. void handleTrackSubscribed(const std::string& participant_identity, const std::string& track_name, const std::shared_ptr& track); @@ -234,7 +221,8 @@ class LIVEKIT_API SubscriptionThreadDispatcher { /// closed and its thread is joined. Callback registration is preserved so /// future re-subscription can start dispatch again automatically. /// - /// Remote data tracks are handled separately via @ref handleDataTrackUnpublished. + /// Remote data tracks are handled separately via @ref + /// handleDataTrackUnpublished. /// /// @param participant_identity Identity of the remote participant. /// @param source Track source associated with the subscription. @@ -269,6 +257,15 @@ class LIVEKIT_API SubscriptionThreadDispatcher { /// for this subscription. /// No-op if the ID is not (or no longer) registered. /// + /// @warning Blocks until any in-flight invocation of the callback returns. + /// + /// @warning Calling this from inside the data frame callback it would remove + /// is not supported. The dispatcher detects the re-entrant call, + /// logs an error, and leaves the reader in place; the reader is + /// reaped on teardown instead. Data readers cannot be safely + /// detached because they re-enter the dispatcher after the callback + /// returns. + /// /// @param id The identifier returned by addOnDataFrameCallback(). void removeOnDataFrameCallback(DataFrameCallbackId id); @@ -297,6 +294,7 @@ class LIVEKIT_API SubscriptionThreadDispatcher { private: friend class SubscriptionThreadDispatcherTest; + friend struct RoomTestAccess; /// Compound lookup key for audio/video callback dispatch. struct CallbackKey { @@ -325,6 +323,10 @@ class LIVEKIT_API SubscriptionThreadDispatcher { /// SID of the subscribed track backing this reader, used to skip redundant /// reader restarts when the same publication is re-subscribed. std::string track_sid; + /// ID of @ref thread, captured at construction. Used to detect a re-entrant + /// call made from inside this reader's own frame callback, where joining + /// would be a self-join. + std::thread::id thread_id; }; /// Compound lookup key for a remote participant identity and data track name. @@ -364,6 +366,10 @@ class LIVEKIT_API SubscriptionThreadDispatcher { std::mutex sub_mutex; std::shared_ptr stream; // guarded by sub_mutex std::thread thread; + /// ID of @ref thread, captured at construction. Used to detect a re-entrant + /// call made from inside this reader's own data frame callback, where + /// joining would be a self-join. + std::thread::id thread_id; }; /// Stored audio callback registration plus stream-construction options. @@ -385,10 +391,27 @@ class LIVEKIT_API SubscriptionThreadDispatcher { /// must be joined after releasing the lock. std::thread extractReaderThreadLocked(const CallbackKey& key); + /// True when @p id identifies the calling thread, i.e. joining that thread + /// would be a self-join. + static bool isSelfThread(std::thread::id id) { return id == std::this_thread::get_id(); } + + /// Dispose of an extracted audio/video reader thread. + /// + /// Normally joins, so the caller is guaranteed the reader has stopped and its + /// callback copy has been destroyed. If the caller *is* that reader -- a + /// re-entrant registration from inside a frame callback -- joining would be a + /// self-join, so this logs an error naming @p operation and detaches instead. + /// Detaching is safe here because audio/video reader lambdas capture no + /// @c this and own their stream and callback by value. + /// + /// Must be called with @ref lock_ released. + void disposeMediaReaderThread(std::thread&& thread, const char* operation); + /// Select the appropriate reader startup path for @p media track. /// - /// This is called by @ref Room when a remote track is subscribed. If a reader for the same track SID is already - /// active, startup is skipped and a default-constructed thread is returned; otherwise any previous reader is + /// This is called by @ref Room when a remote track is subscribed. If a reader + /// for the same track SID is already active, startup is skipped and a + /// default-constructed thread is returned; otherwise any previous reader is /// extracted and returned to the caller for joining outside the lock. /// /// Must be called with @ref lock_ held. diff --git a/src/room.cpp b/src/room.cpp index 48e0fdde..ba7d0165 100644 --- a/src/room.cpp +++ b/src/room.cpp @@ -416,16 +416,15 @@ std::shared_ptr Room::findSubscribedRemoteTrack(const std::string& partic return nullptr; } -bool Room::trySetOnAudioFrameCallback(const std::string& participant_identity, const std::string& track_name, - AudioFrameCallback callback, const AudioStream::Options& opts) { +void Room::setOnAudioFrameCallback(const std::string& participant_identity, const std::string& track_name, + AudioFrameCallback callback, const AudioStream::Options& opts) { if (!subscription_thread_dispatcher_) { - LK_LOG_ERROR("Room::trySetOnAudioFrameCallback: subscription_thread_dispatcher_ is nullptr"); - return false; - } - if (!subscription_thread_dispatcher_->trySetOnAudioFrameCallback(participant_identity, track_name, - std::move(callback), opts)) { - return false; + LK_LOG_ERROR("Room::setOnAudioFrameCallback: subscription_thread_dispatcher_ is nullptr"); + return; } + // Installs the callback and stops any reader still dispatching to the previous + // one, so the restart below binds a fresh reader to the new callback. + subscription_thread_dispatcher_->setOnAudioFrameCallback(participant_identity, track_name, std::move(callback), opts); // If we've already subscribed to the track, handle it immediately auto track = findSubscribedRemoteTrack(participant_identity, track_name); @@ -435,23 +434,19 @@ bool Room::trySetOnAudioFrameCallback(const std::string& participant_identity, c // The track is not subscribed yet. The callback is registered; the reader // starts when the track is subscribed (see kTrackSubscribed in onEvent). LK_LOG_DEBUG( - "Room::trySetOnAudioFrameCallback: track not yet subscribed for participant={} track_name={}; " + "Room::setOnAudioFrameCallback: track not yet subscribed for participant={} track_name={}; " "callback registered for deferred start", participant_identity, track_name); } - return true; } -bool Room::trySetOnVideoFrameCallback(const std::string& participant_identity, const std::string& track_name, - VideoFrameCallback callback, const VideoStream::Options& opts) { +void Room::setOnVideoFrameCallback(const std::string& participant_identity, const std::string& track_name, + VideoFrameCallback callback, const VideoStream::Options& opts) { if (!subscription_thread_dispatcher_) { - LK_LOG_ERROR("Room::trySetOnVideoFrameCallback: subscription_thread_dispatcher_ is nullptr"); - return false; - } - if (!subscription_thread_dispatcher_->trySetOnVideoFrameCallback(participant_identity, track_name, - std::move(callback), opts)) { - return false; + LK_LOG_ERROR("Room::setOnVideoFrameCallback: subscription_thread_dispatcher_ is nullptr"); + return; } + subscription_thread_dispatcher_->setOnVideoFrameCallback(participant_identity, track_name, std::move(callback), opts); // If we've already subscribed to the track, handle it immediately auto track = findSubscribedRemoteTrack(participant_identity, track_name); @@ -461,23 +456,20 @@ bool Room::trySetOnVideoFrameCallback(const std::string& participant_identity, c // The track is not subscribed yet. The callback is registered; the reader // starts when the track is subscribed (see kTrackSubscribed in onEvent). LK_LOG_DEBUG( - "Room::trySetOnVideoFrameCallback: track not yet subscribed for participant={} track_name={}; " + "Room::setOnVideoFrameCallback: track not yet subscribed for participant={} track_name={}; " "callback registered for deferred start", participant_identity, track_name); } - return true; } -bool Room::trySetOnVideoFrameEventCallback(const std::string& participant_identity, const std::string& track_name, - VideoFrameEventCallback callback, const VideoStream::Options& opts) { +void Room::setOnVideoFrameEventCallback(const std::string& participant_identity, const std::string& track_name, + VideoFrameEventCallback callback, const VideoStream::Options& opts) { if (!subscription_thread_dispatcher_) { - LK_LOG_ERROR("Room::trySetOnVideoFrameEventCallback: subscription_thread_dispatcher_ is nullptr"); - return false; - } - if (!subscription_thread_dispatcher_->trySetOnVideoFrameEventCallback(participant_identity, track_name, - std::move(callback), opts)) { - return false; + LK_LOG_ERROR("Room::setOnVideoFrameEventCallback: subscription_thread_dispatcher_ is nullptr"); + return; } + subscription_thread_dispatcher_->setOnVideoFrameEventCallback(participant_identity, track_name, std::move(callback), + opts); // If we've already subscribed to the track, handle it immediately auto track = findSubscribedRemoteTrack(participant_identity, track_name); @@ -487,44 +479,10 @@ bool Room::trySetOnVideoFrameEventCallback(const std::string& participant_identi // The track is not subscribed yet. The callback is registered; the reader // starts when the track is subscribed (see kTrackSubscribed in onEvent). LK_LOG_DEBUG( - "Room::trySetOnVideoFrameEventCallback: track not yet subscribed for participant={} track_name={}; " + "Room::setOnVideoFrameEventCallback: track not yet subscribed for participant={} track_name={}; " "callback registered for deferred start", participant_identity, track_name); } - return true; -} - -void Room::setOnAudioFrameCallback(const std::string& participant_identity, const std::string& track_name, - AudioFrameCallback callback, const AudioStream::Options& opts) { - bool const result = trySetOnAudioFrameCallback(participant_identity, track_name, std::move(callback), opts); - if (!result) { - LK_LOG_ERROR( - "Room::setOnAudioFrameCallback: failed to set callback for participant={} track_name={}. This function is " - "deprecated, instead use trySetOnAudioFrameCallback", - participant_identity, track_name); - } -} - -void Room::setOnVideoFrameCallback(const std::string& participant_identity, const std::string& track_name, - VideoFrameCallback callback, const VideoStream::Options& opts) { - bool const result = trySetOnVideoFrameCallback(participant_identity, track_name, std::move(callback), opts); - if (!result) { - LK_LOG_ERROR( - "Room::setOnVideoFrameCallback: failed to set callback for participant={} track_name={}. This function is " - "deprecated, instead use trySetOnVideoFrameCallback", - participant_identity, track_name); - } -} - -void Room::setOnVideoFrameEventCallback(const std::string& participant_identity, const std::string& track_name, - VideoFrameEventCallback callback, const VideoStream::Options& opts) { - bool const result = trySetOnVideoFrameEventCallback(participant_identity, track_name, std::move(callback), opts); - if (!result) { - LK_LOG_ERROR( - "Room::setOnVideoFrameEventCallback: failed to set callback for participant={} track_name={}. This function is " - "deprecated, instead use trySetOnVideoFrameEventCallback", - participant_identity, track_name); - } } void Room::clearOnAudioFrameCallback(const std::string& participant_identity, const std::string& track_name) { diff --git a/src/subscription_thread_dispatcher.cpp b/src/subscription_thread_dispatcher.cpp index 4bf412a9..63b88c57 100644 --- a/src/subscription_thread_dispatcher.cpp +++ b/src/subscription_thread_dispatcher.cpp @@ -57,97 +57,91 @@ SubscriptionThreadDispatcher::~SubscriptionThreadDispatcher() { } // NOLINTEND(bugprone-exception-escape) -bool SubscriptionThreadDispatcher::trySetOnAudioFrameCallback(const std::string& participant_identity, - const std::string& track_name, - AudioFrameCallback callback, - const AudioStream::Options& opts) { - const CallbackKey key{participant_identity, track_name}; - const std::scoped_lock lock(lock_); - if (active_readers_.find(key) != active_readers_.end()) { - LK_LOG_WARN( - "Cannot register audio frame callback for participant={} track_name={} " - "because a reader is already active; call clearOnAudioFrameCallback() first", - participant_identity, track_name); - return false; +void SubscriptionThreadDispatcher::disposeMediaReaderThread(std::thread&& thread, const char* operation) { + if (!thread.joinable()) { + return; } - const bool replacing = audio_callbacks_.find(key) != audio_callbacks_.end(); - audio_callbacks_[key] = RegisteredAudioCallback{std::move(callback), opts}; - LK_LOG_DEBUG( - "Registered audio frame callback for participant={} track_name={} " - "replacing_existing={} total_audio_callbacks={}", - participant_identity, track_name, replacing, audio_callbacks_.size()); - return true; -} - -bool SubscriptionThreadDispatcher::trySetOnVideoFrameEventCallback(const std::string& participant_identity, - const std::string& track_name, - VideoFrameEventCallback callback, - const VideoStream::Options& opts) { - const CallbackKey key{participant_identity, track_name}; - const std::scoped_lock lock(lock_); - if (active_readers_.find(key) != active_readers_.end()) { - LK_LOG_WARN( - "Cannot register video frame event callback for participant={} track_name={} " - "because a reader is already active; call clearOnVideoFrameCallback() first", - participant_identity, track_name); - return false; - } - const bool replacing = video_callbacks_.find(key) != video_callbacks_.end(); - video_callbacks_[key] = RegisteredVideoCallback{ - VideoFrameCallback{}, - std::move(callback), - opts, - }; - LK_LOG_DEBUG( - "Registered video frame event callback for participant={} track_name={} " - "replacing_existing={} total_video_callbacks={}", - participant_identity, track_name, replacing, video_callbacks_.size()); - return true; -} - -bool SubscriptionThreadDispatcher::trySetOnVideoFrameCallback(const std::string& participant_identity, - const std::string& track_name, - VideoFrameCallback callback, - const VideoStream::Options& opts) { - const CallbackKey key{participant_identity, track_name}; - const std::scoped_lock lock(lock_); - if (active_readers_.find(key) != active_readers_.end()) { - LK_LOG_WARN( - "Cannot register video frame callback for participant={} track_name={} " - "because a reader is already active; call clearOnVideoFrameCallback() first", - participant_identity, track_name); - return false; - } - const bool replacing = video_callbacks_.find(key) != video_callbacks_.end(); - video_callbacks_[key] = RegisteredVideoCallback{ - std::move(callback), - VideoFrameEventCallback{}, - opts, - }; - LK_LOG_DEBUG( - "Registered video frame callback for participant={} track_name={} " - "replacing_existing={} total_video_callbacks={}", - participant_identity, track_name, replacing, video_callbacks_.size()); - return true; + if (isSelfThread(thread.get_id())) { + // The caller IS this reader, so it called us from inside its own frame + // callback. Joining here would be a self-join. Detaching is safe: audio and + // video reader lambdas capture no `this` and own their stream and callback + // by value, so the thread touches nothing owned by the dispatcher once it + // has been extracted. + LK_LOG_ERROR( + "{} was called from inside its own frame callback; detaching the reader " + "instead of self-joining. Registering or clearing a callback from within " + "that callback is not supported", + operation); + thread.detach(); + return; + } + thread.join(); } void SubscriptionThreadDispatcher::setOnAudioFrameCallback(const std::string& participant_identity, const std::string& track_name, AudioFrameCallback callback, const AudioStream::Options& opts) { - (void)trySetOnAudioFrameCallback(participant_identity, track_name, std::move(callback), opts); + const CallbackKey key{participant_identity, track_name}; + std::thread old_thread; + { + const std::scoped_lock lock(lock_); + // Stop any reader still dispatching to the previous callback. Reader threads + // hold their own copy of the callback, so overwriting the registration alone + // would leave the old callback receiving frames. + old_thread = extractReaderThreadLocked(key); + const bool replacing = audio_callbacks_.find(key) != audio_callbacks_.end(); + audio_callbacks_[key] = RegisteredAudioCallback{std::move(callback), opts}; + LK_LOG_DEBUG( + "Registered audio frame callback for participant={} track_name={} " + "replacing_existing={} stopped_reader={} total_audio_callbacks={}", + participant_identity, track_name, replacing, old_thread.joinable(), audio_callbacks_.size()); + } + disposeMediaReaderThread(std::move(old_thread), "setOnAudioFrameCallback"); } void SubscriptionThreadDispatcher::setOnVideoFrameEventCallback(const std::string& participant_identity, const std::string& track_name, VideoFrameEventCallback callback, const VideoStream::Options& opts) { - (void)trySetOnVideoFrameEventCallback(participant_identity, track_name, std::move(callback), opts); + const CallbackKey key{participant_identity, track_name}; + std::thread old_thread; + { + const std::scoped_lock lock(lock_); + old_thread = extractReaderThreadLocked(key); + const bool replacing = video_callbacks_.find(key) != video_callbacks_.end(); + video_callbacks_[key] = RegisteredVideoCallback{ + VideoFrameCallback{}, + std::move(callback), + opts, + }; + LK_LOG_DEBUG( + "Registered video frame event callback for participant={} track_name={} " + "replacing_existing={} stopped_reader={} total_video_callbacks={}", + participant_identity, track_name, replacing, old_thread.joinable(), video_callbacks_.size()); + } + disposeMediaReaderThread(std::move(old_thread), "setOnVideoFrameEventCallback"); } void SubscriptionThreadDispatcher::setOnVideoFrameCallback(const std::string& participant_identity, const std::string& track_name, VideoFrameCallback callback, const VideoStream::Options& opts) { - (void)trySetOnVideoFrameCallback(participant_identity, track_name, std::move(callback), opts); + const CallbackKey key{participant_identity, track_name}; + std::thread old_thread; + { + const std::scoped_lock lock(lock_); + old_thread = extractReaderThreadLocked(key); + const bool replacing = video_callbacks_.find(key) != video_callbacks_.end(); + video_callbacks_[key] = RegisteredVideoCallback{ + std::move(callback), + VideoFrameEventCallback{}, + opts, + }; + LK_LOG_DEBUG( + "Registered video frame callback for participant={} track_name={} " + "replacing_existing={} stopped_reader={} total_video_callbacks={}", + participant_identity, track_name, replacing, old_thread.joinable(), video_callbacks_.size()); + } + disposeMediaReaderThread(std::move(old_thread), "setOnVideoFrameCallback"); } void SubscriptionThreadDispatcher::clearOnAudioFrameCallback(const std::string& participant_identity, @@ -164,9 +158,7 @@ void SubscriptionThreadDispatcher::clearOnAudioFrameCallback(const std::string& "removed_callback={} stopped_reader={} remaining_audio_callbacks={}", participant_identity, track_name, removed_callback, old_thread.joinable(), audio_callbacks_.size()); } - if (old_thread.joinable()) { - old_thread.join(); - } + disposeMediaReaderThread(std::move(old_thread), "clearOnAudioFrameCallback"); } void SubscriptionThreadDispatcher::clearOnVideoFrameCallback(const std::string& participant_identity, @@ -183,9 +175,7 @@ void SubscriptionThreadDispatcher::clearOnVideoFrameCallback(const std::string& "removed_callback={} stopped_reader={} remaining_video_callbacks={}", participant_identity, track_name, removed_callback, old_thread.joinable(), video_callbacks_.size()); } - if (old_thread.joinable()) { - old_thread.join(); - } + disposeMediaReaderThread(std::move(old_thread), "clearOnVideoFrameCallback"); } void SubscriptionThreadDispatcher::handleTrackSubscribed(const std::string& participant_identity, @@ -206,9 +196,7 @@ void SubscriptionThreadDispatcher::handleTrackSubscribed(const std::string& part const std::scoped_lock lock(lock_); old_thread = startReaderLocked(key, track); } - if (old_thread.joinable()) { - old_thread.join(); - } + disposeMediaReaderThread(std::move(old_thread), "handleTrackSubscribed"); } void SubscriptionThreadDispatcher::handleTrackUnsubscribed(const std::string& participant_identity, TrackSource source, @@ -223,9 +211,7 @@ void SubscriptionThreadDispatcher::handleTrackUnsubscribed(const std::string& pa "track_name={} stopped_reader={}", participant_identity, static_cast(source), track_name, old_thread.joinable()); } - if (old_thread.joinable()) { - old_thread.join(); - } + disposeMediaReaderThread(std::move(old_thread), "handleTrackUnsubscribed"); } // ------------------------------------------------------------------- @@ -312,6 +298,19 @@ void SubscriptionThreadDispatcher::handleDataTrackUnpublished(const std::string& reader->stream->close(); } } + if (isSelfThread(reader->thread_id)) { + // Reached from inside this reader's own data frame callback. It is now + // cancelled and its stream is closed, so it will exit on its own; leave + // the slot for stopAll() to reap rather than self-joining. Data readers + // cannot be detached -- they re-enter the dispatcher on the way out. + LK_LOG_ERROR( + "Data reader for callback id={} reached handleDataTrackUnpublished " + "from inside its own data frame callback; leaving the reader in " + "place to exit on its own", + it->first); + ++it; + continue; + } if (reader->thread.joinable()) { old_threads.push_back(std::move(reader->thread)); } @@ -333,7 +332,10 @@ void SubscriptionThreadDispatcher::handleDataTrackUnpublished(const std::string& } void SubscriptionThreadDispatcher::stopAll() { - std::vector threads; + // Media and data reader threads are disposed of differently: media threads may + // be safely detached on a self-join, data threads may not. + std::vector media_threads; + std::vector data_threads; { const std::scoped_lock lock(lock_); LK_LOG_DEBUG( @@ -351,7 +353,7 @@ void SubscriptionThreadDispatcher::stopAll() { reader.video_stream->close(); } if (reader.thread.joinable()) { - threads.push_back(std::move(reader.thread)); + media_threads.push_back(std::move(reader.thread)); } } active_readers_.clear(); @@ -368,17 +370,23 @@ void SubscriptionThreadDispatcher::stopAll() { } } if (reader->thread.joinable()) { - threads.push_back(std::move(reader->thread)); + data_threads.push_back(std::move(reader->thread)); } } active_data_readers_.clear(); data_callbacks_.clear(); remote_data_tracks_.clear(); } - for (auto& thread : threads) { + for (auto& thread : media_threads) { + disposeMediaReaderThread(std::move(thread), "stopAll"); + } + // Data reader threads re-enter the dispatcher after their callback returns, so + // they must be joined even here. Tearing the room down from inside a data + // frame callback is unsupported and will self-join. + for (auto& thread : data_threads) { thread.join(); } - LK_LOG_DEBUG("Stopped {} subscription reader threads", threads.size()); + LK_LOG_DEBUG("Stopped {} subscription reader threads", media_threads.size() + data_threads.size()); } std::thread SubscriptionThreadDispatcher::extractReaderThreadLocked(const CallbackKey& key) { @@ -502,6 +510,7 @@ std::thread SubscriptionThreadDispatcher::startAudioReaderLocked(const CallbackK } }); // NOLINTEND(bugprone-lambda-function-name,bugprone-exception-escape) + reader.thread_id = reader.thread.get_id(); active_readers_[key] = std::move(reader); LK_LOG_DEBUG( "Started audio reader for participant={} track_name={} " @@ -574,6 +583,7 @@ std::thread SubscriptionThreadDispatcher::startVideoReaderLocked(const CallbackK } }); // NOLINTEND(bugprone-lambda-function-name,bugprone-exception-escape) + reader.thread_id = reader.thread.get_id(); active_readers_[key] = std::move(reader); LK_LOG_DEBUG( "Started video reader for participant={} track_name={} " @@ -591,6 +601,19 @@ std::thread SubscriptionThreadDispatcher::extractDataReaderThreadLocked(DataFram if (it == active_data_readers_.end()) { return {}; } + if (it->second && isSelfThread(it->second->thread_id)) { + // The caller IS this reader, so it reached us from inside its own data frame + // callback. Joining would be a self-join, and unlike media readers a data + // reader cannot be detached: it re-enters the dispatcher after the callback + // returns. Leave the slot in place -- the reader exits on its own once its + // stream closes, and stopAll() reaps it. + LK_LOG_ERROR( + "Data reader for callback id={} tried to tear itself down from inside its " + "own data frame callback; leaving the reader in place. Removing a data " + "callback from within that callback is not supported", + id); + return {}; + } auto reader = std::move(it->second); active_data_readers_.erase(it); // Mark cancelled before closing to guard in flight subscriptions @@ -708,6 +731,7 @@ std::thread SubscriptionThreadDispatcher::startDataReaderLocked(DataFrameCallbac LK_LOG_INFO("Data reader thread exiting for \"{}\" track=\"{}\"", identity, track_name); }); // NOLINTEND(bugprone-lambda-function-name) + reader->thread_id = reader->thread.get_id(); active_data_readers_[id] = reader; return old_thread; } diff --git a/src/tests/common/room_test_access.h b/src/tests/common/room_test_access.h new file mode 100644 index 00000000..0387b362 --- /dev/null +++ b/src/tests/common/room_test_access.h @@ -0,0 +1,84 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// @file room_test_access.h +/// @brief In-tree test access to Room internals. +/// +/// Room declares this struct a friend, as does SubscriptionThreadDispatcher, so +/// tests can inspect state that is deliberately not part of the public API. + +#pragma once + +#include +#include + +#include +#include +#include + +#include "ffi.pb.h" +#include "ffi_client.h" + +namespace livekit { + +struct RoomTestAccess { + static void installConnectedListener(Room& room, std::atomic& callback_count) { + const auto listener_id = FfiClient::instance().addListener([&room, &callback_count](const proto::FfiEvent& event) { + callback_count.fetch_add(1, std::memory_order_relaxed); + room.onEvent(event); + }); + + const std::scoped_lock guard(room.lock_); + room.connection_state_ = ConnectionState::Connected; + room.room_handle_ = std::make_shared(); + room.listener_id_ = listener_id; + } + + static bool hasRoomHandle(const Room& room) { + const std::scoped_lock guard(room.lock_); + return static_cast(room.room_handle_); + } + + static int listenerId(const Room& room) { + const std::scoped_lock guard(room.lock_); + return room.listener_id_; + } + + /// Number of live audio/video reader threads owned by the room's dispatcher. + /// + /// Used by integration tests to prove that replacing a frame callback stops + /// the previous reader rather than leaking one per registration. + static std::size_t activeReaderCount(const Room& room) { + const auto& dispatcher = room.subscription_thread_dispatcher_; + if (!dispatcher) { + return 0; + } + const std::scoped_lock guard(dispatcher->lock_); + return dispatcher->active_readers_.size(); + } + + /// Number of live data track reader threads owned by the room's dispatcher. + static std::size_t activeDataReaderCount(const Room& room) { + const auto& dispatcher = room.subscription_thread_dispatcher_; + if (!dispatcher) { + return 0; + } + const std::scoped_lock guard(dispatcher->lock_); + return dispatcher->active_data_readers_.size(); + } +}; + +} // namespace livekit diff --git a/src/tests/integration/test_frame_callback_replacement.cpp b/src/tests/integration/test_frame_callback_replacement.cpp new file mode 100644 index 00000000..ef8774b9 --- /dev/null +++ b/src/tests/integration/test_frame_callback_replacement.cpp @@ -0,0 +1,757 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// @file test_frame_callback_replacement.cpp +/// @brief End-to-end coverage for in-place frame callback replacement. +/// +/// Registering a frame callback again for the same (participant, track name) +/// replaces it in place: the previous reader is stopped and joined, then a fresh +/// reader is started bound to the new callback. Reader threads hold their own +/// copy of the callback, so without that teardown the old callback keeps +/// receiving frames -- a silent no-op these tests are designed to catch. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "tests/common/audio_utils.h" +#include "tests/common/room_test_access.h" +#include "tests/common/test_common.h" + +namespace livekit::test { + +using namespace std::chrono_literals; + +namespace { + +constexpr auto kSubscribeTimeout = 15s; +constexpr auto kFrameTimeout = 15s; +/// How long to watch a retired callback before concluding it has gone quiet. +constexpr auto kQuiescenceGracePeriod = 1s; +/// Upper bound on any call that must not deadlock. Generous enough to absorb a +/// slow join, tight enough that a real deadlock fails instead of hanging CI. +constexpr auto kNoDeadlockTimeout = 30s; + +constexpr int kFrameWidth = 16; +constexpr int kFrameHeight = 16; + +template +bool waitFor(Predicate predicate, std::chrono::milliseconds timeout) { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline) { + if (predicate()) { + return true; + } + std::this_thread::sleep_for(10ms); + } + return predicate(); +} + +/// Wait until @p room reports a subscribed track named @p track_name of @p kind +/// published by @p identity. +bool waitForSubscribedTrack(Room& room, const std::string& identity, const std::string& track_name, TrackKind kind, + std::chrono::milliseconds timeout) { + return waitFor( + [&]() { + auto participant = room.remoteParticipant(identity).lock(); + if (participant == nullptr) { + return false; + } + for (const auto& [sid, publication] : participant->trackPublications()) { + (void)sid; + if (publication == nullptr || publication->name() != track_name || publication->kind() != kind) { + continue; + } + if (publication->subscribed() && publication->track() != nullptr) { + return true; + } + } + return false; + }, + timeout); +} + +/// Drives a VideoSource on a background thread for the life of the object. +class VideoPublisher { +public: + explicit VideoPublisher(std::shared_ptr source) : source_(std::move(source)) { + thread_ = std::thread([this]() { + VideoFrame frame = VideoFrame::create(kFrameWidth, kFrameHeight, VideoBufferType::RGBA); + std::fill(frame.data(), frame.data() + frame.dataSize(), 0x7f); + while (running_.load(std::memory_order_relaxed)) { + try { + source_->captureFrame(frame); + } catch (...) { + break; + } + std::this_thread::sleep_for(20ms); + } + }); + } + + VideoPublisher(const VideoPublisher&) = delete; + VideoPublisher& operator=(const VideoPublisher&) = delete; + + void stop() { + running_.store(false, std::memory_order_relaxed); + if (thread_.joinable()) { + thread_.join(); + } + } + + ~VideoPublisher() { stop(); } + +private: + std::shared_ptr source_; + std::atomic running_{true}; + std::thread thread_; +}; + +/// Run @p action on a worker thread and fail if it does not return in time. +/// Used for calls that join a reader thread, where a regression shows up as a +/// hang rather than a wrong value. +[[nodiscard]] bool completesWithoutDeadlock(const std::function& action, + std::chrono::milliseconds timeout = kNoDeadlockTimeout) { + auto future = std::async(std::launch::async, action); + return future.wait_for(timeout) == std::future_status::ready; +} + +/// Two connected rooms plus one published video track, with the receiver +/// confirmed subscribed. Every video test starts from this state. +class VideoFixture { +public: + VideoFixture(const std::string& url, const std::string& token_a, const std::string& token_b, + std::string track_name = "replacement-track") + : track_name_(std::move(track_name)) { + const RoomOptions options; + connected_ = receiver_.connect(url, token_b, options) && sender_.connect(url, token_a, options); + if (!connected_) { + return; + } + if (sender_.localParticipant().expired() || receiver_.localParticipant().expired()) { + connected_ = false; + return; + } + sender_identity_ = lockLocalParticipant(sender_)->identity(); + } + + /// Publish the video track and wait for the receiver to subscribe. + bool publishAndAwaitSubscription() { + if (!connected_ || !waitForParticipant(&receiver_, sender_identity_, kSubscribeTimeout)) { + return false; + } + source_ = std::make_shared(kFrameWidth, kFrameHeight); + track_ = LocalVideoTrack::createLocalVideoTrack(track_name_, source_); + + TrackPublishOptions publish_options; + publish_options.source = TrackSource::SOURCE_CAMERA; + publish_options.simulcast = false; + lockLocalParticipant(sender_)->publishTrack(track_, publish_options); + + publisher_ = std::make_unique(source_); + return waitForSubscribedTrack(receiver_, sender_identity_, track_name_, TrackKind::KIND_VIDEO, kSubscribeTimeout); + } + + void unpublish() { + if (track_ && track_->publication()) { + lockLocalParticipant(sender_)->unpublishTrack(track_->publication()->sid()); + } + } + + void teardown() { + if (publisher_) { + publisher_->stop(); + } + receiver_.clearOnVideoFrameCallback(sender_identity_, track_name_); + unpublish(); + } + + ~VideoFixture() { + if (publisher_) { + publisher_->stop(); + } + } + + bool connected() const { return connected_; } + Room& receiver() { return receiver_; } + Room& sender() { return sender_; } + const std::string& senderIdentity() const { return sender_identity_; } + const std::string& trackName() const { return track_name_; } + const std::shared_ptr& source() const { return source_; } + +private: + Room sender_; + Room receiver_; + std::string track_name_; + std::string sender_identity_; + bool connected_ = false; + std::shared_ptr source_; + std::shared_ptr track_; + std::unique_ptr publisher_; +}; + +/// Assert that @p counter stops advancing, i.e. its callback has been retired. +[[nodiscard]] bool wentQuiet(const std::atomic& counter) { + const int before = counter.load(); + std::this_thread::sleep_for(kQuiescenceGracePeriod); + return counter.load() == before; +} + +} // namespace + +class FrameCallbackReplacementTest : public LiveKitTestBase {}; + +// ============================================================================ +// Core replacement +// ============================================================================ + +// The canonical regression: before the in-place replacement fix, the reader +// thread kept invoking its own copy of callback A forever and B never fired. +TEST_F(FrameCallbackReplacementTest, ReplacingActiveVideoCallbackSwitchesFrameDelivery) { + failIfNotConfigured(); + + VideoFixture fixture(config_.url, config_.token_a, config_.token_b); + ASSERT_TRUE(fixture.connected()); + + std::atomic a_frames{0}; + std::atomic b_frames{0}; + + fixture.receiver().setOnVideoFrameCallback(fixture.senderIdentity(), fixture.trackName(), + [&a_frames](const VideoFrame&, std::int64_t) { a_frames.fetch_add(1); }); + + ASSERT_TRUE(fixture.publishAndAwaitSubscription()); + ASSERT_TRUE(waitFor([&]() { return a_frames.load() > 0; }, kFrameTimeout)) << "First callback never received a frame"; + + fixture.receiver().setOnVideoFrameCallback(fixture.senderIdentity(), fixture.trackName(), + [&b_frames](const VideoFrame&, std::int64_t) { b_frames.fetch_add(1); }); + + EXPECT_TRUE(waitFor([&]() { return b_frames.load() > 0; }, kFrameTimeout)) + << "Replacement callback never received a frame"; + EXPECT_TRUE(wentQuiet(a_frames)) << "Replaced callback is still receiving frames; its reader was not stopped"; + EXPECT_EQ(RoomTestAccess::activeReaderCount(fixture.receiver()), 1u); + + fixture.teardown(); +} + +TEST_F(FrameCallbackReplacementTest, ReplacingActiveAudioCallbackSwitchesFrameDelivery) { + failIfNotConfigured(); + + Room sender_room; + Room receiver_room; + const RoomOptions options; + ASSERT_TRUE(receiver_room.connect(config_.url, config_.token_b, options)); + ASSERT_TRUE(sender_room.connect(config_.url, config_.token_a, options)); + + const std::string sender_identity = lockLocalParticipant(sender_room)->identity(); + ASSERT_TRUE(waitForParticipant(&receiver_room, sender_identity, kSubscribeTimeout)); + + const std::string track_name = "replacement-audio"; + std::atomic a_frames{0}; + std::atomic b_frames{0}; + + receiver_room.setOnAudioFrameCallback(sender_identity, track_name, [&a_frames](const AudioFrame& frame) { + if (frame.totalSamples() > 0) { + a_frames.fetch_add(1); + } + }); + + auto source = std::make_shared(kDefaultAudioSampleRate, kDefaultAudioChannels); + auto track = LocalAudioTrack::createLocalAudioTrack(track_name, source); + TrackPublishOptions publish_options; + publish_options.source = TrackSource::SOURCE_MICROPHONE; + lockLocalParticipant(sender_room)->publishTrack(track, publish_options); + + std::atomic publishing{true}; + std::thread publisher([&]() { runToneLoop(source, publishing, 440.0, /*siren_mode=*/false); }); + + ASSERT_TRUE( + waitForSubscribedTrack(receiver_room, sender_identity, track_name, TrackKind::KIND_AUDIO, kSubscribeTimeout)); + ASSERT_TRUE(waitFor([&]() { return a_frames.load() > 0; }, kFrameTimeout)) << "First callback never received a frame"; + + receiver_room.setOnAudioFrameCallback(sender_identity, track_name, [&b_frames](const AudioFrame& frame) { + if (frame.totalSamples() > 0) { + b_frames.fetch_add(1); + } + }); + + EXPECT_TRUE(waitFor([&]() { return b_frames.load() > 0; }, kFrameTimeout)) + << "Replacement callback never received a frame"; + EXPECT_TRUE(wentQuiet(a_frames)) << "Replaced callback is still receiving frames"; + EXPECT_EQ(RoomTestAccess::activeReaderCount(receiver_room), 1u); + + publishing.store(false); + publisher.join(); + receiver_room.clearOnAudioFrameCallback(sender_identity, track_name); + if (track->publication()) { + lockLocalParticipant(sender_room)->unpublishTrack(track->publication()->sid()); + } +} + +// The legacy and event video callbacks share one registration slot, so +// registering either must displace the other and stop its reader. +TEST_F(FrameCallbackReplacementTest, ReplacingVideoCallbackWithEventCallbackSwitchesDelivery) { + failIfNotConfigured(); + + VideoFixture fixture(config_.url, config_.token_a, config_.token_b); + ASSERT_TRUE(fixture.connected()); + + std::atomic legacy_frames{0}; + std::atomic event_frames{0}; + + fixture.receiver().setOnVideoFrameCallback( + fixture.senderIdentity(), fixture.trackName(), + [&legacy_frames](const VideoFrame&, std::int64_t) { legacy_frames.fetch_add(1); }); + + ASSERT_TRUE(fixture.publishAndAwaitSubscription()); + ASSERT_TRUE(waitFor([&]() { return legacy_frames.load() > 0; }, kFrameTimeout)); + + fixture.receiver().setOnVideoFrameEventCallback( + fixture.senderIdentity(), fixture.trackName(), + [&event_frames](const VideoFrameEvent&) { event_frames.fetch_add(1); }); + + EXPECT_TRUE(waitFor([&]() { return event_frames.load() > 0; }, kFrameTimeout)) + << "Event callback never received a frame after displacing the legacy callback"; + EXPECT_TRUE(wentQuiet(legacy_frames)) << "Displaced legacy callback is still receiving frames"; + EXPECT_EQ(RoomTestAccess::activeReaderCount(fixture.receiver()), 1u); + + fixture.teardown(); +} + +// ============================================================================ +// Long-running callbacks +// ============================================================================ + +// Replacement joins the previous reader, so it must wait out an in-flight +// callback invocation rather than abandoning it mid-frame. This is the guarantee +// the API documents: when the setter returns, the old callback has finished. +TEST_F(FrameCallbackReplacementTest, SetOnVideoFrameCallbackBlocksUntilSlowCallbackReturns) { + failIfNotConfigured(); + + VideoFixture fixture(config_.url, config_.token_a, config_.token_b); + ASSERT_TRUE(fixture.connected()); + + constexpr auto kSlowCallbackDuration = 2s; + std::atomic slow_entered{false}; + std::atomic slow_exited{false}; + std::atomic fast_frames{0}; + + fixture.receiver().setOnVideoFrameCallback(fixture.senderIdentity(), fixture.trackName(), + [&](const VideoFrame&, std::int64_t) { + slow_entered.store(true); + std::this_thread::sleep_for(kSlowCallbackDuration); + slow_exited.store(true); + }); + + ASSERT_TRUE(fixture.publishAndAwaitSubscription()); + ASSERT_TRUE(waitFor([&]() { return slow_entered.load(); }, kFrameTimeout)) + << "Slow callback never started an invocation"; + ASSERT_FALSE(slow_exited.load()) << "Slow callback finished before the replacement was attempted"; + + const auto started_at = std::chrono::steady_clock::now(); + bool exited_before_return = false; + const bool completed = completesWithoutDeadlock([&]() { + fixture.receiver().setOnVideoFrameCallback( + fixture.senderIdentity(), fixture.trackName(), + [&fast_frames](const VideoFrame&, std::int64_t) { fast_frames.fetch_add(1); }); + exited_before_return = slow_exited.load(); + }); + const auto elapsed = std::chrono::steady_clock::now() - started_at; + + ASSERT_TRUE(completed) << "setOnVideoFrameCallback did not return; the join deadlocked"; + EXPECT_TRUE(exited_before_return) << "Setter returned while the previous callback was still executing"; + EXPECT_GE(elapsed, 500ms) << "Setter returned too quickly to have waited on the in-flight callback"; + + EXPECT_TRUE(waitFor([&]() { return fast_frames.load() > 0; }, kFrameTimeout)); + EXPECT_EQ(RoomTestAccess::activeReaderCount(fixture.receiver()), 1u); + + fixture.teardown(); +} + +// The join happens outside the dispatcher lock, so a slow callback on one +// subscription must not stall readers for other subscriptions. +TEST_F(FrameCallbackReplacementTest, SlowCallbackDoesNotStallOtherSubscriptionReaders) { + failIfNotConfigured(); + + Room sender_room; + Room receiver_room; + const RoomOptions options; + ASSERT_TRUE(receiver_room.connect(config_.url, config_.token_b, options)); + ASSERT_TRUE(sender_room.connect(config_.url, config_.token_a, options)); + + const std::string sender_identity = lockLocalParticipant(sender_room)->identity(); + ASSERT_TRUE(waitForParticipant(&receiver_room, sender_identity, kSubscribeTimeout)); + + const std::string slow_track_name = "slow-track"; + const std::string fast_track_name = "fast-track"; + + std::atomic slow_invocations{0}; + std::atomic fast_frames{0}; + + receiver_room.setOnVideoFrameCallback(sender_identity, slow_track_name, [&](const VideoFrame&, std::int64_t) { + slow_invocations.fetch_add(1); + std::this_thread::sleep_for(2s); + }); + receiver_room.setOnVideoFrameCallback(sender_identity, fast_track_name, + [&fast_frames](const VideoFrame&, std::int64_t) { fast_frames.fetch_add(1); }); + + auto slow_source = std::make_shared(kFrameWidth, kFrameHeight); + auto fast_source = std::make_shared(kFrameWidth, kFrameHeight); + auto slow_track = LocalVideoTrack::createLocalVideoTrack(slow_track_name, slow_source); + auto fast_track = LocalVideoTrack::createLocalVideoTrack(fast_track_name, fast_source); + + TrackPublishOptions publish_options; + publish_options.source = TrackSource::SOURCE_CAMERA; + publish_options.simulcast = false; + lockLocalParticipant(sender_room)->publishTrack(slow_track, publish_options); + lockLocalParticipant(sender_room)->publishTrack(fast_track, publish_options); + + VideoPublisher slow_publisher(slow_source); + VideoPublisher fast_publisher(fast_source); + + ASSERT_TRUE(waitForSubscribedTrack(receiver_room, sender_identity, slow_track_name, TrackKind::KIND_VIDEO, + kSubscribeTimeout)); + ASSERT_TRUE(waitForSubscribedTrack(receiver_room, sender_identity, fast_track_name, TrackKind::KIND_VIDEO, + kSubscribeTimeout)); + ASSERT_TRUE(waitFor([&]() { return slow_invocations.load() > 0; }, kFrameTimeout)); + + const int fast_before = fast_frames.load(); + const bool completed = completesWithoutDeadlock([&]() { + receiver_room.setOnVideoFrameCallback(sender_identity, slow_track_name, [](const VideoFrame&, std::int64_t) {}); + }); + ASSERT_TRUE(completed) << "Replacing the slow callback deadlocked"; + + EXPECT_GT(fast_frames.load(), fast_before) + << "The unrelated fast reader stalled while the slow callback was being replaced"; + EXPECT_EQ(RoomTestAccess::activeReaderCount(receiver_room), 2u); + + slow_publisher.stop(); + fast_publisher.stop(); + receiver_room.clearOnVideoFrameCallback(sender_identity, slow_track_name); + receiver_room.clearOnVideoFrameCallback(sender_identity, fast_track_name); + if (slow_track->publication()) { + lockLocalParticipant(sender_room)->unpublishTrack(slow_track->publication()->sid()); + } + if (fast_track->publication()) { + lockLocalParticipant(sender_room)->unpublishTrack(fast_track->publication()->sid()); + } +} + +// ============================================================================ +// Multiple and concurrent calls +// ============================================================================ + +// Each replacement must stop exactly one reader and start exactly one. A leak +// shows up as a growing reader count or as several generations firing at once. +TEST_F(FrameCallbackReplacementTest, RepeatedReplacementUnderLoadLeavesExactlyOneActiveReader) { + failIfNotConfigured(); + + constexpr int kGenerations = 20; + + VideoFixture fixture(config_.url, config_.token_a, config_.token_b); + ASSERT_TRUE(fixture.connected()); + + std::vector>> counters; + counters.reserve(kGenerations); + for (int i = 0; i < kGenerations; ++i) { + counters.push_back(std::make_unique>(0)); + } + + auto* first = counters.front().get(); + fixture.receiver().setOnVideoFrameCallback(fixture.senderIdentity(), fixture.trackName(), + [first](const VideoFrame&, std::int64_t) { first->fetch_add(1); }); + + ASSERT_TRUE(fixture.publishAndAwaitSubscription()); + ASSERT_TRUE(waitFor([&]() { return first->load() > 0; }, kFrameTimeout)); + + for (int i = 1; i < kGenerations; ++i) { + auto* counter = counters[static_cast(i)].get(); + const bool completed = completesWithoutDeadlock([&]() { + fixture.receiver().setOnVideoFrameCallback(fixture.senderIdentity(), fixture.trackName(), + [counter](const VideoFrame&, std::int64_t) { counter->fetch_add(1); }); + }); + ASSERT_TRUE(completed) << "Replacement " << i << " deadlocked"; + ASSERT_LE(RoomTestAccess::activeReaderCount(fixture.receiver()), 1u) + << "Reader count grew during replacement " << i; + std::this_thread::sleep_for(50ms); + } + + auto* last = counters.back().get(); + EXPECT_TRUE(waitFor([&]() { return last->load() > 0; }, kFrameTimeout)) + << "The final callback generation never received a frame"; + EXPECT_EQ(RoomTestAccess::activeReaderCount(fixture.receiver()), 1u); + + // Every earlier generation must be retired: snapshot all of them, wait, and + // confirm none advanced. + std::vector before; + before.reserve(counters.size()); + for (const auto& counter : counters) { + before.push_back(counter->load()); + } + std::this_thread::sleep_for(kQuiescenceGracePeriod); + for (std::size_t i = 0; i + 1 < counters.size(); ++i) { + EXPECT_EQ(counters[i]->load(), before[i]) << "Retired callback generation " << i << " is still receiving frames"; + } + + fixture.teardown(); +} + +// Concurrent replacements must serialize on the dispatcher lock without +// deadlocking, double-starting readers, or losing the final registration. +TEST_F(FrameCallbackReplacementTest, ConcurrentReplacementFromMultipleThreadsIsSerialized) { + failIfNotConfigured(); + + constexpr int kThreads = 4; + constexpr auto kChurnDuration = 2s; + + VideoFixture fixture(config_.url, config_.token_a, config_.token_b); + ASSERT_TRUE(fixture.connected()); + + std::atomic initial_frames{0}; + fixture.receiver().setOnVideoFrameCallback( + fixture.senderIdentity(), fixture.trackName(), + [&initial_frames](const VideoFrame&, std::int64_t) { initial_frames.fetch_add(1); }); + + ASSERT_TRUE(fixture.publishAndAwaitSubscription()); + ASSERT_TRUE(waitFor([&]() { return initial_frames.load() > 0; }, kFrameTimeout)); + + std::atomic churning{true}; + std::atomic max_readers_seen{0}; + std::vector workers; + workers.reserve(kThreads); + for (int i = 0; i < kThreads; ++i) { + workers.emplace_back([&]() { + while (churning.load(std::memory_order_relaxed)) { + fixture.receiver().setOnVideoFrameCallback(fixture.senderIdentity(), fixture.trackName(), + [](const VideoFrame&, std::int64_t) {}); + const auto readers = static_cast(RoomTestAccess::activeReaderCount(fixture.receiver())); + int previous = max_readers_seen.load(); + while (readers > previous && !max_readers_seen.compare_exchange_weak(previous, readers)) { + } + std::this_thread::sleep_for(10ms); + } + }); + } + + auto churn_done = std::async(std::launch::async, [&]() { + std::this_thread::sleep_for(kChurnDuration); + churning.store(false, std::memory_order_relaxed); + for (auto& worker : workers) { + worker.join(); + } + }); + ASSERT_EQ(churn_done.wait_for(kNoDeadlockTimeout), std::future_status::ready) << "Concurrent replacement deadlocked"; + + EXPECT_LE(max_readers_seen.load(), 1) << "Concurrent replacements started more than one reader for the same key"; + + // The registration surviving the churn must still deliver frames. + std::atomic final_frames{0}; + fixture.receiver().setOnVideoFrameCallback( + fixture.senderIdentity(), fixture.trackName(), + [&final_frames](const VideoFrame&, std::int64_t) { final_frames.fetch_add(1); }); + EXPECT_TRUE(waitFor([&]() { return final_frames.load() > 0; }, kFrameTimeout)) + << "Frame delivery did not recover after concurrent replacement"; + EXPECT_EQ(RoomTestAccess::activeReaderCount(fixture.receiver()), 1u); + + fixture.teardown(); +} + +// Deferred start: with no subscription yet there is no reader to stop, and the +// newest registration is the one the reader must bind when the track arrives. +TEST_F(FrameCallbackReplacementTest, ReplacingCallbackBeforeSubscriptionUsesNewestCallback) { + failIfNotConfigured(); + + VideoFixture fixture(config_.url, config_.token_a, config_.token_b); + ASSERT_TRUE(fixture.connected()); + + std::atomic a_frames{0}; + std::atomic b_frames{0}; + + fixture.receiver().setOnVideoFrameCallback(fixture.senderIdentity(), fixture.trackName(), + [&a_frames](const VideoFrame&, std::int64_t) { a_frames.fetch_add(1); }); + fixture.receiver().setOnVideoFrameCallback(fixture.senderIdentity(), fixture.trackName(), + [&b_frames](const VideoFrame&, std::int64_t) { b_frames.fetch_add(1); }); + ASSERT_EQ(RoomTestAccess::activeReaderCount(fixture.receiver()), 0u) << "No reader should exist before subscription"; + + ASSERT_TRUE(fixture.publishAndAwaitSubscription()); + + EXPECT_TRUE(waitFor([&]() { return b_frames.load() > 0; }, kFrameTimeout)) + << "The newest pre-subscription callback never received a frame"; + EXPECT_EQ(a_frames.load(), 0) << "The overwritten pre-subscription callback must never fire"; + EXPECT_EQ(RoomTestAccess::activeReaderCount(fixture.receiver()), 1u); + + fixture.teardown(); +} + +// Replacement while unsubscribed must survive the resubscribe: the registration +// persists across unpublish, and the new callback binds on republish. +TEST_F(FrameCallbackReplacementTest, ReplacementSurvivesUnpublishAndRepublish) { + failIfNotConfigured(); + + VideoFixture fixture(config_.url, config_.token_a, config_.token_b); + ASSERT_TRUE(fixture.connected()); + + std::atomic a_frames{0}; + std::atomic b_frames{0}; + + fixture.receiver().setOnVideoFrameCallback(fixture.senderIdentity(), fixture.trackName(), + [&a_frames](const VideoFrame&, std::int64_t) { a_frames.fetch_add(1); }); + + ASSERT_TRUE(fixture.publishAndAwaitSubscription()); + ASSERT_TRUE(waitFor([&]() { return a_frames.load() > 0; }, kFrameTimeout)); + + fixture.unpublish(); + ASSERT_TRUE(waitFor([&]() { return RoomTestAccess::activeReaderCount(fixture.receiver()) == 0u; }, kSubscribeTimeout)) + << "Reader was not torn down on unpublish"; + + fixture.receiver().setOnVideoFrameCallback(fixture.senderIdentity(), fixture.trackName(), + [&b_frames](const VideoFrame&, std::int64_t) { b_frames.fetch_add(1); }); + + ASSERT_TRUE(fixture.publishAndAwaitSubscription()) << "Republished track was never subscribed"; + EXPECT_TRUE(waitFor([&]() { return b_frames.load() > 0; }, kFrameTimeout)) + << "Replacement callback never received a frame from the new publication"; + EXPECT_TRUE(wentQuiet(a_frames)) << "Replaced callback is still receiving frames after republish"; + EXPECT_EQ(RoomTestAccess::activeReaderCount(fixture.receiver()), 1u); + + fixture.teardown(); +} + +// ============================================================================ +// Re-entrancy +// ============================================================================ + +// Registering from inside the frame callback would make the join a self-join. +// Media readers are detached instead, so the call must return rather than hang. +TEST_F(FrameCallbackReplacementTest, SetOnVideoFrameCallbackFromInsideCallbackDoesNotDeadlock) { + failIfNotConfigured(); + + VideoFixture fixture(config_.url, config_.token_a, config_.token_b); + ASSERT_TRUE(fixture.connected()); + + std::atomic reentrant_call_returned{false}; + std::atomic attempted{false}; + std::atomic replacement_frames{0}; + + fixture.receiver().setOnVideoFrameCallback( + fixture.senderIdentity(), fixture.trackName(), [&](const VideoFrame&, std::int64_t) { + if (attempted.exchange(true)) { + return; + } + fixture.receiver().setOnVideoFrameCallback( + fixture.senderIdentity(), fixture.trackName(), + [&replacement_frames](const VideoFrame&, std::int64_t) { replacement_frames.fetch_add(1); }); + reentrant_call_returned.store(true); + }); + + ASSERT_TRUE(fixture.publishAndAwaitSubscription()); + EXPECT_TRUE(waitFor([&]() { return reentrant_call_returned.load(); }, kNoDeadlockTimeout)) + << "Re-entrant setOnVideoFrameCallback never returned; the reader self-joined"; + + // The detached reader exits on its own, and teardown must still complete. + const bool torn_down = completesWithoutDeadlock([&]() { fixture.teardown(); }); + EXPECT_TRUE(torn_down) << "Teardown deadlocked after a re-entrant registration"; +} + +TEST_F(FrameCallbackReplacementTest, ClearOnVideoFrameCallbackFromInsideCallbackDoesNotDeadlock) { + failIfNotConfigured(); + + VideoFixture fixture(config_.url, config_.token_a, config_.token_b); + ASSERT_TRUE(fixture.connected()); + + std::atomic reentrant_call_returned{false}; + std::atomic attempted{false}; + + fixture.receiver().setOnVideoFrameCallback( + fixture.senderIdentity(), fixture.trackName(), [&](const VideoFrame&, std::int64_t) { + if (attempted.exchange(true)) { + return; + } + fixture.receiver().clearOnVideoFrameCallback(fixture.senderIdentity(), fixture.trackName()); + reentrant_call_returned.store(true); + }); + + ASSERT_TRUE(fixture.publishAndAwaitSubscription()); + EXPECT_TRUE(waitFor([&]() { return reentrant_call_returned.load(); }, kNoDeadlockTimeout)) + << "Re-entrant clearOnVideoFrameCallback never returned; the reader self-joined"; + + const bool torn_down = completesWithoutDeadlock([&]() { fixture.teardown(); }); + EXPECT_TRUE(torn_down) << "Teardown deadlocked after a re-entrant clear"; +} + +// Data readers re-enter the dispatcher after their callback returns, so they +// cannot be detached. The re-entrant removal is refused and the reader is left +// for teardown to reap -- which must still join cleanly. +TEST_F(FrameCallbackReplacementTest, RemoveDataCallbackFromInsideDataCallbackIsRefusedWithoutDeadlock) { + failIfNotConfigured(); + + Room sender_room; + Room receiver_room; + const RoomOptions options; + ASSERT_TRUE(receiver_room.connect(config_.url, config_.token_b, options)); + ASSERT_TRUE(sender_room.connect(config_.url, config_.token_a, options)); + + const std::string sender_identity = lockLocalParticipant(sender_room)->identity(); + ASSERT_TRUE(waitForParticipant(&receiver_room, sender_identity, kSubscribeTimeout)); + + const std::string track_name = "reentrant-data"; + std::atomic reentrant_call_returned{false}; + std::atomic attempted{false}; + DataFrameCallbackId callback_id = 0; + + callback_id = receiver_room.addOnDataFrameCallback( + sender_identity, track_name, [&](const std::vector&, std::optional) { + if (attempted.exchange(true)) { + return; + } + receiver_room.removeOnDataFrameCallback(callback_id); + reentrant_call_returned.store(true); + }); + + auto publish_result = lockLocalParticipant(sender_room)->publishDataTrack(track_name); + ASSERT_TRUE(publish_result) << "Failed to publish data track"; + auto local_track = publish_result.value(); + + std::atomic pushing{true}; + std::thread pusher([&]() { + DataTrackFrame frame; + frame.payload.assign(32, 0x5A); + while (pushing.load(std::memory_order_relaxed)) { + (void)local_track->tryPush(frame); + std::this_thread::sleep_for(50ms); + } + }); + + EXPECT_TRUE(waitFor([&]() { return reentrant_call_returned.load(); }, kNoDeadlockTimeout)) + << "Re-entrant removeOnDataFrameCallback never returned; the data reader self-joined"; + + pushing.store(false, std::memory_order_relaxed); + pusher.join(); + + // The refused removal left the reader in place; disconnect must still reap it. + const bool disconnected = completesWithoutDeadlock([&]() { + local_track->unpublishDataTrack(); + receiver_room.disconnect(); + }); + EXPECT_TRUE(disconnected) << "Disconnect deadlocked while reaping the refused data reader"; +} + +} // namespace livekit::test diff --git a/src/tests/integration/test_platform_audio.cpp b/src/tests/integration/test_platform_audio.cpp index 647956c3..c6e86596 100644 --- a/src/tests/integration/test_platform_audio.cpp +++ b/src/tests/integration/test_platform_audio.cpp @@ -287,18 +287,16 @@ TEST_F(PlatformAudioIntegrationTest, PlatformAudioFramesReachRemote) { // The reader thread is only started when the subscription event fires and a // matching callback is already registered, so register before publishing. - const bool audio_callback_registered = - receiver_room->trySetOnAudioFrameCallback(sender_identity, track_name, [&](const AudioFrame& frame) { - if (frame.totalSamples() == 0) { - return; - } - { - std::lock_guard lock(frame_mutex); - ++received_frames; - } - frame_cv.notify_all(); - }); - ASSERT_TRUE(audio_callback_registered); + receiver_room->setOnAudioFrameCallback(sender_identity, track_name, [&](const AudioFrame& frame) { + if (frame.totalSamples() == 0) { + return; + } + { + std::lock_guard lock(frame_mutex); + ++received_frames; + } + frame_cv.notify_all(); + }); TrackPublishOptions publish_options; publish_options.source = TrackSource::SOURCE_MICROPHONE; diff --git a/src/tests/integration/test_video_frame_metadata.cpp b/src/tests/integration/test_video_frame_metadata.cpp index 63b48664..4c309b05 100644 --- a/src/tests/integration/test_video_frame_metadata.cpp +++ b/src/tests/integration/test_video_frame_metadata.cpp @@ -51,18 +51,18 @@ TEST_F(VideoFrameMetadataServerTest, UserTimestampRoundTripsToReceiverEventCallb std::optional received_user_timestamp_us; const std::string track_name = "metadata-track"; - ASSERT_TRUE(receiver_room.trySetOnVideoFrameEventCallback( - sender_identity, track_name, [&mutex, &cv, &received_user_timestamp_us](const VideoFrameEvent& event) { - std::lock_guard lock(mutex); - if (!event.metadata) { - return; - } - const auto& user_timestamp_us = event.metadata->user_timestamp_us; - if (user_timestamp_us.has_value() && *user_timestamp_us != 0) { - received_user_timestamp_us = user_timestamp_us; - cv.notify_all(); - } - })); + receiver_room.setOnVideoFrameEventCallback(sender_identity, track_name, + [&mutex, &cv, &received_user_timestamp_us](const VideoFrameEvent& event) { + std::lock_guard lock(mutex); + if (!event.metadata) { + return; + } + const auto& user_timestamp_us = event.metadata->user_timestamp_us; + if (user_timestamp_us.has_value() && *user_timestamp_us != 0) { + received_user_timestamp_us = user_timestamp_us; + cv.notify_all(); + } + }); auto source = std::make_shared(16, 16); auto track = LocalVideoTrack::createLocalVideoTrack(track_name, source); @@ -180,17 +180,17 @@ TEST_F(VideoFrameMetadataServerTest, UserDataRoundTripsToReceiverEventCallback) const std::string track_name = "userdata-track"; const std::vector expected_user_data{0x01, 0x02, 0xab, 0xcd, 0xef}; - ASSERT_TRUE(receiver_room.trySetOnVideoFrameEventCallback( - sender_identity, track_name, [&mutex, &cv, &received_user_data](const VideoFrameEvent& event) { - std::lock_guard lock(mutex); - if (!event.metadata || !event.metadata->user_data.has_value()) { - return; - } - if (!event.metadata->user_data->empty()) { - received_user_data = event.metadata->user_data; - cv.notify_all(); - } - })); + receiver_room.setOnVideoFrameEventCallback(sender_identity, track_name, + [&mutex, &cv, &received_user_data](const VideoFrameEvent& event) { + std::lock_guard lock(mutex); + if (!event.metadata || !event.metadata->user_data.has_value()) { + return; + } + if (!event.metadata->user_data->empty()) { + received_user_data = event.metadata->user_data; + cv.notify_all(); + } + }); auto source = std::make_shared(16, 16); auto track = LocalVideoTrack::createLocalVideoTrack(track_name, source); diff --git a/src/tests/unit/test_room.cpp b/src/tests/unit/test_room.cpp index 22769930..f514508a 100644 --- a/src/tests/unit/test_room.cpp +++ b/src/tests/unit/test_room.cpp @@ -24,38 +24,11 @@ #include #include "../common/ffi_utils.h" +#include "../common/room_test_access.h" #include "ffi.pb.h" #include "ffi_client.h" #include "room_proto_converter.h" -namespace livekit { - -struct RoomTestAccess { - static void installConnectedListener(Room& room, std::atomic& callback_count) { - const auto listener_id = FfiClient::instance().addListener([&room, &callback_count](const proto::FfiEvent& event) { - callback_count.fetch_add(1, std::memory_order_relaxed); - room.onEvent(event); - }); - - const std::scoped_lock guard(room.lock_); - room.connection_state_ = ConnectionState::Connected; - room.room_handle_ = std::make_shared(); - room.listener_id_ = listener_id; - } - - static bool hasRoomHandle(const Room& room) { - const std::scoped_lock guard(room.lock_); - return static_cast(room.room_handle_); - } - - static int listenerId(const Room& room) { - const std::scoped_lock guard(room.lock_); - return room.listener_id_; - } -}; - -} // namespace livekit - namespace livekit::test { class RoomTest : public ::testing::Test { diff --git a/src/tests/unit/test_room_callbacks.cpp b/src/tests/unit/test_room_callbacks.cpp index f74524dc..ad89be14 100644 --- a/src/tests/unit/test_room_callbacks.cpp +++ b/src/tests/unit/test_room_callbacks.cpp @@ -37,8 +37,8 @@ class RoomCallbackTest : public ::testing::Test { TEST_F(RoomCallbackTest, FrameCallbackRegistrationByTrackNameIsAccepted) { Room room; - EXPECT_TRUE(room.trySetOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {})); - EXPECT_TRUE(room.trySetOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {})); + room.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); + room.setOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {}); EXPECT_NO_THROW(room.clearOnAudioFrameCallback("alice", "mic-main")); EXPECT_NO_THROW(room.clearOnVideoFrameCallback("alice", "cam-main")); } @@ -46,9 +46,9 @@ TEST_F(RoomCallbackTest, FrameCallbackRegistrationByTrackNameIsAccepted) { TEST_F(RoomCallbackTest, TrySetOnAudioReturnsTrueWithoutSubscription) { // Without a subscribed track, registration succeeds and no reader starts. Room room; - EXPECT_TRUE(room.trySetOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {})); + room.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); // Re-registering the same key while no reader is active is allowed. - EXPECT_TRUE(room.trySetOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {})); + room.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); } TEST_F(RoomCallbackTest, DataCallbackRegistrationReturnsUsableIds) { @@ -76,8 +76,8 @@ TEST_F(RoomCallbackTest, RemovingUnknownDataCallbackIsNoOp) { TEST_F(RoomCallbackTest, DestroyRoomWithRegisteredCallbacksIsSafe) { EXPECT_NO_THROW({ Room room; - (void)room.trySetOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); - (void)room.trySetOnVideoFrameCallback("bob", "cam-main", [](const VideoFrame&, std::int64_t) {}); + room.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); + room.setOnVideoFrameCallback("bob", "cam-main", [](const VideoFrame&, std::int64_t) {}); room.addOnDataFrameCallback("carol", "track", [](const std::vector&, std::optional) {}); }); @@ -86,7 +86,7 @@ TEST_F(RoomCallbackTest, DestroyRoomWithRegisteredCallbacksIsSafe) { TEST_F(RoomCallbackTest, DestroyRoomAfterClearingCallbacksIsSafe) { EXPECT_NO_THROW({ Room room; - (void)room.trySetOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); + room.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); room.clearOnAudioFrameCallback("alice", "mic-main"); const auto id = room.addOnDataFrameCallback("alice", "track", @@ -103,8 +103,8 @@ TEST_F(RoomCallbackTest, DefaultConnectionStateIsDisconnected) { TEST_F(RoomCallbackTest, ConnectionStateRemainsDisconnectedWithoutConnect) { // Register callbacks, do other operations — state must stay Disconnected. Room room; - (void)room.trySetOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); - (void)room.trySetOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {}); + room.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); + room.setOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {}); room.addOnDataFrameCallback("alice", "track", [](const std::vector&, std::optional) {}); room.registerTextStreamHandler("topic", [](const std::shared_ptr&, const std::string&) {}); EXPECT_EQ(room.connectionState(), ConnectionState::Disconnected); diff --git a/src/tests/unit/test_subscription_thread_dispatcher.cpp b/src/tests/unit/test_subscription_thread_dispatcher.cpp index d2a34cd1..fa035cb9 100644 --- a/src/tests/unit/test_subscription_thread_dispatcher.cpp +++ b/src/tests/unit/test_subscription_thread_dispatcher.cpp @@ -47,6 +47,11 @@ class FakeMediaTrack : public Track { : Track(FfiHandle(0), std::move(sid), "track", kind, StreamState::STATE_ACTIVE, false, true) {} }; +/// Minimal frames used to invoke a stored callback directly, so tests can prove +/// which callback a registration slot actually holds. +AudioFrame makeAudioFrame() { return AudioFrame::create(48000, 1, 480); } +VideoFrame makeVideoFrame() { return VideoFrame::create(16, 16, VideoBufferType::RGBA); } + template bool waitFor(Predicate predicate, std::chrono::milliseconds timeout) { const auto start = std::chrono::steady_clock::now(); @@ -81,6 +86,7 @@ class SubscriptionThreadDispatcherTest : public ::testing::Test { static auto& activeDataReaders(SubscriptionThreadDispatcher& dispatcher) { return dispatcher.active_data_readers_; } static auto& remoteDataTracks(SubscriptionThreadDispatcher& dispatcher) { return dispatcher.remote_data_tracks_; } static int maxActiveReaders() { return SubscriptionThreadDispatcher::kMaxActiveReaders; } + static bool isSelfThread(std::thread::id id) { return SubscriptionThreadDispatcher::isSelfThread(id); } static std::size_t activeReaderCount(SubscriptionThreadDispatcher& dispatcher) { const std::scoped_lock lock(dispatcher.lock_); return dispatcher.active_readers_.size(); @@ -196,21 +202,21 @@ TEST_F(SubscriptionThreadDispatcherTest, MaxActiveReadersIs20) { EXPECT_EQ(maxAc TEST_F(SubscriptionThreadDispatcherTest, SetAudioCallbackStoresRegistration) { SubscriptionThreadDispatcher dispatcher; - EXPECT_TRUE(dispatcher.trySetOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {})); + dispatcher.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); EXPECT_EQ(audioCallbacks(dispatcher).size(), 1u); } TEST_F(SubscriptionThreadDispatcherTest, SetVideoCallbackStoresRegistration) { SubscriptionThreadDispatcher dispatcher; - EXPECT_TRUE(dispatcher.trySetOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {})); + dispatcher.setOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {}); EXPECT_EQ(videoCallbacks(dispatcher).size(), 1u); } TEST_F(SubscriptionThreadDispatcherTest, ClearAudioCallbackRemovesRegistration) { SubscriptionThreadDispatcher dispatcher; - (void)dispatcher.trySetOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); + dispatcher.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); ASSERT_EQ(audioCallbacks(dispatcher).size(), 1u); dispatcher.clearOnAudioFrameCallback("alice", "mic-main"); @@ -219,7 +225,7 @@ TEST_F(SubscriptionThreadDispatcherTest, ClearAudioCallbackRemovesRegistration) TEST_F(SubscriptionThreadDispatcherTest, ClearVideoCallbackRemovesRegistration) { SubscriptionThreadDispatcher dispatcher; - (void)dispatcher.trySetOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {}); + dispatcher.setOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {}); ASSERT_EQ(videoCallbacks(dispatcher).size(), 1u); dispatcher.clearOnVideoFrameCallback("alice", "cam-main"); @@ -232,31 +238,62 @@ TEST_F(SubscriptionThreadDispatcherTest, ClearNonExistentCallbackIsNoOp) { EXPECT_NO_THROW(dispatcher.clearOnVideoFrameCallback("nobody", "missing")); } -TEST_F(SubscriptionThreadDispatcherTest, OverwriteAudioCallbackKeepsSingleEntry) { +TEST_F(SubscriptionThreadDispatcherTest, OverwriteAudioCallbackStoresTheNewCallback) { SubscriptionThreadDispatcher dispatcher; - std::atomic counter1{0}; - std::atomic counter2{0}; + std::atomic first{0}; + std::atomic second{0}; - (void)dispatcher.trySetOnAudioFrameCallback("alice", "mic-main", [&counter1](const AudioFrame&) { counter1++; }); - (void)dispatcher.trySetOnAudioFrameCallback("alice", "mic-main", [&counter2](const AudioFrame&) { counter2++; }); + dispatcher.setOnAudioFrameCallback("alice", "mic-main", [&first](const AudioFrame&) { first++; }); + dispatcher.setOnAudioFrameCallback("alice", "mic-main", [&second](const AudioFrame&) { second++; }); EXPECT_EQ(audioCallbacks(dispatcher).size(), 1u) << "Re-registering with the same key should overwrite, not add"; + + // Invoke what the slot actually holds: size alone would not catch a setter + // that tore down the reader but forgot to install the new callback. + const CallbackKey key{"alice", "mic-main"}; + audioCallbacks(dispatcher)[key].callback(makeAudioFrame()); + EXPECT_EQ(first.load(), 0) << "The replaced callback must not be the one stored"; + EXPECT_EQ(second.load(), 1); } -TEST_F(SubscriptionThreadDispatcherTest, OverwriteVideoCallbackKeepsSingleEntry) { +TEST_F(SubscriptionThreadDispatcherTest, OverwriteVideoCallbackStoresTheNewCallback) { SubscriptionThreadDispatcher dispatcher; - (void)dispatcher.trySetOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {}); - (void)dispatcher.trySetOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {}); + std::atomic first{0}; + std::atomic second{0}; + + dispatcher.setOnVideoFrameCallback("alice", "cam-main", [&first](const VideoFrame&, std::int64_t) { first++; }); + dispatcher.setOnVideoFrameCallback("alice", "cam-main", [&second](const VideoFrame&, std::int64_t) { second++; }); EXPECT_EQ(videoCallbacks(dispatcher).size(), 1u); + + const CallbackKey key{"alice", "cam-main"}; + videoCallbacks(dispatcher)[key].legacy_callback(makeVideoFrame(), 0); + EXPECT_EQ(first.load(), 0); + EXPECT_EQ(second.load(), 1); +} + +TEST_F(SubscriptionThreadDispatcherTest, OverwriteAudioCallbackStoresTheNewStreamOptions) { + SubscriptionThreadDispatcher dispatcher; + AudioStream::Options first_opts; + first_opts.capacity = 4; + AudioStream::Options second_opts; + second_opts.capacity = 32; + + dispatcher.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}, first_opts); + dispatcher.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}, second_opts); + + // The options travel with the callback into the next reader, so a stale copy + // would silently rebuild the stream with the wrong queue behavior. + const CallbackKey key{"alice", "mic-main"}; + EXPECT_EQ(audioCallbacks(dispatcher)[key].options.capacity, 32u); } TEST_F(SubscriptionThreadDispatcherTest, MultipleDistinctCallbacksAreIndependent) { SubscriptionThreadDispatcher dispatcher; - (void)dispatcher.trySetOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); - (void)dispatcher.trySetOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {}); - (void)dispatcher.trySetOnAudioFrameCallback("bob", "mic-main", [](const AudioFrame&) {}); - (void)dispatcher.trySetOnVideoFrameCallback("bob", "cam-main", [](const VideoFrame&, std::int64_t) {}); + dispatcher.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); + dispatcher.setOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {}); + dispatcher.setOnAudioFrameCallback("bob", "mic-main", [](const AudioFrame&) {}); + dispatcher.setOnVideoFrameCallback("bob", "cam-main", [](const VideoFrame&, std::int64_t) {}); EXPECT_EQ(audioCallbacks(dispatcher).size(), 2u); EXPECT_EQ(videoCallbacks(dispatcher).size(), 2u); @@ -268,8 +305,8 @@ TEST_F(SubscriptionThreadDispatcherTest, MultipleDistinctCallbacksAreIndependent TEST_F(SubscriptionThreadDispatcherTest, ClearingOneTrackNameDoesNotAffectOther) { SubscriptionThreadDispatcher dispatcher; - (void)dispatcher.trySetOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); - (void)dispatcher.trySetOnAudioFrameCallback("alice", "screenshare-main", [](const AudioFrame&) {}); + dispatcher.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); + dispatcher.setOnAudioFrameCallback("alice", "screenshare-main", [](const AudioFrame&) {}); ASSERT_EQ(audioCallbacks(dispatcher).size(), 2u); dispatcher.clearOnAudioFrameCallback("alice", "mic-main"); @@ -290,7 +327,7 @@ TEST_F(SubscriptionThreadDispatcherTest, NoActiveReadersInitially) { TEST_F(SubscriptionThreadDispatcherTest, ActiveReadersEmptyAfterCallbackRegistration) { SubscriptionThreadDispatcher dispatcher; - (void)dispatcher.trySetOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); + dispatcher.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); EXPECT_TRUE(activeReaders(dispatcher).empty()) << "Registering a callback without a subscribed track should not spawn " "readers"; @@ -303,15 +340,15 @@ TEST_F(SubscriptionThreadDispatcherTest, ActiveReadersEmptyAfterCallbackRegistra TEST_F(SubscriptionThreadDispatcherTest, DestroyDispatcherWithRegisteredCallbacksIsSafe) { EXPECT_NO_THROW({ SubscriptionThreadDispatcher dispatcher; - (void)dispatcher.trySetOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); - (void)dispatcher.trySetOnVideoFrameCallback("bob", "cam-main", [](const VideoFrame&, std::int64_t) {}); + dispatcher.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); + dispatcher.setOnVideoFrameCallback("bob", "cam-main", [](const VideoFrame&, std::int64_t) {}); }); } TEST_F(SubscriptionThreadDispatcherTest, DestroyDispatcherAfterClearingCallbacksIsSafe) { EXPECT_NO_THROW({ SubscriptionThreadDispatcher dispatcher; - (void)dispatcher.trySetOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); + dispatcher.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); dispatcher.clearOnAudioFrameCallback("alice", "mic-main"); }); } @@ -332,7 +369,7 @@ TEST_F(SubscriptionThreadDispatcherTest, ConcurrentRegistrationDoesNotCrash) { threads.emplace_back([&dispatcher, t, kIterations]() { for (int i = 0; i < kIterations; ++i) { const std::string id = "participant-" + std::to_string(t); - (void)dispatcher.trySetOnAudioFrameCallback(id, "mic-main", [](const AudioFrame&) {}); + dispatcher.setOnAudioFrameCallback(id, "mic-main", [](const AudioFrame&) {}); dispatcher.clearOnAudioFrameCallback(id, "mic-main"); } }); @@ -357,8 +394,8 @@ TEST_F(SubscriptionThreadDispatcherTest, ConcurrentMixedAudioVideoRegistration) threads.emplace_back([&dispatcher, t, kIterations]() { const std::string id = "p-" + std::to_string(t); for (int i = 0; i < kIterations; ++i) { - (void)dispatcher.trySetOnAudioFrameCallback(id, "mic-main", [](const AudioFrame&) {}); - (void)dispatcher.trySetOnVideoFrameCallback(id, "cam-main", [](const VideoFrame&, std::int64_t) {}); + dispatcher.setOnAudioFrameCallback(id, "mic-main", [](const AudioFrame&) {}); + dispatcher.setOnVideoFrameCallback(id, "cam-main", [](const VideoFrame&, std::int64_t) {}); } }); } @@ -380,8 +417,7 @@ TEST_F(SubscriptionThreadDispatcherTest, ManyDistinctCallbacksCanBeRegistered) { constexpr int kCount = 50; for (int i = 0; i < kCount; ++i) { - (void)dispatcher.trySetOnAudioFrameCallback("participant-" + std::to_string(i), "mic-main", - [](const AudioFrame&) {}); + dispatcher.setOnAudioFrameCallback("participant-" + std::to_string(i), "mic-main", [](const AudioFrame&) {}); } EXPECT_EQ(audioCallbacks(dispatcher).size(), static_cast(kCount)); @@ -617,7 +653,7 @@ TEST_F(SubscriptionThreadDispatcherTest, ExtractFinishedDataReaderRemovesEntryAn TEST_F(SubscriptionThreadDispatcherTest, DuplicateSubscribeWithSameAudioSidDoesNotRestartReader) { SubscriptionThreadDispatcher dispatcher; - (void)dispatcher.trySetOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {}); + dispatcher.setOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {}); // Simulate an already-running reader for this subscription. const CallbackKey key{"alice", "mic"}; @@ -636,7 +672,7 @@ TEST_F(SubscriptionThreadDispatcherTest, DuplicateSubscribeWithSameAudioSidDoesN TEST_F(SubscriptionThreadDispatcherTest, DuplicateSubscribeWithSameVideoSidDoesNotRestartReader) { SubscriptionThreadDispatcher dispatcher; - (void)dispatcher.trySetOnVideoFrameCallback("alice", "cam", [](const VideoFrame&, std::int64_t) {}); + dispatcher.setOnVideoFrameCallback("alice", "cam", [](const VideoFrame&, std::int64_t) {}); const CallbackKey key{"alice", "cam"}; activeReaders(dispatcher)[key].track_sid = "TR_video_1"; @@ -651,66 +687,129 @@ TEST_F(SubscriptionThreadDispatcherTest, DuplicateSubscribeWithSameVideoSidDoesN } // ============================================================================ -// trySetOn* replacement semantics: registration is rejected while a reader is -// active; clearing first allows re-registration. +// setOn* replacement semantics: re-registering for a key with an active reader +// stops that reader in place so the next start binds the new callback. // ============================================================================ -TEST_F(SubscriptionThreadDispatcherTest, TrySetOnAudioWhileReaderActiveIsRejected) { +TEST_F(SubscriptionThreadDispatcherTest, SetOnAudioWhileReaderActiveReplacesRegistrationAndStopsReader) { SubscriptionThreadDispatcher dispatcher; - ASSERT_TRUE(dispatcher.trySetOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {})); + dispatcher.setOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {}); // Simulate an already-running reader for this subscription. const CallbackKey key{"alice", "mic"}; activeReaders(dispatcher)[key].track_sid = "TR_audio_1"; ASSERT_EQ(activeReaderCount(dispatcher), 1u); - EXPECT_FALSE(dispatcher.trySetOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {})) - << "Replacing a callback while its reader is active must be rejected"; + std::atomic replacement_invocations{0}; + dispatcher.setOnAudioFrameCallback("alice", "mic", + [&replacement_invocations](const AudioFrame&) { replacement_invocations++; }); + + EXPECT_EQ(activeReaderCount(dispatcher), 0u) << "The stale reader must be extracted so it stops dispatching to the " + "callback it captured by value"; + ASSERT_EQ(audioCallbacks(dispatcher).size(), 1u); + audioCallbacks(dispatcher)[key].callback(makeAudioFrame()); + EXPECT_EQ(replacement_invocations.load(), 1) << "The replacement callback must be the one now stored"; +} + +TEST_F(SubscriptionThreadDispatcherTest, SetOnVideoWhileReaderActiveReplacesRegistrationAndStopsReader) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.setOnVideoFrameCallback("alice", "cam", [](const VideoFrame&, std::int64_t) {}); + + const CallbackKey key{"alice", "cam"}; + activeReaders(dispatcher)[key].track_sid = "TR_video_1"; + ASSERT_EQ(activeReaderCount(dispatcher), 1u); + + std::atomic replacement_invocations{0}; + dispatcher.setOnVideoFrameCallback( + "alice", "cam", [&replacement_invocations](const VideoFrame&, std::int64_t) { replacement_invocations++; }); + + EXPECT_EQ(activeReaderCount(dispatcher), 0u); + ASSERT_EQ(videoCallbacks(dispatcher).size(), 1u); + videoCallbacks(dispatcher)[key].legacy_callback(makeVideoFrame(), 0); + EXPECT_EQ(replacement_invocations.load(), 1) << "The replacement callback must be the one now stored"; } -TEST_F(SubscriptionThreadDispatcherTest, TrySetOnVideoWhileReaderActiveIsRejected) { +TEST_F(SubscriptionThreadDispatcherTest, SetOnVideoEventWhileReaderActiveReplacesRegistrationAndStopsReader) { SubscriptionThreadDispatcher dispatcher; - ASSERT_TRUE(dispatcher.trySetOnVideoFrameCallback("alice", "cam", [](const VideoFrame&, std::int64_t) {})); + dispatcher.setOnVideoFrameCallback("alice", "cam", [](const VideoFrame&, std::int64_t) {}); const CallbackKey key{"alice", "cam"}; activeReaders(dispatcher)[key].track_sid = "TR_video_1"; ASSERT_EQ(activeReaderCount(dispatcher), 1u); - EXPECT_FALSE(dispatcher.trySetOnVideoFrameCallback("alice", "cam", [](const VideoFrame&, std::int64_t) {})); - EXPECT_FALSE(dispatcher.trySetOnVideoFrameEventCallback("alice", "cam", [](const VideoFrameEvent&) {})); + // The legacy and event callbacks share one registration slot, so registering + // the event variant must displace the legacy one and stop its reader. + dispatcher.setOnVideoFrameEventCallback("alice", "cam", [](const VideoFrameEvent&) {}); + + EXPECT_EQ(activeReaderCount(dispatcher), 0u); + ASSERT_EQ(videoCallbacks(dispatcher).size(), 1u); + EXPECT_FALSE(static_cast(videoCallbacks(dispatcher)[key].legacy_callback)); + EXPECT_TRUE(static_cast(videoCallbacks(dispatcher)[key].event_callback)); } -TEST_F(SubscriptionThreadDispatcherTest, ClearThenTrySetOnAudioRegistersNewCallback) { +TEST_F(SubscriptionThreadDispatcherTest, SetOnAudioAfterReplacementRestartsOnNextSubscribe) { SubscriptionThreadDispatcher dispatcher; - ASSERT_TRUE(dispatcher.trySetOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {})); + dispatcher.setOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {}); const CallbackKey key{"alice", "mic"}; activeReaders(dispatcher)[key].track_sid = "TR_audio_1"; ASSERT_EQ(activeReaderCount(dispatcher), 1u); - // Rejected while active, accepted once the reader is cleared. - ASSERT_FALSE(dispatcher.trySetOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {})); - dispatcher.clearOnAudioFrameCallback("alice", "mic"); - EXPECT_EQ(activeReaderCount(dispatcher), 0u); - EXPECT_TRUE(dispatcher.trySetOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {})); + // Replacing extracts the reader, so the SID dedup guard no longer suppresses a + // restart for the same publication -- this is what lets Room rebuild the reader + // against the new callback. + dispatcher.setOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {}); + ASSERT_EQ(activeReaderCount(dispatcher), 0u); + + // Re-subscribing the same SID now reaches stream construction instead of being + // short-circuited by the guard. The fake track carries an invalid FFI handle, + // so AudioStream::fromTrack throws -- that throw is precisely the evidence + // that startup was attempted rather than skipped. + auto track = std::make_shared("TR_audio_1", TrackKind::KIND_AUDIO); + EXPECT_ANY_THROW(dispatcher.handleTrackSubscribed("alice", "mic", track)); + EXPECT_EQ(audioCallbacks(dispatcher).size(), 1u); } -TEST_F(SubscriptionThreadDispatcherTest, ClearThenDeprecatedSetOnAudioRegistersNewCallback) { +TEST_F(SubscriptionThreadDispatcherTest, SetOnVideoAfterReplacementRestartsOnNextSubscribe) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.setOnVideoFrameCallback("alice", "cam", [](const VideoFrame&, std::int64_t) {}); + + const CallbackKey key{"alice", "cam"}; + activeReaders(dispatcher)[key].track_sid = "TR_video_1"; + ASSERT_EQ(activeReaderCount(dispatcher), 1u); + + dispatcher.setOnVideoFrameCallback("alice", "cam", [](const VideoFrame&, std::int64_t) {}); + ASSERT_EQ(activeReaderCount(dispatcher), 0u); + + // As in the audio case, the throw from VideoStream::fromTrack on the invalid + // fake handle is the evidence that the guard no longer short-circuits startup. + auto track = std::make_shared("TR_video_1", TrackKind::KIND_VIDEO); + EXPECT_ANY_THROW(dispatcher.handleTrackSubscribed("alice", "cam", track)); + + EXPECT_EQ(videoCallbacks(dispatcher).size(), 1u); +} + +TEST_F(SubscriptionThreadDispatcherTest, SetOnAudioWithoutReplacementLeavesSidGuardIntact) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.setOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {}); + + const CallbackKey key{"alice", "mic"}; + activeReaders(dispatcher)[key].track_sid = "TR_audio_1"; + ASSERT_EQ(activeReaderCount(dispatcher), 1u); + + // Counterpart to the test above: with the reader still in place, a duplicate + // subscribe for the same SID is skipped and never reaches stream construction. + auto track = std::make_shared("TR_audio_1", TrackKind::KIND_AUDIO); + EXPECT_NO_THROW(dispatcher.handleTrackSubscribed("alice", "mic", track)); + EXPECT_EQ(activeReaderCount(dispatcher), 1u); +} + +// Distinct from ClearAudioCallbackRemovesRegistration, which clears a key that +// has no reader: this covers clearing while a reader is active. +TEST_F(SubscriptionThreadDispatcherTest, ClearAudioCallbackWithActiveReaderStopsReader) { SubscriptionThreadDispatcher dispatcher; -#if defined(__clang__) -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wdeprecated-declarations" -#elif defined(__GNUC__) -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" -#endif dispatcher.setOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {}); -#if defined(__clang__) -#pragma clang diagnostic pop -#elif defined(__GNUC__) -#pragma GCC diagnostic pop -#endif const CallbackKey key{"alice", "mic"}; activeReaders(dispatcher)[key].track_sid = "TR_audio_1"; @@ -718,29 +817,96 @@ TEST_F(SubscriptionThreadDispatcherTest, ClearThenDeprecatedSetOnAudioRegistersN dispatcher.clearOnAudioFrameCallback("alice", "mic"); EXPECT_EQ(activeReaderCount(dispatcher), 0u); -#if defined(__clang__) -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wdeprecated-declarations" -#elif defined(__GNUC__) -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" -#endif + EXPECT_TRUE(audioCallbacks(dispatcher).empty()); +} + +TEST_F(SubscriptionThreadDispatcherTest, ClearVideoCallbackWithActiveReaderStopsReader) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.setOnVideoFrameCallback("alice", "cam", [](const VideoFrame&, std::int64_t) {}); + + const CallbackKey key{"alice", "cam"}; + activeReaders(dispatcher)[key].track_sid = "TR_video_1"; + ASSERT_EQ(activeReaderCount(dispatcher), 1u); + + dispatcher.clearOnVideoFrameCallback("alice", "cam"); + EXPECT_EQ(activeReaderCount(dispatcher), 0u); + EXPECT_TRUE(videoCallbacks(dispatcher).empty()); +} + +// The reverse of SetOnVideoEventWhileReaderActiveReplacesRegistrationAndStopsReader: +// the legacy setter must displace a stored event callback, not merge with it. +TEST_F(SubscriptionThreadDispatcherTest, SetOnVideoDisplacesStoredEventCallback) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.setOnVideoFrameEventCallback("alice", "cam", [](const VideoFrameEvent&) {}); + + const CallbackKey key{"alice", "cam"}; + activeReaders(dispatcher)[key].track_sid = "TR_video_1"; + ASSERT_EQ(activeReaderCount(dispatcher), 1u); + + dispatcher.setOnVideoFrameCallback("alice", "cam", [](const VideoFrame&, std::int64_t) {}); + + EXPECT_EQ(activeReaderCount(dispatcher), 0u); + ASSERT_EQ(videoCallbacks(dispatcher).size(), 1u); + EXPECT_TRUE(static_cast(videoCallbacks(dispatcher)[key].legacy_callback)); + EXPECT_FALSE(static_cast(videoCallbacks(dispatcher)[key].event_callback)); +} + +// Replacement must be scoped to its own key; an unrelated subscription's reader +// is extracted by key, so a bug there would tear down the wrong stream. +TEST_F(SubscriptionThreadDispatcherTest, ReplacementLeavesOtherKeysReadersUntouched) { + SubscriptionThreadDispatcher dispatcher; dispatcher.setOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {}); -#if defined(__clang__) -#pragma clang diagnostic pop -#elif defined(__GNUC__) -#pragma GCC diagnostic pop -#endif - EXPECT_EQ(audioCallbacks(dispatcher).size(), 1u); + dispatcher.setOnAudioFrameCallback("bob", "mic", [](const AudioFrame&) {}); + + const CallbackKey alice{"alice", "mic"}; + const CallbackKey bob{"bob", "mic"}; + activeReaders(dispatcher)[alice].track_sid = "TR_audio_1"; + activeReaders(dispatcher)[bob].track_sid = "TR_audio_2"; + ASSERT_EQ(activeReaderCount(dispatcher), 2u); + + dispatcher.setOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {}); + + EXPECT_EQ(activeReaderCount(dispatcher), 1u); + EXPECT_EQ(activeReaders(dispatcher).count(alice), 0u); + ASSERT_EQ(activeReaders(dispatcher).count(bob), 1u); + EXPECT_EQ(activeReaders(dispatcher)[bob].track_sid, "TR_audio_2") << "Replacing one key must not disturb another"; + EXPECT_EQ(audioCallbacks(dispatcher).size(), 2u); } -TEST_F(SubscriptionThreadDispatcherTest, TrySetOnAudioWithoutActiveReaderOverwritesRegistration) { +// Unsubscribe stops the reader but keeps the registration, so a replacement made +// while unsubscribed is the one that binds on the next subscribe. +TEST_F(SubscriptionThreadDispatcherTest, ReplacementWhileUnsubscribedKeepsRegistrationForNextSubscribe) { SubscriptionThreadDispatcher dispatcher; - EXPECT_TRUE(dispatcher.trySetOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {})); - // No reader is active, so re-registering the same key is allowed and simply - // overwrites the stored callback. - EXPECT_TRUE(dispatcher.trySetOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {})); - EXPECT_EQ(audioCallbacks(dispatcher).size(), 1u); + dispatcher.setOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {}); + + const CallbackKey key{"alice", "mic"}; + activeReaders(dispatcher)[key].track_sid = "TR_audio_1"; + ASSERT_EQ(activeReaderCount(dispatcher), 1u); + + dispatcher.handleTrackUnsubscribed("alice", TrackSource::SOURCE_MICROPHONE, "mic"); + EXPECT_EQ(activeReaderCount(dispatcher), 0u); + EXPECT_EQ(audioCallbacks(dispatcher).size(), 1u) << "Unsubscribe must preserve the registration"; + + std::atomic replacement_invocations{0}; + dispatcher.setOnAudioFrameCallback("alice", "mic", + [&replacement_invocations](const AudioFrame&) { replacement_invocations++; }); + ASSERT_EQ(audioCallbacks(dispatcher).size(), 1u); + audioCallbacks(dispatcher)[key].callback(makeAudioFrame()); + EXPECT_EQ(replacement_invocations.load(), 1); +} + +// ============================================================================ +// Self-join detection +// ============================================================================ + +TEST_F(SubscriptionThreadDispatcherTest, IsSelfThreadIdentifiesTheCallingThread) { + EXPECT_TRUE(isSelfThread(std::this_thread::get_id())); + EXPECT_FALSE(isSelfThread(std::thread::id{})) << "A default-constructed id must never match a running thread"; + + std::thread other([]() {}); + const auto other_id = other.get_id(); + other.join(); + EXPECT_FALSE(isSelfThread(other_id)); } // ============================================================================ @@ -842,8 +1008,8 @@ TEST_F(SubscriptionThreadDispatcherTest, DestroyDispatcherAfterRemovingDataCallb TEST_F(SubscriptionThreadDispatcherTest, MixedAudioVideoDataCallbacksAreIndependent) { SubscriptionThreadDispatcher dispatcher; - (void)dispatcher.trySetOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); - (void)dispatcher.trySetOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {}); + dispatcher.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); + dispatcher.setOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {}); dispatcher.addOnDataFrameCallback("alice", "data-track", [](const std::vector&, std::optional) {}); From f5d50557510b8dc56da06b109045e200a520c694 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Fri, 11 Sep 2026 15:13:18 -0600 Subject: [PATCH 3/8] gh issue 235: support async setting of callbacks --- include/livekit/room.h | 30 ++++- .../livekit/subscription_thread_dispatcher.h | 38 ++++-- src/subscription_thread_dispatcher.cpp | 62 ++++++++- .../integration/test_frame_callbacks.cpp | 121 ++++++++++++++++++ .../test_subscription_thread_dispatcher.cpp | 33 +++++ 5 files changed, 263 insertions(+), 21 deletions(-) create mode 100644 src/tests/integration/test_frame_callbacks.cpp diff --git a/include/livekit/room.h b/include/livekit/room.h index 5404985a..0aabe3c3 100644 --- a/include/livekit/room.h +++ b/include/livekit/room.h @@ -313,15 +313,39 @@ class LIVEKIT_API Room { // Frame callbacks // --------------------------------------------------------------- - /// Register or replace an audio frame callback for a remote subscription via SubscriptionThreadDispatcher. + /// @brief Sets the callback for frames from a remote audio track. + /// + /// The callback can be set before or after the matching track is subscribed + /// and runs on a dedicated reader thread. + /// + /// @param participant_identity Identity of the remote participant. + /// @param track_name Name of the remote audio track. + /// @param callback Function invoked for each decoded audio frame. + /// @param opts Options used to create the backing audio stream. void setOnAudioFrameCallback(const std::string& participant_identity, const std::string& track_name, AudioFrameCallback callback, const AudioStream::Options& opts = {}); - /// Register or replace a video frame callback for a remote subscription via SubscriptionThreadDispatcher. + /// @brief Sets the callback for frames from a remote video track. + /// + /// The callback can be set before or after the matching track is subscribed + /// and runs on a dedicated reader thread. + /// + /// @param participant_identity Identity of the remote participant. + /// @param track_name Name of the remote video track. + /// @param callback Function invoked for each decoded video frame. + /// @param opts Options used to create the backing video stream. void setOnVideoFrameCallback(const std::string& participant_identity, const std::string& track_name, VideoFrameCallback callback, const VideoStream::Options& opts = {}); - /// Register or replace a video frame event callback for a remote subscription via SubscriptionThreadDispatcher. + /// @brief Sets the event callback for frames from a remote video track. + /// + /// The callback can be set before or after the matching track is subscribed + /// and runs on a dedicated reader thread. + /// + /// @param participant_identity Identity of the remote participant. + /// @param track_name Name of the remote video track. + /// @param callback Function invoked for each decoded video frame event. + /// @param opts Options used to create the backing video stream. void setOnVideoFrameEventCallback(const std::string& participant_identity, const std::string& track_name, VideoFrameEventCallback callback, const VideoStream::Options& opts = {}); diff --git a/include/livekit/subscription_thread_dispatcher.h b/include/livekit/subscription_thread_dispatcher.h index 68d228ed..d99eef4a 100644 --- a/include/livekit/subscription_thread_dispatcher.h +++ b/include/livekit/subscription_thread_dispatcher.h @@ -96,14 +96,16 @@ class LIVEKIT_INTERNAL_API SubscriptionThreadDispatcher { /// Register or replace an audio frame callback for a remote subscription. /// /// The callback is keyed by remote participant identity plus @p track_name. - /// If the matching remote audio track is already subscribed, @ref Room may - /// immediately call @ref handleTrackSubscribed to start a reader. + /// If the matching remote audio track is already subscribed, this starts a + /// reader immediately. Otherwise, the reader starts when the track is + /// subscribed. /// /// Registering again for a key that already has an active reader replaces the /// callback in place: the previous reader's stream is closed and its thread - /// is joined before this call returns, and @ref Room then starts a fresh - /// reader bound to the new callback. When this call returns, the previous - /// callback has finished executing and its copy has been destroyed. + /// is joined before this call returns. If the track remains subscribed, the + /// dispatcher starts a fresh reader bound to the new callback. When this call + /// returns, the previous callback has finished executing and its copy has + /// been destroyed. /// /// @warning This call blocks until any in-flight invocation of the previous /// callback returns. A slow callback makes registration slow; a @@ -124,8 +126,9 @@ class LIVEKIT_INTERNAL_API SubscriptionThreadDispatcher { /// Register or replace a video frame callback for a remote subscription. /// /// The callback is keyed by remote participant identity plus @p track_name. - /// If the matching remote video track is already subscribed, @ref Room may - /// immediately call @ref handleTrackSubscribed to start a reader. + /// If the matching remote video track is already subscribed, this starts a + /// reader immediately. Otherwise, the reader starts when the track is + /// subscribed. /// /// Registering again for a key that already has an active reader replaces the /// callback in place; see @ref setOnAudioFrameCallback for the full @@ -146,8 +149,9 @@ class LIVEKIT_INTERNAL_API SubscriptionThreadDispatcher { /// subscription. /// /// The callback is keyed by remote participant identity plus @p track_name. - /// If the matching remote video track is already subscribed, @ref Room may - /// immediately call @ref handleTrackSubscribed to start a reader. + /// If the matching remote video track is already subscribed, this starts a + /// reader immediately. Otherwise, the reader starts when the track is + /// subscribed. /// /// Registering again for a key that already has an active reader replaces the /// callback in place; see @ref setOnAudioFrameCallback for the full @@ -203,9 +207,11 @@ class LIVEKIT_INTERNAL_API SubscriptionThreadDispatcher { /// AudioStream or @ref VideoStream and launches a reader thread for the /// `(participant, track_name)` key. /// - /// Remote data tracks are handled separately via @ref - /// handleDataTrackPublished. If @p track is not audio or video, or no - /// matching callback is registered, this is a no-op. + /// The dispatcher retains the subscription until it receives + /// @ref handleTrackUnsubscribed. This lets a callback registered after this + /// method returns start a reader immediately. Remote data tracks are handled + /// separately via @ref handleDataTrackPublished. If @p track is not audio or + /// video, no reader is started. /// /// @param participant_identity Identity of the remote participant. /// @param track_name Track name associated with the subscription. @@ -417,6 +423,11 @@ class LIVEKIT_INTERNAL_API SubscriptionThreadDispatcher { /// Must be called with @ref lock_ held. std::thread startReaderLocked(const CallbackKey& key, const std::shared_ptr& track); + /// Start a reader when a matching subscribed track is retained for @p key. + /// + /// Must be called with @ref lock_ held. + std::thread startReaderForSubscribedTrackLocked(const CallbackKey& key, TrackKind kind); + /// Start an audio reader thread for @p key using @p track. /// /// Must be called with @ref lock_ held. Any previous reader for the same key @@ -459,6 +470,9 @@ class LIVEKIT_INTERNAL_API SubscriptionThreadDispatcher { /// Active stream/thread state keyed by @ref CallbackKey. std::unordered_map active_readers_; + /// Currently subscribed remote audio/video tracks keyed by @ref CallbackKey. + std::unordered_map, CallbackKeyHash> subscribed_tracks_; + /// Next auto-increment ID for data frame callbacks. DataFrameCallbackId next_data_callback_id_{0}; diff --git a/src/subscription_thread_dispatcher.cpp b/src/subscription_thread_dispatcher.cpp index 63b88c57..de373be5 100644 --- a/src/subscription_thread_dispatcher.cpp +++ b/src/subscription_thread_dispatcher.cpp @@ -83,6 +83,8 @@ void SubscriptionThreadDispatcher::setOnAudioFrameCallback(const std::string& pa const AudioStream::Options& opts) { const CallbackKey key{participant_identity, track_name}; std::thread old_thread; + std::thread replaced_thread; + std::exception_ptr start_error; { const std::scoped_lock lock(lock_); // Stop any reader still dispatching to the previous callback. Reader threads @@ -91,12 +93,22 @@ void SubscriptionThreadDispatcher::setOnAudioFrameCallback(const std::string& pa old_thread = extractReaderThreadLocked(key); const bool replacing = audio_callbacks_.find(key) != audio_callbacks_.end(); audio_callbacks_[key] = RegisteredAudioCallback{std::move(callback), opts}; + try { + replaced_thread = startReaderForSubscribedTrackLocked(key, TrackKind::KIND_AUDIO); + } catch (...) { + start_error = std::current_exception(); + } LK_LOG_DEBUG( "Registered audio frame callback for participant={} track_name={} " - "replacing_existing={} stopped_reader={} total_audio_callbacks={}", - participant_identity, track_name, replacing, old_thread.joinable(), audio_callbacks_.size()); + "replacing_existing={} stopped_reader={} restarted_reader={} total_audio_callbacks={}", + participant_identity, track_name, replacing, old_thread.joinable(), + active_readers_.find(key) != active_readers_.end(), audio_callbacks_.size()); } disposeMediaReaderThread(std::move(old_thread), "setOnAudioFrameCallback"); + disposeMediaReaderThread(std::move(replaced_thread), "setOnAudioFrameCallback"); + if (start_error) { + std::rethrow_exception(start_error); + } } void SubscriptionThreadDispatcher::setOnVideoFrameEventCallback(const std::string& participant_identity, @@ -105,6 +117,8 @@ void SubscriptionThreadDispatcher::setOnVideoFrameEventCallback(const std::strin const VideoStream::Options& opts) { const CallbackKey key{participant_identity, track_name}; std::thread old_thread; + std::thread replaced_thread; + std::exception_ptr start_error; { const std::scoped_lock lock(lock_); old_thread = extractReaderThreadLocked(key); @@ -114,12 +128,22 @@ void SubscriptionThreadDispatcher::setOnVideoFrameEventCallback(const std::strin std::move(callback), opts, }; + try { + replaced_thread = startReaderForSubscribedTrackLocked(key, TrackKind::KIND_VIDEO); + } catch (...) { + start_error = std::current_exception(); + } LK_LOG_DEBUG( "Registered video frame event callback for participant={} track_name={} " - "replacing_existing={} stopped_reader={} total_video_callbacks={}", - participant_identity, track_name, replacing, old_thread.joinable(), video_callbacks_.size()); + "replacing_existing={} stopped_reader={} restarted_reader={} total_video_callbacks={}", + participant_identity, track_name, replacing, old_thread.joinable(), + active_readers_.find(key) != active_readers_.end(), video_callbacks_.size()); } disposeMediaReaderThread(std::move(old_thread), "setOnVideoFrameEventCallback"); + disposeMediaReaderThread(std::move(replaced_thread), "setOnVideoFrameEventCallback"); + if (start_error) { + std::rethrow_exception(start_error); + } } void SubscriptionThreadDispatcher::setOnVideoFrameCallback(const std::string& participant_identity, @@ -127,6 +151,8 @@ void SubscriptionThreadDispatcher::setOnVideoFrameCallback(const std::string& pa const VideoStream::Options& opts) { const CallbackKey key{participant_identity, track_name}; std::thread old_thread; + std::thread replaced_thread; + std::exception_ptr start_error; { const std::scoped_lock lock(lock_); old_thread = extractReaderThreadLocked(key); @@ -136,12 +162,22 @@ void SubscriptionThreadDispatcher::setOnVideoFrameCallback(const std::string& pa VideoFrameEventCallback{}, opts, }; + try { + replaced_thread = startReaderForSubscribedTrackLocked(key, TrackKind::KIND_VIDEO); + } catch (...) { + start_error = std::current_exception(); + } LK_LOG_DEBUG( "Registered video frame callback for participant={} track_name={} " - "replacing_existing={} stopped_reader={} total_video_callbacks={}", - participant_identity, track_name, replacing, old_thread.joinable(), video_callbacks_.size()); + "replacing_existing={} stopped_reader={} restarted_reader={} total_video_callbacks={}", + participant_identity, track_name, replacing, old_thread.joinable(), + active_readers_.find(key) != active_readers_.end(), video_callbacks_.size()); } disposeMediaReaderThread(std::move(old_thread), "setOnVideoFrameCallback"); + disposeMediaReaderThread(std::move(replaced_thread), "setOnVideoFrameCallback"); + if (start_error) { + std::rethrow_exception(start_error); + } } void SubscriptionThreadDispatcher::clearOnAudioFrameCallback(const std::string& participant_identity, @@ -194,6 +230,7 @@ void SubscriptionThreadDispatcher::handleTrackSubscribed(const std::string& part std::thread old_thread; { const std::scoped_lock lock(lock_); + subscribed_tracks_[key] = track; old_thread = startReaderLocked(key, track); } disposeMediaReaderThread(std::move(old_thread), "handleTrackSubscribed"); @@ -205,6 +242,7 @@ void SubscriptionThreadDispatcher::handleTrackUnsubscribed(const std::string& pa std::thread old_thread; { const std::scoped_lock lock(lock_); + subscribed_tracks_.erase(key); old_thread = extractReaderThreadLocked(key); LK_LOG_DEBUG( "Handling unsubscribed track for participant={} source={} " @@ -357,6 +395,7 @@ void SubscriptionThreadDispatcher::stopAll() { } } active_readers_.clear(); + subscribed_tracks_.clear(); audio_callbacks_.clear(); video_callbacks_.clear(); @@ -449,6 +488,17 @@ std::thread SubscriptionThreadDispatcher::startReaderLocked(const CallbackKey& k return {}; } +std::thread SubscriptionThreadDispatcher::startReaderForSubscribedTrackLocked(const CallbackKey& key, TrackKind kind) { + if (active_readers_.find(key) != active_readers_.end()) { + return {}; + } + const auto track_it = subscribed_tracks_.find(key); + if (track_it == subscribed_tracks_.end() || !track_it->second || track_it->second->kind() != kind) { + return {}; + } + return startReaderLocked(key, track_it->second); +} + std::thread SubscriptionThreadDispatcher::startAudioReaderLocked(const CallbackKey& key, const std::shared_ptr& track, const AudioFrameCallback& cb, diff --git a/src/tests/integration/test_frame_callbacks.cpp b/src/tests/integration/test_frame_callbacks.cpp new file mode 100644 index 00000000..d9b09c69 --- /dev/null +++ b/src/tests/integration/test_frame_callbacks.cpp @@ -0,0 +1,121 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include +#include +#include +#include + +#include "tests/common/test_common.h" + +namespace livekit::test { + +using namespace std::chrono_literals; + +class FrameCallbackServerTest : public LiveKitTestBase {}; + +TEST_F(FrameCallbackServerTest, VideoCallbackRegisteredAfterSubscriptionReceivesFrames) { + failIfNotConfigured(); + + Room receiver_room; + Room sender_room; + RoomOptions options; + options.auto_subscribe = true; + + ASSERT_TRUE(receiver_room.connect(config_.url, config_.token_b, options)); + ASSERT_TRUE(sender_room.connect(config_.url, config_.token_a, options)); + + const std::string sender_identity = lockLocalParticipant(sender_room)->identity(); + ASSERT_TRUE(waitForParticipant(&receiver_room, sender_identity, 10s)); + + const std::string track_name = "late-video-callback"; + auto source = std::make_shared(16, 16); + auto track = LocalVideoTrack::createLocalVideoTrack(track_name, source); + + TrackPublishOptions publish_options; + publish_options.source = TrackSource::SOURCE_CAMERA; + publish_options.simulcast = false; + ASSERT_NO_THROW(lockLocalParticipant(sender_room)->publishTrack(track, publish_options)); + + const auto subscription_deadline = std::chrono::steady_clock::now() + 10s; + bool subscribed = false; + while (std::chrono::steady_clock::now() < subscription_deadline && !subscribed) { + auto sender_on_receiver = receiver_room.remoteParticipant(sender_identity).lock(); + if (sender_on_receiver != nullptr) { + for (const auto& [sid, publication] : sender_on_receiver->trackPublications()) { + (void)sid; + if (publication != nullptr && publication->name() == track_name && publication->subscribed() && + publication->track() != nullptr) { + subscribed = true; + break; + } + } + } + if (!subscribed) { + std::this_thread::sleep_for(10ms); + } + } + ASSERT_TRUE(subscribed) << "Timed out waiting for the remote video subscription"; + + std::mutex frame_mutex; + std::condition_variable frame_cv; + int received_frames = 0; + std::thread registrar([&]() { + receiver_room.setOnVideoFrameCallback(sender_identity, track_name, [&](const VideoFrame&, std::int64_t) { + { + const std::scoped_lock lock(frame_mutex); + ++received_frames; + } + frame_cv.notify_all(); + }); + }); + registrar.join(); + + std::atomic publishing{true}; + std::thread publisher([&]() { + VideoFrame frame = VideoFrame::create(16, 16, VideoBufferType::RGBA); + std::fill(frame.data(), frame.data() + frame.dataSize(), 0x7f); + while (publishing.load(std::memory_order_relaxed)) { + try { + source->captureFrame(frame); + } catch (...) { + publishing.store(false, std::memory_order_relaxed); + break; + } + std::this_thread::sleep_for(50ms); + } + }); + + bool received = false; + { + std::unique_lock lock(frame_mutex); + received = frame_cv.wait_for(lock, 10s, [&]() { return received_frames > 0; }); + } + + publishing.store(false, std::memory_order_relaxed); + publisher.join(); + receiver_room.clearOnVideoFrameCallback(sender_identity, track_name); + if (track->publication()) { + lockLocalParticipant(sender_room)->unpublishTrack(track->publication()->sid()); + } + + EXPECT_TRUE(received) << "No video frames arrived after late callback registration"; +} + +} // namespace livekit::test diff --git a/src/tests/unit/test_subscription_thread_dispatcher.cpp b/src/tests/unit/test_subscription_thread_dispatcher.cpp index fa035cb9..118f5cb3 100644 --- a/src/tests/unit/test_subscription_thread_dispatcher.cpp +++ b/src/tests/unit/test_subscription_thread_dispatcher.cpp @@ -82,6 +82,7 @@ class SubscriptionThreadDispatcherTest : public ::testing::Test { static auto& audioCallbacks(SubscriptionThreadDispatcher& dispatcher) { return dispatcher.audio_callbacks_; } static auto& videoCallbacks(SubscriptionThreadDispatcher& dispatcher) { return dispatcher.video_callbacks_; } static auto& activeReaders(SubscriptionThreadDispatcher& dispatcher) { return dispatcher.active_readers_; } + static auto& subscribedTracks(SubscriptionThreadDispatcher& dispatcher) { return dispatcher.subscribed_tracks_; } static auto& dataCallbacks(SubscriptionThreadDispatcher& dispatcher) { return dispatcher.data_callbacks_; } static auto& activeDataReaders(SubscriptionThreadDispatcher& dispatcher) { return dispatcher.active_data_readers_; } static auto& remoteDataTracks(SubscriptionThreadDispatcher& dispatcher) { return dispatcher.remote_data_tracks_; } @@ -333,6 +334,38 @@ TEST_F(SubscriptionThreadDispatcherTest, ActiveReadersEmptyAfterCallbackRegistra "readers"; } +TEST_F(SubscriptionThreadDispatcherTest, SubscribedTrackIsRetainedWithoutCallback) { + SubscriptionThreadDispatcher dispatcher; + auto track = std::make_shared("fake-sid", TrackKind::KIND_AUDIO); + + dispatcher.handleTrackSubscribed("alice", "mic-main", track); + + const CallbackKey key{"alice", "mic-main"}; + ASSERT_EQ(subscribedTracks(dispatcher).count(key), 1u); + EXPECT_EQ(subscribedTracks(dispatcher).at(key), track); + EXPECT_TRUE(activeReaders(dispatcher).empty()); +} + +TEST_F(SubscriptionThreadDispatcherTest, UnsubscribeRemovesRetainedTrack) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.handleTrackSubscribed("alice", "mic-main", + std::make_shared("fake-sid", TrackKind::KIND_AUDIO)); + + dispatcher.handleTrackUnsubscribed("alice", TrackSource::SOURCE_MICROPHONE, "mic-main"); + + EXPECT_TRUE(subscribedTracks(dispatcher).empty()); +} + +TEST_F(SubscriptionThreadDispatcherTest, StopAllRemovesRetainedTracks) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.handleTrackSubscribed("alice", "mic-main", + std::make_shared("fake-sid", TrackKind::KIND_AUDIO)); + + dispatcher.stopAll(); + + EXPECT_TRUE(subscribedTracks(dispatcher).empty()); +} + // ============================================================================ // Destruction safety // ============================================================================ From b03f79859a56e8522d96217d000991caa676203d Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Mon, 14 Sep 2026 12:18:08 -0600 Subject: [PATCH 4/8] deprecate public usage of subscription thread dispatcher --- include/livekit/frame_callbacks.h | 53 +++++++++++++++++++ include/livekit/room.h | 5 +- .../livekit/subscription_thread_dispatcher.h | 37 ++++--------- include/livekit/visibility.h | 15 ++++++ src/room.cpp | 1 + src/tests/common/room_test_access.h | 19 ++++++- .../test_subscription_thread_dispatcher.cpp | 18 +++++++ 7 files changed, 119 insertions(+), 29 deletions(-) create mode 100644 include/livekit/frame_callbacks.h diff --git a/include/livekit/frame_callbacks.h b/include/livekit/frame_callbacks.h new file mode 100644 index 00000000..cd1cda07 --- /dev/null +++ b/include/livekit/frame_callbacks.h @@ -0,0 +1,53 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +namespace livekit { + +class AudioFrame; +class VideoFrame; +struct VideoFrameEvent; + +/// Callback type for incoming audio frames. +/// Invoked on a dedicated reader thread per (participant, track_name) pair. +using AudioFrameCallback = std::function; + +/// Callback type for incoming video frames. +/// Invoked on a dedicated reader thread per (participant, track_name) pair. +using VideoFrameCallback = std::function; + +/// Callback type for incoming video frame events. +/// Invoked on a dedicated reader thread per (participant, track_name) pair. +using VideoFrameEventCallback = std::function; + +/// Callback type for incoming data track frames. +/// Invoked on a dedicated reader thread per subscription. +/// @param payload Raw binary data received. +/// @param user_timestamp Optional application-defined timestamp from sender. +using DataFrameCallback = + std::function& payload, std::optional user_timestamp)>; + +/// Opaque identifier returned by addOnDataFrameCallback, used to remove an +/// individual subscription via removeOnDataFrameCallback. +using DataFrameCallbackId = std::uint64_t; + +} // namespace livekit diff --git a/include/livekit/room.h b/include/livekit/room.h index 0aabe3c3..382e0e6f 100644 --- a/include/livekit/room.h +++ b/include/livekit/room.h @@ -22,18 +22,21 @@ #include #include +#include "livekit/audio_stream.h" #include "livekit/data_stream.h" #include "livekit/e2ee.h" #include "livekit/ffi_handle.h" +#include "livekit/frame_callbacks.h" #include "livekit/room_event_types.h" #include "livekit/stats.h" -#include "livekit/subscription_thread_dispatcher.h" +#include "livekit/video_stream.h" #include "livekit/visibility.h" namespace livekit { class RoomDelegate; struct RoomInfoData; +class SubscriptionThreadDispatcher; namespace proto { class FfiEvent; } diff --git a/include/livekit/subscription_thread_dispatcher.h b/include/livekit/subscription_thread_dispatcher.h index d99eef4a..901e0fdd 100644 --- a/include/livekit/subscription_thread_dispatcher.h +++ b/include/livekit/subscription_thread_dispatcher.h @@ -18,17 +18,15 @@ #include #include -#include #include #include #include -#include #include #include #include -#include #include "livekit/audio_stream.h" +#include "livekit/frame_callbacks.h" #include "livekit/video_stream.h" #include "livekit/visibility.h" @@ -40,29 +38,6 @@ class RemoteDataTrack; class Track; class VideoFrame; -/// Callback type for incoming audio frames. -/// Invoked on a dedicated reader thread per (participant, track_name) pair. -using AudioFrameCallback = std::function; - -/// Callback type for incoming video frames. -/// Invoked on a dedicated reader thread per (participant, track_name) pair. -using VideoFrameCallback = std::function; - -/// Callback type for incoming video frame events. -/// Invoked on a dedicated reader thread per (participant, track_name) pair. -using VideoFrameEventCallback = std::function; - -/// Callback type for incoming data track frames. -/// Invoked on a dedicated reader thread per subscription. -/// @param payload Raw binary data received. -/// @param user_timestamp Optional application-defined timestamp from sender. -using DataFrameCallback = - std::function& payload, std::optional user_timestamp)>; - -/// Opaque identifier returned by addOnDataFrameCallback, used to remove an -/// individual subscription via removeOnDataFrameCallback. -using DataFrameCallbackId = std::uint64_t; - /// Owns subscription callback registration and per-subscription reader threads. /// /// `SubscriptionThreadDispatcher` is the low-level companion to @ref Room's @@ -85,7 +60,15 @@ using DataFrameCallbackId = std::uint64_t; /// The design keeps track-type-specific startup isolated so additional track /// kinds can be added later without pushing more thread state back into /// @ref Room. -class LIVEKIT_INTERNAL_API SubscriptionThreadDispatcher { +/// +/// @deprecated Prefer @ref Room's `setOn*FrameCallback` / `clearOn*FrameCallback` +/// / `addOnDataFrameCallback` / `removeOnDataFrameCallback` methods, which +/// delegate to this class internally. Direct use of this class is deprecated +/// and it may be removed, or its API may change, in a future major version. +class LIVEKIT_DEPRECATED( + "SubscriptionThreadDispatcher is deprecated; use Room::setOnAudioFrameCallback / " + "setOnVideoFrameCallback / setOnVideoFrameEventCallback / addOnDataFrameCallback instead. " + "It may be removed in a future major version.") LIVEKIT_API SubscriptionThreadDispatcher { public: /// Constructs an empty dispatcher with no registered callbacks or readers. SubscriptionThreadDispatcher(); diff --git a/include/livekit/visibility.h b/include/livekit/visibility.h index 3f256e18..e783a30f 100644 --- a/include/livekit/visibility.h +++ b/include/livekit/visibility.h @@ -58,3 +58,18 @@ #define LIVEKIT_INTERNAL_API #endif #endif + +// LIVEKIT_DEPRECATED marks a symbol that remains part of the public, +// supported ABI but is discouraged for new code and may be removed in a +// future major version. It expands to the standard [[deprecated(msg)]] +// attribute for external consumers. +// +// SDK sources (compiled with LIVEKIT_BUILDING_SDK) still call into +// deprecated APIs as part of their own implementation during the transition +// period, so the attribute compiles away for those translation units instead +// of warning on our own code. +#if defined(LIVEKIT_BUILDING_SDK) +#define LIVEKIT_DEPRECATED(msg) +#else +#define LIVEKIT_DEPRECATED(msg) [[deprecated(msg)]] +#endif diff --git a/src/room.cpp b/src/room.cpp index ba7d0165..f196f6bb 100644 --- a/src/room.cpp +++ b/src/room.cpp @@ -28,6 +28,7 @@ #include "livekit/remote_video_track.h" #include "livekit/room_delegate.h" #include "livekit/room_event_types.h" +#include "livekit/subscription_thread_dispatcher.h" #include "livekit_ffi.h" #include "lk_log.h" #include "room.pb.h" diff --git a/src/tests/common/room_test_access.h b/src/tests/common/room_test_access.h index 0387b362..3289e85e 100644 --- a/src/tests/common/room_test_access.h +++ b/src/tests/common/room_test_access.h @@ -23,7 +23,6 @@ #pragma once #include -#include #include #include @@ -31,9 +30,21 @@ #include "ffi.pb.h" #include "ffi_client.h" +#include "livekit/subscription_thread_dispatcher.h" namespace livekit { +// RoomTestAccess deliberately reaches into the (deprecated-for-external-use) +// SubscriptionThreadDispatcher as part of exercising Room's internals; suppress +// the deprecation warning for that in-tree, intentional use. +#if defined(__clang__) || defined(__GNUC__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +#elif defined(_MSC_VER) +#pragma warning(push) +#pragma warning(disable : 4996) +#endif + struct RoomTestAccess { static void installConnectedListener(Room& room, std::atomic& callback_count) { const auto listener_id = FfiClient::instance().addListener([&room, &callback_count](const proto::FfiEvent& event) { @@ -81,4 +92,10 @@ struct RoomTestAccess { } }; +#if defined(__clang__) || defined(__GNUC__) +#pragma GCC diagnostic pop +#elif defined(_MSC_VER) +#pragma warning(pop) +#endif + } // namespace livekit diff --git a/src/tests/unit/test_subscription_thread_dispatcher.cpp b/src/tests/unit/test_subscription_thread_dispatcher.cpp index 118f5cb3..0a520358 100644 --- a/src/tests/unit/test_subscription_thread_dispatcher.cpp +++ b/src/tests/unit/test_subscription_thread_dispatcher.cpp @@ -31,6 +31,18 @@ #include #include "../common/remote_data_track_test_access.h" +#include "livekit/subscription_thread_dispatcher.h" + +// This file unit-tests SubscriptionThreadDispatcher's internals directly, an +// intentional in-tree use of an API that is deprecated for external +// consumers; suppress the deprecation warning throughout. +#if defined(__clang__) || defined(__GNUC__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +#elif defined(_MSC_VER) +#pragma warning(push) +#pragma warning(disable : 4996) +#endif namespace livekit { @@ -1097,3 +1109,9 @@ TEST_F(SubscriptionThreadDispatcherTest, ConcurrentDataCallbackRegistrationDoesN } } // namespace livekit + +#if defined(__clang__) || defined(__GNUC__) +#pragma GCC diagnostic pop +#elif defined(_MSC_VER) +#pragma warning(pop) +#endif From e94b70bcabc8d8b2239eec6da1a53a06ba4e2841 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Mon, 14 Sep 2026 14:52:28 -0600 Subject: [PATCH 5/8] no breaking changes --- README.md | 24 +- .../livekit/subscription_thread_dispatcher.h | 155 ++++-- src/room.cpp | 12 +- src/subscription_thread_dispatcher.cpp | 347 +++++++------- src/tests/common/room_test_access.h | 27 ++ .../test_frame_callback_replacement.cpp | 142 +++++- .../integration/test_frame_callbacks.cpp | 388 ++++++++++++--- src/tests/unit/test_room_callbacks.cpp | 13 +- .../test_subscription_thread_dispatcher.cpp | 453 +++++++++++++++++- 9 files changed, 1218 insertions(+), 343 deletions(-) diff --git a/README.md b/README.md index 1d6dfb1e..8a786707 100644 --- a/README.md +++ b/README.md @@ -191,22 +191,30 @@ room->addOnDataFrameCallback(sender_identity, "app-data", }); ``` +Frame callbacks can be registered before or after the matching track is +subscribed. If the track is already subscribed, a reader starts immediately; +otherwise it starts when the subscription arrives. + Calling `setOnAudioFrameCallback` / `setOnVideoFrameCallback` / `setOnVideoFrameEventCallback` again for the same `(participant_identity, track_name)` **replaces** the callback in place. The -previous reader is stopped and its thread joined before the call returns, then a -fresh reader is started bound to the new callback — there is no need to call -`clearOn*FrameCallback` first. Two consequences worth knowing: +previous reader is stopped and its thread joined, and only then is a fresh reader +started bound to the new callback — the old and new callbacks never run at the +same time, and there is no need to call `clearOn*FrameCallback` first. Two +consequences worth knowing: - **These calls block** until any in-flight invocation of the previous callback returns. When the call returns, the old callback is guaranteed to have finished and been destroyed. A callback that blocks forever blocks registration forever. -- **Do not register or clear from inside a frame callback.** Doing so would make - the join a self-join. The SDK detects this, logs an error, and detaches the - reader (media) or leaves it in place to be reaped at teardown (data), but the - registration does not behave as intended. Drive callback changes from another - thread. +- **Avoid registering, clearing, or disconnecting from inside a frame + callback.** Doing so would make the join a self-join, so the SDK logs a + warning and detaches that reader instead. The change still takes effect + (the new callback is installed, or the reader is stopped, or the room + disconnects) and the detached reader exits once the current callback + invocation returns — but for that one invocation the "previous callback has + finished" guarantee above does not hold. Prefer driving callback changes from + another thread. For end-to-end samples and a fuller set of demos, see the [cpp-example-collection repo](https://github.com/livekit-examples/cpp-example-collection). diff --git a/include/livekit/subscription_thread_dispatcher.h b/include/livekit/subscription_thread_dispatcher.h index 901e0fdd..9c2eeafa 100644 --- a/include/livekit/subscription_thread_dispatcher.h +++ b/include/livekit/subscription_thread_dispatcher.h @@ -85,18 +85,23 @@ class LIVEKIT_DEPRECATED( /// /// Registering again for a key that already has an active reader replaces the /// callback in place: the previous reader's stream is closed and its thread - /// is joined before this call returns. If the track remains subscribed, the - /// dispatcher starts a fresh reader bound to the new callback. When this call - /// returns, the previous callback has finished executing and its copy has - /// been destroyed. + /// is joined, and only then -- if the track is still subscribed -- is a fresh + /// reader started bound to the new callback. The old and new callbacks are + /// therefore never invoked concurrently, and when this call returns the + /// previous callback has finished executing and its copy has been destroyed. + /// While the previous reader is being joined, no other caller (a concurrent + /// registration or a subscription event) can start a reader for the key. /// /// @warning This call blocks until any in-flight invocation of the previous /// callback returns. A slow callback makes registration slow; a /// callback that never returns blocks this call indefinitely. /// - /// @warning Calling this from inside a frame callback for the same key is not - /// supported. The dispatcher detects the re-entrant call, logs an - /// error, and detaches the reader instead of self-joining. + /// @warning Calling this from inside a frame callback for the same key is + /// discouraged. Joining the reader would be a self-join, so the + /// dispatcher logs a warning and detaches that reader instead. The + /// replacement still takes effect, but the in-flight invocation of + /// the previous callback only finishes after this call returns, so + /// the no-overlap guarantee above does not hold for that invocation. /// /// @param participant_identity Identity of the remote participant. /// @param track_name Track name to match. @@ -158,9 +163,9 @@ class LIVEKIT_DEPRECATED( /// callback does not require clearing first -- see /// @ref setOnAudioFrameCallback. /// - /// @warning Blocks until any in-flight callback invocation returns, and is - /// not supported from inside a frame callback for the same key. See - /// @ref setOnAudioFrameCallback. + /// @warning Blocks until any in-flight callback invocation returns. See + /// @ref setOnAudioFrameCallback for the caveat on calling this from + /// inside a frame callback for the same key. /// /// @param participant_identity Identity of the remote participant. /// @param track_name Track name to clear. @@ -173,9 +178,9 @@ class LIVEKIT_DEPRECATED( /// callback does not require clearing first -- see /// @ref setOnVideoFrameCallback. /// - /// @warning Blocks until any in-flight callback invocation returns, and is - /// not supported from inside a frame callback for the same key. See - /// @ref setOnAudioFrameCallback. + /// @warning Blocks until any in-flight callback invocation returns. See + /// @ref setOnAudioFrameCallback for the caveat on calling this from + /// inside a frame callback for the same key. /// /// @param participant_identity Identity of the remote participant. /// @param track_name Track name to clear. @@ -188,7 +193,9 @@ class LIVEKIT_DEPRECATED( /// updated its publication state. If a matching audio or video callback /// registration exists, the dispatcher creates the appropriate @ref /// AudioStream or @ref VideoStream and launches a reader thread for the - /// `(participant, track_name)` key. + /// `(participant, track_name)` key. A repeated event for the track SID a + /// reader is already serving is a no-op; a different SID (a republish) + /// stops and joins the previous reader before starting the new one. /// /// The dispatcher retains the subscription until it receives /// @ref handleTrackUnsubscribed. This lets a callback registered after this @@ -248,12 +255,11 @@ class LIVEKIT_DEPRECATED( /// /// @warning Blocks until any in-flight invocation of the callback returns. /// - /// @warning Calling this from inside the data frame callback it would remove - /// is not supported. The dispatcher detects the re-entrant call, - /// logs an error, and leaves the reader in place; the reader is - /// reaped on teardown instead. Data readers cannot be safely - /// detached because they re-enter the dispatcher after the callback - /// returns. + /// @warning Calling this from inside the data frame callback it removes is + /// discouraged. Joining the reader would be a self-join, so the + /// dispatcher logs a warning and detaches the reader instead. The + /// removal still takes effect: the reader's stream is closed and it + /// exits as soon as the in-flight callback invocation returns. /// /// @param id The identifier returned by addOnDataFrameCallback(). void removeOnDataFrameCallback(DataFrameCallbackId id); @@ -278,7 +284,10 @@ class LIVEKIT_DEPRECATED( /// Stop all readers and clear all callback registrations. /// /// This is used during room teardown or EOS handling to ensure no reader - /// thread survives beyond the lifetime of the owning @ref Room. + /// thread survives beyond the lifetime of the owning @ref Room. If called + /// from inside a frame callback (for example `Room::disconnect()` invoked + /// from a data frame callback), the calling reader is detached rather than + /// self-joined; it exits once that callback invocation returns. void stopAll(); private: @@ -349,9 +358,11 @@ class LIVEKIT_DEPRECATED( /// Set true when this reader is being replaced or torn down so the reader /// thread can abort a subscription that is still in flight. std::atomic cancelled{false}; - /// Guarded by lock_. Reader threads may mark themselves finished, but only - /// dispatcher lifecycle paths erase the slot and join the thread. - bool finished = false; + /// Set true by the reader thread itself when it exits (failed, cancelled, + /// or terminal subscription). A finished reader still occupying its slot + /// is replaced rather than deduplicated on the next same-SID publish. Only + /// dispatcher lifecycle paths erase the slot and join or detach the thread. + std::atomic finished{false}; std::mutex sub_mutex; std::shared_ptr stream; // guarded by sub_mutex std::thread thread; @@ -377,57 +388,87 @@ class LIVEKIT_DEPRECATED( /// Remove and close the active reader for @p key, returning its thread. /// /// Must be called with @ref lock_ held. The returned thread, if joinable, - /// must be joined after releasing the lock. + /// must be disposed of after releasing the lock. std::thread extractReaderThreadLocked(const CallbackKey& key); + /// Remove and close the active reader for @p key and, if a thread came out, + /// mark the key as draining so that no reader for it is started until + /// @ref finishReaderDrainAndRestart has disposed of that thread. + /// + /// Every audio/video path that stops a reader goes through this, so a + /// concurrent registration or subscription event for the same key cannot + /// start a replacement while the previous callback may still be executing. + /// + /// Must be called with @ref lock_ held. + std::thread extractReaderForDrainLocked(const CallbackKey& key); + + /// Second half of every audio/video reader restart. + /// + /// Disposes of @p old_thread (obtained from @ref extractReaderForDrainLocked) + /// with @ref lock_ released, then re-acquires the lock, clears the draining + /// mark, and starts a reader for @p key if a callback is registered, a + /// subscribed track is retained, and no other caller is still draining the + /// key. Because the start happens only after the join, the previous and the + /// new callback never run concurrently. + /// + /// Must be called with @ref lock_ released. @p operation names the public + /// entry point for diagnostics. + void finishReaderDrainAndRestart(const CallbackKey& key, std::thread old_thread, const char* operation); + /// True when @p id identifies the calling thread, i.e. joining that thread /// would be a self-join. static bool isSelfThread(std::thread::id id) { return id == std::this_thread::get_id(); } - /// Dispose of an extracted audio/video reader thread. + /// Dispose of an extracted reader thread (audio, video, or data). /// /// Normally joins, so the caller is guaranteed the reader has stopped and its /// callback copy has been destroyed. If the caller *is* that reader -- a - /// re-entrant registration from inside a frame callback -- joining would be a - /// self-join, so this logs an error naming @p operation and detaches instead. - /// Detaching is safe here because audio/video reader lambdas capture no - /// @c this and own their stream and callback by value. + /// re-entrant call from inside its own frame callback -- joining would be a + /// self-join, so this logs a warning naming @p operation and detaches + /// instead. Detaching is safe because no reader lambda captures @c this: + /// each owns its stream, callback, and (for data) its @ref ActiveDataReader + /// by value, so a detached thread touches nothing owned by the dispatcher. /// /// Must be called with @ref lock_ released. - void disposeMediaReaderThread(std::thread&& thread, const char* operation); + void disposeReaderThread(std::thread&& thread, const char* operation); - /// Select the appropriate reader startup path for @p media track. + /// Select the appropriate reader startup path for the media @p track. + /// + /// Looks up the callback registration matching the track's kind and starts + /// an audio or video reader bound to it. If no callback is registered for + /// that kind, or the kind is unsupported, this is a no-op. /// - /// This is called by @ref Room when a remote track is subscribed. If a reader - /// for the same track SID is already active, startup is skipped and a - /// default-constructed thread is returned; otherwise any previous reader is - /// extracted and returned to the caller for joining outside the lock. + /// Precondition: no reader is active for @p key. Every caller stops the + /// previous reader through the drain protocol first, so a reader found here + /// is a bug; it is logged and left untouched rather than replaced. /// /// Must be called with @ref lock_ held. - std::thread startReaderLocked(const CallbackKey& key, const std::shared_ptr& track); + void startReaderLocked(const CallbackKey& key, const std::shared_ptr& track); - /// Start a reader when a matching subscribed track is retained for @p key. + /// Start a reader for @p key if one should be running: a subscribed track is + /// retained for the key, no reader is active, and the key is not draining. + /// Whether a callback of the matching kind is registered is decided by + /// @ref startReaderLocked. /// /// Must be called with @ref lock_ held. - std::thread startReaderForSubscribedTrackLocked(const CallbackKey& key, TrackKind kind); + void startReaderForSubscribedTrackLocked(const CallbackKey& key); /// Start an audio reader thread for @p key using @p track. /// - /// Must be called with @ref lock_ held. Any previous reader for the same key - /// is extracted and returned to the caller for joining outside the lock. - std::thread startAudioReaderLocked(const CallbackKey& key, const std::shared_ptr& track, - const AudioFrameCallback& cb, const AudioStream::Options& opts); + /// Must be called with @ref lock_ held and with no reader active for @p key. + void startAudioReaderLocked(const CallbackKey& key, const std::shared_ptr& track, const AudioFrameCallback& cb, + const AudioStream::Options& opts); /// Start a video reader thread for @p key using @p track. /// - /// Must be called with @ref lock_ held. Any previous reader for the same key - /// is extracted and returned to the caller for joining outside the lock. - std::thread startVideoReaderLocked(const CallbackKey& key, const std::shared_ptr& track, - const RegisteredVideoCallback& callback); + /// Must be called with @ref lock_ held and with no reader active for @p key. + void startVideoReaderLocked(const CallbackKey& key, const std::shared_ptr& track, + const RegisteredVideoCallback& callback); /// Extract and close the data reader for a given callback ID, returning its /// thread. Marks the reader cancelled so a subscription still in flight is - /// aborted. Must be called with @ref lock_ held. + /// aborted. Must be called with @ref lock_ held; the returned thread must be + /// passed to @ref disposeReaderThread after releasing the lock. std::thread extractDataReaderThreadLocked(DataFrameCallbackId id); /// Start a data reader thread for the given callback ID, key, and track. @@ -435,11 +476,14 @@ class LIVEKIT_DEPRECATED( std::thread startDataReaderLocked(DataFrameCallbackId id, const DataCallbackKey& key, const std::shared_ptr& track, const DataFrameCallback& cb); - /// Mark @p reader finished if the slot for @p id still refers to it. - /// Called by the reader thread itself when it exits after a failed, - /// cancelled, or terminal subscription. Acquires @ref lock_. Reader threads - /// must not erase, detach, or join their own @ref std::thread. - void markDataReaderFinishedIfCurrent(DataFrameCallbackId id, const std::shared_ptr& reader); + /// Mark @p reader finished and release its stream. + /// + /// Called by the reader thread itself, on every exit path, so a slot still + /// holding a dead reader is recognised as replaceable. Deliberately touches + /// only @p reader (never the dispatcher), which is what makes data reader + /// threads safe to detach. Reader threads must not erase, detach, or join + /// their own @ref std::thread. + static void markDataReaderFinished(const std::shared_ptr& reader); /// Protects callback registration maps and active reader state. mutable std::mutex lock_; @@ -456,6 +500,11 @@ class LIVEKIT_DEPRECATED( /// Currently subscribed remote audio/video tracks keyed by @ref CallbackKey. std::unordered_map, CallbackKeyHash> subscribed_tracks_; + /// Keys whose previous reader has been extracted but not yet joined, with the + /// number of such in-progress drains. No reader is started for a key while + /// it has an entry here. See @ref extractReaderForDrainLocked. + std::unordered_map draining_readers_; + /// Next auto-increment ID for data frame callbacks. DataFrameCallbackId next_data_callback_id_{0}; diff --git a/src/room.cpp b/src/room.cpp index f196f6bb..31b3af57 100644 --- a/src/room.cpp +++ b/src/room.cpp @@ -423,11 +423,17 @@ void Room::setOnAudioFrameCallback(const std::string& participant_identity, cons LK_LOG_ERROR("Room::setOnAudioFrameCallback: subscription_thread_dispatcher_ is nullptr"); return; } - // Installs the callback and stops any reader still dispatching to the previous - // one, so the restart below binds a fresh reader to the new callback. + // Installs the callback, stops and joins any reader still dispatching to the + // previous one, and -- if the dispatcher has already retained the subscribed + // track -- starts a fresh reader bound to the new callback. subscription_thread_dispatcher_->setOnAudioFrameCallback(participant_identity, track_name, std::move(callback), opts); - // If we've already subscribed to the track, handle it immediately + // The dispatcher only retains the track once onEvent has forwarded the + // subscribe event to it, which happens *after* RoomDelegate::onTrackSubscribed + // returns. A callback registered from inside that delegate therefore finds no + // retained track, so resolve the publication here as well. When the dispatcher + // already started the reader this is a same-SID no-op. The video setters below + // follow the same pattern. auto track = findSubscribedRemoteTrack(participant_identity, track_name); if (track) { subscription_thread_dispatcher_->handleTrackSubscribed(participant_identity, track_name, track); diff --git a/src/subscription_thread_dispatcher.cpp b/src/subscription_thread_dispatcher.cpp index de373be5..30e3e2fa 100644 --- a/src/subscription_thread_dispatcher.cpp +++ b/src/subscription_thread_dispatcher.cpp @@ -57,20 +57,26 @@ SubscriptionThreadDispatcher::~SubscriptionThreadDispatcher() { } // NOLINTEND(bugprone-exception-escape) -void SubscriptionThreadDispatcher::disposeMediaReaderThread(std::thread&& thread, const char* operation) { +// ------------------------------------------------------------------- +// Reader thread lifecycle helpers shared by every public entry point +// ------------------------------------------------------------------- + +void SubscriptionThreadDispatcher::disposeReaderThread(std::thread&& thread, const char* operation) { if (!thread.joinable()) { return; } if (isSelfThread(thread.get_id())) { - // The caller IS this reader, so it called us from inside its own frame - // callback. Joining here would be a self-join. Detaching is safe: audio and - // video reader lambdas capture no `this` and own their stream and callback - // by value, so the thread touches nothing owned by the dispatcher once it - // has been extracted. - LK_LOG_ERROR( - "{} was called from inside its own frame callback; detaching the reader " - "instead of self-joining. Registering or clearing a callback from within " - "that callback is not supported", + // The caller IS this reader, so it reached us from inside its own frame + // callback. Joining here would be a self-join (std::system_error, and a + // still-joinable std::thread destroyed during unwinding would terminate + // the process). Detaching is safe: no reader lambda captures `this`; each + // owns its stream, callback, and per-reader state by value, so once + // extracted the thread touches nothing owned by the dispatcher. + LK_LOG_WARN( + "{} was called from inside the frame callback of the reader it stops; " + "detaching that reader instead of self-joining. It exits once the " + "callback returns. Registering, clearing, or tearing down from within a " + "frame callback is discouraged", operation); thread.detach(); return; @@ -78,37 +84,60 @@ void SubscriptionThreadDispatcher::disposeMediaReaderThread(std::thread&& thread thread.join(); } +std::thread SubscriptionThreadDispatcher::extractReaderForDrainLocked(const CallbackKey& key) { + std::thread old_thread = extractReaderThreadLocked(key); + if (old_thread.joinable()) { + // Block every start for this key until finishReaderDrainAndRestart has + // joined (or detached) this thread. Without this, a replacement reader + // could begin invoking the new callback while the old callback is still + // mid-invocation on the thread we are about to join. + ++draining_readers_[key]; + } + return old_thread; +} + +void SubscriptionThreadDispatcher::finishReaderDrainAndRestart(const CallbackKey& key, std::thread old_thread, + const char* operation) { + const bool drained = old_thread.joinable(); + disposeReaderThread(std::move(old_thread), operation); + + const std::scoped_lock lock(lock_); + if (drained) { + auto it = draining_readers_.find(key); + if (it != draining_readers_.end() && --it->second <= 0) { + draining_readers_.erase(it); + } + } + startReaderForSubscribedTrackLocked(key); + LK_LOG_DEBUG("{}: reader restart for participant={} track_name={} drained_previous={} reader_active={}", operation, + key.participant_identity, key.track_name, drained, active_readers_.find(key) != active_readers_.end()); +} + +// ------------------------------------------------------------------- +// Audio/video callback registration +// ------------------------------------------------------------------- + void SubscriptionThreadDispatcher::setOnAudioFrameCallback(const std::string& participant_identity, const std::string& track_name, AudioFrameCallback callback, const AudioStream::Options& opts) { const CallbackKey key{participant_identity, track_name}; std::thread old_thread; - std::thread replaced_thread; - std::exception_ptr start_error; { const std::scoped_lock lock(lock_); // Stop any reader still dispatching to the previous callback. Reader threads // hold their own copy of the callback, so overwriting the registration alone // would leave the old callback receiving frames. - old_thread = extractReaderThreadLocked(key); + old_thread = extractReaderForDrainLocked(key); const bool replacing = audio_callbacks_.find(key) != audio_callbacks_.end(); audio_callbacks_[key] = RegisteredAudioCallback{std::move(callback), opts}; - try { - replaced_thread = startReaderForSubscribedTrackLocked(key, TrackKind::KIND_AUDIO); - } catch (...) { - start_error = std::current_exception(); - } LK_LOG_DEBUG( "Registered audio frame callback for participant={} track_name={} " - "replacing_existing={} stopped_reader={} restarted_reader={} total_audio_callbacks={}", - participant_identity, track_name, replacing, old_thread.joinable(), - active_readers_.find(key) != active_readers_.end(), audio_callbacks_.size()); - } - disposeMediaReaderThread(std::move(old_thread), "setOnAudioFrameCallback"); - disposeMediaReaderThread(std::move(replaced_thread), "setOnAudioFrameCallback"); - if (start_error) { - std::rethrow_exception(start_error); + "replacing_existing={} stopped_reader={} total_audio_callbacks={}", + participant_identity, track_name, replacing, old_thread.joinable(), audio_callbacks_.size()); } + // Joins the previous reader first, then starts a fresh one bound to the new + // callback if the track is subscribed. + finishReaderDrainAndRestart(key, std::move(old_thread), "setOnAudioFrameCallback"); } void SubscriptionThreadDispatcher::setOnVideoFrameEventCallback(const std::string& participant_identity, @@ -117,33 +146,21 @@ void SubscriptionThreadDispatcher::setOnVideoFrameEventCallback(const std::strin const VideoStream::Options& opts) { const CallbackKey key{participant_identity, track_name}; std::thread old_thread; - std::thread replaced_thread; - std::exception_ptr start_error; { const std::scoped_lock lock(lock_); - old_thread = extractReaderThreadLocked(key); + old_thread = extractReaderForDrainLocked(key); const bool replacing = video_callbacks_.find(key) != video_callbacks_.end(); video_callbacks_[key] = RegisteredVideoCallback{ VideoFrameCallback{}, std::move(callback), opts, }; - try { - replaced_thread = startReaderForSubscribedTrackLocked(key, TrackKind::KIND_VIDEO); - } catch (...) { - start_error = std::current_exception(); - } LK_LOG_DEBUG( "Registered video frame event callback for participant={} track_name={} " - "replacing_existing={} stopped_reader={} restarted_reader={} total_video_callbacks={}", - participant_identity, track_name, replacing, old_thread.joinable(), - active_readers_.find(key) != active_readers_.end(), video_callbacks_.size()); - } - disposeMediaReaderThread(std::move(old_thread), "setOnVideoFrameEventCallback"); - disposeMediaReaderThread(std::move(replaced_thread), "setOnVideoFrameEventCallback"); - if (start_error) { - std::rethrow_exception(start_error); + "replacing_existing={} stopped_reader={} total_video_callbacks={}", + participant_identity, track_name, replacing, old_thread.joinable(), video_callbacks_.size()); } + finishReaderDrainAndRestart(key, std::move(old_thread), "setOnVideoFrameEventCallback"); } void SubscriptionThreadDispatcher::setOnVideoFrameCallback(const std::string& participant_identity, @@ -151,33 +168,21 @@ void SubscriptionThreadDispatcher::setOnVideoFrameCallback(const std::string& pa const VideoStream::Options& opts) { const CallbackKey key{participant_identity, track_name}; std::thread old_thread; - std::thread replaced_thread; - std::exception_ptr start_error; { const std::scoped_lock lock(lock_); - old_thread = extractReaderThreadLocked(key); + old_thread = extractReaderForDrainLocked(key); const bool replacing = video_callbacks_.find(key) != video_callbacks_.end(); video_callbacks_[key] = RegisteredVideoCallback{ std::move(callback), VideoFrameEventCallback{}, opts, }; - try { - replaced_thread = startReaderForSubscribedTrackLocked(key, TrackKind::KIND_VIDEO); - } catch (...) { - start_error = std::current_exception(); - } LK_LOG_DEBUG( "Registered video frame callback for participant={} track_name={} " - "replacing_existing={} stopped_reader={} restarted_reader={} total_video_callbacks={}", - participant_identity, track_name, replacing, old_thread.joinable(), - active_readers_.find(key) != active_readers_.end(), video_callbacks_.size()); - } - disposeMediaReaderThread(std::move(old_thread), "setOnVideoFrameCallback"); - disposeMediaReaderThread(std::move(replaced_thread), "setOnVideoFrameCallback"); - if (start_error) { - std::rethrow_exception(start_error); + "replacing_existing={} stopped_reader={} total_video_callbacks={}", + participant_identity, track_name, replacing, old_thread.joinable(), video_callbacks_.size()); } + finishReaderDrainAndRestart(key, std::move(old_thread), "setOnVideoFrameCallback"); } void SubscriptionThreadDispatcher::clearOnAudioFrameCallback(const std::string& participant_identity, @@ -188,13 +193,15 @@ void SubscriptionThreadDispatcher::clearOnAudioFrameCallback(const std::string& { const std::scoped_lock lock(lock_); removed_callback = audio_callbacks_.erase(key) > 0; - old_thread = extractReaderThreadLocked(key); + old_thread = extractReaderForDrainLocked(key); LK_LOG_DEBUG( "Clearing audio frame callback for participant={} track_name={} " "removed_callback={} stopped_reader={} remaining_audio_callbacks={}", participant_identity, track_name, removed_callback, old_thread.joinable(), audio_callbacks_.size()); } - disposeMediaReaderThread(std::move(old_thread), "clearOnAudioFrameCallback"); + // With the registration gone nothing restarts here, unless a concurrent + // caller re-registered while we were joining -- in which case it should. + finishReaderDrainAndRestart(key, std::move(old_thread), "clearOnAudioFrameCallback"); } void SubscriptionThreadDispatcher::clearOnVideoFrameCallback(const std::string& participant_identity, @@ -205,13 +212,13 @@ void SubscriptionThreadDispatcher::clearOnVideoFrameCallback(const std::string& { const std::scoped_lock lock(lock_); removed_callback = video_callbacks_.erase(key) > 0; - old_thread = extractReaderThreadLocked(key); + old_thread = extractReaderForDrainLocked(key); LK_LOG_DEBUG( "Clearing video frame callback for participant={} track_name={} " "removed_callback={} stopped_reader={} remaining_video_callbacks={}", participant_identity, track_name, removed_callback, old_thread.joinable(), video_callbacks_.size()); } - disposeMediaReaderThread(std::move(old_thread), "clearOnVideoFrameCallback"); + finishReaderDrainAndRestart(key, std::move(old_thread), "clearOnVideoFrameCallback"); } void SubscriptionThreadDispatcher::handleTrackSubscribed(const std::string& participant_identity, @@ -231,9 +238,21 @@ void SubscriptionThreadDispatcher::handleTrackSubscribed(const std::string& part { const std::scoped_lock lock(lock_); subscribed_tracks_[key] = track; - old_thread = startReaderLocked(key, track); + auto existing = active_readers_.find(key); + if (existing != active_readers_.end() && existing->second.track_sid == track->sid()) { + // A duplicate track_subscribed for the publication this reader already + // serves. Rebuilding the reader would only churn the stream. + LK_LOG_DEBUG( + "Skipping reader restart for participant={} track_name={} because a " + "reader for sid={} is already active", + participant_identity, track_name, track->sid()); + return; + } + // Either no reader, or a reader for a previous publication (republish + // under the same name): stop it and rebuild against the new track. + old_thread = extractReaderForDrainLocked(key); } - disposeMediaReaderThread(std::move(old_thread), "handleTrackSubscribed"); + finishReaderDrainAndRestart(key, std::move(old_thread), "handleTrackSubscribed"); } void SubscriptionThreadDispatcher::handleTrackUnsubscribed(const std::string& participant_identity, TrackSource source, @@ -243,13 +262,15 @@ void SubscriptionThreadDispatcher::handleTrackUnsubscribed(const std::string& pa { const std::scoped_lock lock(lock_); subscribed_tracks_.erase(key); - old_thread = extractReaderThreadLocked(key); + old_thread = extractReaderForDrainLocked(key); LK_LOG_DEBUG( "Handling unsubscribed track for participant={} source={} " "track_name={} stopped_reader={}", participant_identity, static_cast(source), track_name, old_thread.joinable()); } - disposeMediaReaderThread(std::move(old_thread), "handleTrackUnsubscribed"); + // Nothing restarts here unless the track was re-subscribed while we were + // joining, in which case the retained track is picked up. + finishReaderDrainAndRestart(key, std::move(old_thread), "handleTrackUnsubscribed"); } // ------------------------------------------------------------------- @@ -272,9 +293,7 @@ DataFrameCallbackId SubscriptionThreadDispatcher::addOnDataFrameCallback(const s old_thread = startDataReaderLocked(id, key, track_it->second, data_callbacks_[id].callback); } } - if (old_thread.joinable()) { - old_thread.join(); - } + disposeReaderThread(std::move(old_thread), "addOnDataFrameCallback"); return id; } @@ -285,9 +304,7 @@ void SubscriptionThreadDispatcher::removeOnDataFrameCallback(DataFrameCallbackId data_callbacks_.erase(id); old_thread = extractDataReaderThreadLocked(id); } - if (old_thread.joinable()) { - old_thread.join(); - } + disposeReaderThread(std::move(old_thread), "removeOnDataFrameCallback"); } void SubscriptionThreadDispatcher::handleDataTrackPublished(const std::shared_ptr& track) { @@ -315,7 +332,7 @@ void SubscriptionThreadDispatcher::handleDataTrackPublished(const std::shared_pt } } for (auto& t : old_threads) { - t.join(); + disposeReaderThread(std::move(t), "handleDataTrackPublished"); } } @@ -336,19 +353,6 @@ void SubscriptionThreadDispatcher::handleDataTrackUnpublished(const std::string& reader->stream->close(); } } - if (isSelfThread(reader->thread_id)) { - // Reached from inside this reader's own data frame callback. It is now - // cancelled and its stream is closed, so it will exit on its own; leave - // the slot for stopAll() to reap rather than self-joining. Data readers - // cannot be detached -- they re-enter the dispatcher on the way out. - LK_LOG_ERROR( - "Data reader for callback id={} reached handleDataTrackUnpublished " - "from inside its own data frame callback; leaving the reader in " - "place to exit on its own", - it->first); - ++it; - continue; - } if (reader->thread.joinable()) { old_threads.push_back(std::move(reader->thread)); } @@ -365,15 +369,12 @@ void SubscriptionThreadDispatcher::handleDataTrackUnpublished(const std::string& } } for (auto& t : old_threads) { - t.join(); + disposeReaderThread(std::move(t), "handleDataTrackUnpublished"); } } void SubscriptionThreadDispatcher::stopAll() { - // Media and data reader threads are disposed of differently: media threads may - // be safely detached on a self-join, data threads may not. - std::vector media_threads; - std::vector data_threads; + std::vector threads; { const std::scoped_lock lock(lock_); LK_LOG_DEBUG( @@ -391,7 +392,7 @@ void SubscriptionThreadDispatcher::stopAll() { reader.video_stream->close(); } if (reader.thread.joinable()) { - media_threads.push_back(std::move(reader.thread)); + threads.push_back(std::move(reader.thread)); } } active_readers_.clear(); @@ -409,23 +410,20 @@ void SubscriptionThreadDispatcher::stopAll() { } } if (reader->thread.joinable()) { - data_threads.push_back(std::move(reader->thread)); + threads.push_back(std::move(reader->thread)); } } active_data_readers_.clear(); data_callbacks_.clear(); remote_data_tracks_.clear(); } - for (auto& thread : media_threads) { - disposeMediaReaderThread(std::move(thread), "stopAll"); - } - // Data reader threads re-enter the dispatcher after their callback returns, so - // they must be joined even here. Tearing the room down from inside a data - // frame callback is unsupported and will self-join. - for (auto& thread : data_threads) { - thread.join(); + // A reader that reached stopAll() from inside its own callback (e.g. the + // application called Room::disconnect() from a frame callback) is detached + // rather than self-joined; every other reader is joined. + for (auto& thread : threads) { + disposeReaderThread(std::move(thread), "stopAll"); } - LK_LOG_DEBUG("Stopped {} subscription reader threads", media_threads.size() + data_threads.size()); + LK_LOG_DEBUG("Stopped {} subscription reader threads", threads.size()); } std::thread SubscriptionThreadDispatcher::extractReaderThreadLocked(const CallbackKey& key) { @@ -449,8 +447,18 @@ std::thread SubscriptionThreadDispatcher::extractReaderThreadLocked(const Callba return std::move(reader.thread); } -std::thread SubscriptionThreadDispatcher::startReaderLocked(const CallbackKey& key, - const std::shared_ptr& track) { +void SubscriptionThreadDispatcher::startReaderLocked(const CallbackKey& key, const std::shared_ptr& track) { + if (active_readers_.find(key) != active_readers_.end()) { + // Callers stop the previous reader through the drain protocol before + // getting here, so this indicates a lifecycle bug. Replacing the slot + // would drop a joinable std::thread, so leave the existing reader alone. + LK_LOG_ERROR( + "Refusing to start a reader for participant={} track_name={} because one " + "is already active; the previous reader must be stopped first", + key.participant_identity, key.track_name); + return; + } + if (track->kind() == TrackKind::KIND_AUDIO) { auto it = audio_callbacks_.find(key); if (it == audio_callbacks_.end()) { @@ -458,9 +466,10 @@ std::thread SubscriptionThreadDispatcher::startReaderLocked(const CallbackKey& k "Skipping audio reader start for participant={} track_name={} " "because no audio callback is registered", key.participant_identity, key.track_name); - return {}; + return; } - return startAudioReaderLocked(key, track, it->second.callback, it->second.options); + startAudioReaderLocked(key, track, it->second.callback, it->second.options); + return; } if (track->kind() == TrackKind::KIND_VIDEO) { auto it = video_callbacks_.find(key); @@ -469,65 +478,61 @@ std::thread SubscriptionThreadDispatcher::startReaderLocked(const CallbackKey& k "Skipping video reader start for participant={} track_name={} " "because no video callback is registered", key.participant_identity, key.track_name); - return {}; + return; } - return startVideoReaderLocked(key, track, it->second); + startVideoReaderLocked(key, track, it->second); + return; } if (track->kind() == TrackKind::KIND_UNKNOWN) { LK_LOG_WARN( "Skipping reader start for participant={} track_name={} because track " "kind is unknown", key.participant_identity, key.track_name); - return {}; + return; } LK_LOG_WARN( "Skipping reader start for participant={} track_name={} because track kind " "is unsupported", key.participant_identity, key.track_name); - return {}; } -std::thread SubscriptionThreadDispatcher::startReaderForSubscribedTrackLocked(const CallbackKey& key, TrackKind kind) { +void SubscriptionThreadDispatcher::startReaderForSubscribedTrackLocked(const CallbackKey& key) { if (active_readers_.find(key) != active_readers_.end()) { - return {}; + return; + } + if (draining_readers_.find(key) != draining_readers_.end()) { + // Another caller is still joining the previous reader for this key. It + // will start the reader once the join completes; starting one here would + // let the new callback overlap the old one. + LK_LOG_TRACE("Deferring reader start for participant={} track_name={} until the previous reader is joined", + key.participant_identity, key.track_name); + return; } const auto track_it = subscribed_tracks_.find(key); - if (track_it == subscribed_tracks_.end() || !track_it->second || track_it->second->kind() != kind) { - return {}; + if (track_it == subscribed_tracks_.end() || !track_it->second) { + return; } - return startReaderLocked(key, track_it->second); + startReaderLocked(key, track_it->second); } -std::thread SubscriptionThreadDispatcher::startAudioReaderLocked(const CallbackKey& key, - const std::shared_ptr& track, - const AudioFrameCallback& cb, - const AudioStream::Options& opts) { +void SubscriptionThreadDispatcher::startAudioReaderLocked(const CallbackKey& key, const std::shared_ptr& track, + const AudioFrameCallback& cb, + const AudioStream::Options& opts) { LK_LOG_DEBUG("Starting audio reader for participant={} track_name={}", key.participant_identity, key.track_name); - auto existing = active_readers_.find(key); - if (existing != active_readers_.end() && existing->second.track_sid == track->sid()) { - LK_LOG_DEBUG( - "Skipping audio reader start for participant={} track_name={} because a " - "reader for sid={} is already active", - key.participant_identity, key.track_name, track->sid()); - return {}; - } - - auto old_thread = extractReaderThreadLocked(key); - if (static_cast(active_readers_.size()) >= kMaxActiveReaders) { LK_LOG_ERROR( "Cannot start audio reader for {} track_name={}: active reader limit ({}) " "reached", key.participant_identity, key.track_name, kMaxActiveReaders); - return old_thread; + return; } const auto stream = AudioStream::fromTrack(track, opts); if (!stream) { LK_LOG_ERROR("Failed to create AudioStream for {} track_name={}", key.participant_identity, key.track_name); - return old_thread; + return; } ActiveReader reader; @@ -541,6 +546,8 @@ std::thread SubscriptionThreadDispatcher::startAudioReaderLocked(const CallbackK // thread cannot std::terminate the process. clang-tidy still flags a // residual escape path through spdlog's own formatter; that's a logger // fault, not application logic -- suppressed at the lambda level. + // + // Deliberately captures no `this`: see disposeReaderThread. reader.thread = std::thread([stream, cb, participant_identity, track_name]() { try { LK_LOG_DEBUG("Audio reader thread started for participant={} track_name={}", participant_identity, track_name); @@ -566,37 +573,24 @@ std::thread SubscriptionThreadDispatcher::startAudioReaderLocked(const CallbackK "Started audio reader for participant={} track_name={} " "active_readers={}", key.participant_identity, key.track_name, active_readers_.size()); - return old_thread; } -std::thread SubscriptionThreadDispatcher::startVideoReaderLocked(const CallbackKey& key, - const std::shared_ptr& track, - const RegisteredVideoCallback& callback) { +void SubscriptionThreadDispatcher::startVideoReaderLocked(const CallbackKey& key, const std::shared_ptr& track, + const RegisteredVideoCallback& callback) { LK_LOG_DEBUG("Starting video reader for participant={} track_name={}", key.participant_identity, key.track_name); - auto existing = active_readers_.find(key); - if (existing != active_readers_.end() && existing->second.track_sid == track->sid()) { - LK_LOG_DEBUG( - "Skipping video reader start for participant={} track_name={} because a " - "reader for sid={} is already active", - key.participant_identity, key.track_name, track->sid()); - return {}; - } - - auto old_thread = extractReaderThreadLocked(key); - if (static_cast(active_readers_.size()) >= kMaxActiveReaders) { LK_LOG_ERROR( "Cannot start video reader for {} track_name={}: active reader limit ({}) " "reached", key.participant_identity, key.track_name, kMaxActiveReaders); - return old_thread; + return; } auto stream = VideoStream::fromTrack(track, callback.options); if (!stream) { LK_LOG_ERROR("Failed to create VideoStream for {} track_name={}", key.participant_identity, key.track_name); - return old_thread; + return; } ActiveReader reader; @@ -610,6 +604,8 @@ std::thread SubscriptionThreadDispatcher::startVideoReaderLocked(const CallbackK // Mirrors the audio reader: outer try/catch contains escapes from // stream->read, LK_LOG, etc. Residual diagnostic from spdlog's own // formatter is an unrelated logger-fault path and is suppressed. + // + // Deliberately captures no `this`: see disposeReaderThread. reader.thread = std::thread([stream = std::move(stream), legacy_cb, event_cb, participant_identity, track_name]() { try { LK_LOG_DEBUG("Video reader thread started for participant={} track_name={}", participant_identity, track_name); @@ -639,7 +635,6 @@ std::thread SubscriptionThreadDispatcher::startVideoReaderLocked(const CallbackK "Started video reader for participant={} track_name={} " "active_readers={}", key.participant_identity, key.track_name, active_readers_.size()); - return old_thread; } // ------------------------------------------------------------------- @@ -651,19 +646,6 @@ std::thread SubscriptionThreadDispatcher::extractDataReaderThreadLocked(DataFram if (it == active_data_readers_.end()) { return {}; } - if (it->second && isSelfThread(it->second->thread_id)) { - // The caller IS this reader, so it reached us from inside its own data frame - // callback. Joining would be a self-join, and unlike media readers a data - // reader cannot be detached: it re-enters the dispatcher after the callback - // returns. Leave the slot in place -- the reader exits on its own once its - // stream closes, and stopAll() reaps it. - LK_LOG_ERROR( - "Data reader for callback id={} tried to tear itself down from inside its " - "own data frame callback; leaving the reader in place. Removing a data " - "callback from within that callback is not supported", - id); - return {}; - } auto reader = std::move(it->second); active_data_readers_.erase(it); // Mark cancelled before closing to guard in flight subscriptions @@ -674,29 +656,23 @@ std::thread SubscriptionThreadDispatcher::extractDataReaderThreadLocked(DataFram reader->stream->close(); } } + // If the caller is this very reader (removal from inside its own callback), + // disposeReaderThread detaches instead of self-joining. The stream is already + // closed, so the reader exits as soon as the callback returns. return std::move(reader->thread); } -void SubscriptionThreadDispatcher::markDataReaderFinishedIfCurrent(DataFrameCallbackId id, - const std::shared_ptr& reader) { - const std::scoped_lock lock(lock_); - auto it = active_data_readers_.find(id); - if (it == active_data_readers_.end() || it->second != reader) { - // The slot was already extracted or replaced; the owner joins that thread. - return; - } +void SubscriptionThreadDispatcher::markDataReaderFinished(const std::shared_ptr& reader) { reader->finished = true; - { - const std::scoped_lock guard(reader->sub_mutex); - reader->stream.reset(); - } + const std::scoped_lock guard(reader->sub_mutex); + reader->stream.reset(); } std::thread SubscriptionThreadDispatcher::startDataReaderLocked(DataFrameCallbackId id, const DataCallbackKey& key, const std::shared_ptr& track, const DataFrameCallback& cb) { auto existing = active_data_readers_.find(id); - if (existing != active_data_readers_.end() && !existing->second->finished && existing->second->remote_track && + if (existing != active_data_readers_.end() && !existing->second->finished.load() && existing->second->remote_track && existing->second->remote_track->info().sid == track->info().sid) { LK_LOG_DEBUG( "Skipping data reader start for \"{}\" track=\"{}\" because a reader for " @@ -723,7 +699,10 @@ std::thread SubscriptionThreadDispatcher::startDataReaderLocked(DataFrameCallbac auto identity = key.participant_identity; auto track_name = key.track_name; // NOLINTBEGIN(bugprone-lambda-function-name) - reader->thread = std::thread([this, id, reader, track, cb, identity, track_name]() { + // Deliberately captures no `this`: the thread reports its exit through the + // shared ActiveDataReader only, which is what allows disposeReaderThread to + // detach it when torn down from inside its own callback. + reader->thread = std::thread([reader, track, cb, identity, track_name]() { LK_LOG_INFO("Data reader thread: subscribing to \"{}\" track=\"{}\"", identity, track_name); std::shared_ptr stream; auto subscribe_result = track->subscribe(); @@ -733,7 +712,7 @@ std::thread SubscriptionThreadDispatcher::startDataReaderLocked(DataFrameCallbac "Failed to subscribe to data track \"{}\" from \"{}\": code={} " "message={}", track_name, identity, static_cast(error.code), error.message); - markDataReaderFinishedIfCurrent(id, reader); + markDataReaderFinished(reader); return; } stream = subscribe_result.value(); @@ -753,10 +732,7 @@ std::thread SubscriptionThreadDispatcher::startDataReaderLocked(DataFrameCallbac } } if (cancelled) { - // Mirror the normal-exit cleanup below. Done outside sub_mutex to keep the - // lock_ -> sub_mutex order and avoid inversion; a no-op unless this reader - // still owns its slot. - markDataReaderFinishedIfCurrent(id, reader); + markDataReaderFinished(reader); return; } @@ -775,9 +751,10 @@ std::thread SubscriptionThreadDispatcher::startDataReaderLocked(DataFrameCallbac "\"{}\": code={} message={}", track_name, identity, static_cast(error->code), error->message); } - // Mark our own slot finished if the stream ended on its own (server EOS) - // and no extract/teardown already claimed it. A no-op when we were extracted. - markDataReaderFinishedIfCurrent(id, reader); + // Whether the stream ended on its own (server EOS) or was closed by an + // extract/teardown, record that this reader is done so a slot still + // holding it is treated as replaceable. + markDataReaderFinished(reader); LK_LOG_INFO("Data reader thread exiting for \"{}\" track=\"{}\"", identity, track_name); }); // NOLINTEND(bugprone-lambda-function-name) diff --git a/src/tests/common/room_test_access.h b/src/tests/common/room_test_access.h index 3289e85e..08182813 100644 --- a/src/tests/common/room_test_access.h +++ b/src/tests/common/room_test_access.h @@ -90,6 +90,33 @@ struct RoomTestAccess { const std::scoped_lock guard(dispatcher->lock_); return dispatcher->active_data_readers_.size(); } + + /// Whether the room's dispatcher has retained a subscribed audio/video track + /// for the given participant and track name. This retention is what lets a + /// frame callback registered after the subscription event start a reader + /// immediately (GitHub issue #235). + static bool hasRetainedSubscribedTrack(const Room& room, const std::string& participant_identity, + const std::string& track_name) { + const auto& dispatcher = room.subscription_thread_dispatcher_; + if (!dispatcher) { + return false; + } + const std::scoped_lock guard(dispatcher->lock_); + const SubscriptionThreadDispatcher::CallbackKey key{participant_identity, track_name}; + const auto it = dispatcher->subscribed_tracks_.find(key); + return it != dispatcher->subscribed_tracks_.end() && it->second != nullptr; + } + + /// Number of audio/video keys whose previous reader is being joined. Zero + /// whenever no replacement, clear, or resubscribe is mid-flight. + static std::size_t drainingReaderCount(const Room& room) { + const auto& dispatcher = room.subscription_thread_dispatcher_; + if (!dispatcher) { + return 0; + } + const std::scoped_lock guard(dispatcher->lock_); + return dispatcher->draining_readers_.size(); + } }; #if defined(__clang__) || defined(__GNUC__) diff --git a/src/tests/integration/test_frame_callback_replacement.cpp b/src/tests/integration/test_frame_callback_replacement.cpp index ef8774b9..f1f7ac5b 100644 --- a/src/tests/integration/test_frame_callback_replacement.cpp +++ b/src/tests/integration/test_frame_callback_replacement.cpp @@ -387,6 +387,56 @@ TEST_F(FrameCallbackReplacementTest, SetOnVideoFrameCallbackBlocksUntilSlowCallb fixture.teardown(); } +// Replacement must join the previous reader *before* starting the new one, so +// the two callbacks never execute concurrently. Here callback A is mid-flight +// (sleeping) when B is registered. If B is ever invoked while A is still inside +// its invocation, the documented ordering is broken -- which is exactly what +// happened when the replacement reader was started before the join. +TEST_F(FrameCallbackReplacementTest, ReplacementCallbackNeverOverlapsPreviousCallback) { + failIfNotConfigured(); + + VideoFixture fixture(config_.url, config_.token_a, config_.token_b); + ASSERT_TRUE(fixture.connected()); + + constexpr auto kSlowCallbackDuration = 1500ms; + std::atomic slow_entered{false}; + std::atomic slow_in_flight{false}; + std::atomic overlap_detected{false}; + std::atomic fast_frames{0}; + + fixture.receiver().setOnVideoFrameCallback(fixture.senderIdentity(), fixture.trackName(), + [&](const VideoFrame&, std::int64_t) { + slow_in_flight.store(true); + slow_entered.store(true); + std::this_thread::sleep_for(kSlowCallbackDuration); + slow_in_flight.store(false); + }); + + ASSERT_TRUE(fixture.publishAndAwaitSubscription()); + ASSERT_TRUE(waitFor([&]() { return slow_entered.load(); }, kFrameTimeout)) + << "Slow callback never started an invocation"; + + const bool completed = completesWithoutDeadlock([&]() { + fixture.receiver().setOnVideoFrameCallback(fixture.senderIdentity(), fixture.trackName(), + [&](const VideoFrame&, std::int64_t) { + if (slow_in_flight.load()) { + overlap_detected.store(true); + } + fast_frames.fetch_add(1); + }); + }); + ASSERT_TRUE(completed) << "setOnVideoFrameCallback did not return"; + + EXPECT_TRUE(waitFor([&]() { return fast_frames.load() > 0; }, kFrameTimeout)) + << "Replacement callback never received a frame"; + EXPECT_FALSE(overlap_detected.load()) + << "Replacement callback was invoked while the previous callback was still executing"; + EXPECT_EQ(RoomTestAccess::activeReaderCount(fixture.receiver()), 1u); + EXPECT_EQ(RoomTestAccess::drainingReaderCount(fixture.receiver()), 0u); + + fixture.teardown(); +} + // The join happens outside the dispatcher lock, so a slow callback on one // subscription must not stall readers for other subscriptions. TEST_F(FrameCallbackReplacementTest, SlowCallbackDoesNotStallOtherSubscriptionReaders) { @@ -640,7 +690,8 @@ TEST_F(FrameCallbackReplacementTest, ReplacementSurvivesUnpublishAndRepublish) { // ============================================================================ // Registering from inside the frame callback would make the join a self-join. -// Media readers are detached instead, so the call must return rather than hang. +// The reader is detached instead, so the call must return rather than hang -- +// and the replacement must still take effect once the detached reader exits. TEST_F(FrameCallbackReplacementTest, SetOnVideoFrameCallbackFromInsideCallbackDoesNotDeadlock) { failIfNotConfigured(); @@ -649,10 +700,12 @@ TEST_F(FrameCallbackReplacementTest, SetOnVideoFrameCallbackFromInsideCallbackDo std::atomic reentrant_call_returned{false}; std::atomic attempted{false}; + std::atomic original_frames{0}; std::atomic replacement_frames{0}; fixture.receiver().setOnVideoFrameCallback( fixture.senderIdentity(), fixture.trackName(), [&](const VideoFrame&, std::int64_t) { + original_frames.fetch_add(1); if (attempted.exchange(true)) { return; } @@ -666,6 +719,11 @@ TEST_F(FrameCallbackReplacementTest, SetOnVideoFrameCallbackFromInsideCallbackDo EXPECT_TRUE(waitFor([&]() { return reentrant_call_returned.load(); }, kNoDeadlockTimeout)) << "Re-entrant setOnVideoFrameCallback never returned; the reader self-joined"; + EXPECT_TRUE(waitFor([&]() { return replacement_frames.load() > 0; }, kFrameTimeout)) + << "The re-entrant registration never took effect"; + EXPECT_TRUE(wentQuiet(original_frames)) << "The detached original reader is still delivering frames"; + EXPECT_EQ(RoomTestAccess::activeReaderCount(fixture.receiver()), 1u); + // The detached reader exits on its own, and teardown must still complete. const bool torn_down = completesWithoutDeadlock([&]() { fixture.teardown(); }); EXPECT_TRUE(torn_down) << "Teardown deadlocked after a re-entrant registration"; @@ -697,10 +755,11 @@ TEST_F(FrameCallbackReplacementTest, ClearOnVideoFrameCallbackFromInsideCallback EXPECT_TRUE(torn_down) << "Teardown deadlocked after a re-entrant clear"; } -// Data readers re-enter the dispatcher after their callback returns, so they -// cannot be detached. The re-entrant removal is refused and the reader is left -// for teardown to reap -- which must still join cleanly. -TEST_F(FrameCallbackReplacementTest, RemoveDataCallbackFromInsideDataCallbackIsRefusedWithoutDeadlock) { +// Removing a data callback from inside that callback would make the join a +// self-join. The reader is cancelled, its stream closed, and its thread +// detached instead -- so the removal takes effect (the callback stops being +// invoked) and teardown afterwards is clean. +TEST_F(FrameCallbackReplacementTest, RemoveDataCallbackFromInsideDataCallbackStopsDelivery) { failIfNotConfigured(); Room sender_room; @@ -715,10 +774,12 @@ TEST_F(FrameCallbackReplacementTest, RemoveDataCallbackFromInsideDataCallbackIsR const std::string track_name = "reentrant-data"; std::atomic reentrant_call_returned{false}; std::atomic attempted{false}; + std::atomic invocations{0}; DataFrameCallbackId callback_id = 0; callback_id = receiver_room.addOnDataFrameCallback( sender_identity, track_name, [&](const std::vector&, std::optional) { + invocations.fetch_add(1); if (attempted.exchange(true)) { return; } @@ -743,15 +804,82 @@ TEST_F(FrameCallbackReplacementTest, RemoveDataCallbackFromInsideDataCallbackIsR EXPECT_TRUE(waitFor([&]() { return reentrant_call_returned.load(); }, kNoDeadlockTimeout)) << "Re-entrant removeOnDataFrameCallback never returned; the data reader self-joined"; + EXPECT_EQ(RoomTestAccess::activeDataReaderCount(receiver_room), 0u) + << "Removal from inside the callback must still release the reader slot"; + EXPECT_TRUE(wentQuiet(invocations)) << "The removed data callback is still being invoked"; + pushing.store(false, std::memory_order_relaxed); pusher.join(); - // The refused removal left the reader in place; disconnect must still reap it. const bool disconnected = completesWithoutDeadlock([&]() { local_track->unpublishDataTrack(); receiver_room.disconnect(); }); - EXPECT_TRUE(disconnected) << "Disconnect deadlocked while reaping the refused data reader"; + EXPECT_TRUE(disconnected) << "Disconnect deadlocked after a re-entrant data callback removal"; +} + +// Room::disconnect() from inside a data frame callback reaches the dispatcher's +// stopAll() on the reader's own thread. A self-join there throws +// std::system_error, and the still-joinable std::thread destroyed during the +// unwind terminates the process. The reader must be detached instead, and the +// disconnect must complete normally. +TEST_F(FrameCallbackReplacementTest, DisconnectFromInsideDataCallbackDoesNotCrashOrDeadlock) { + failIfNotConfigured(); + + Room sender_room; + Room receiver_room; + const RoomOptions options; + ASSERT_TRUE(receiver_room.connect(config_.url, config_.token_b, options)); + ASSERT_TRUE(sender_room.connect(config_.url, config_.token_a, options)); + + const std::string sender_identity = lockLocalParticipant(sender_room)->identity(); + ASSERT_TRUE(waitForParticipant(&receiver_room, sender_identity, kSubscribeTimeout)); + + const std::string track_name = "disconnect-from-data"; + std::atomic attempted{false}; + std::atomic disconnect_returned{false}; + std::atomic disconnect_result{false}; + std::atomic callback_exited{false}; + + receiver_room.addOnDataFrameCallback(sender_identity, track_name, + [&](const std::vector&, std::optional) { + if (attempted.exchange(true)) { + return; + } + disconnect_result.store(receiver_room.disconnect()); + disconnect_returned.store(true); + callback_exited.store(true); + }); + + auto publish_result = lockLocalParticipant(sender_room)->publishDataTrack(track_name); + ASSERT_TRUE(publish_result) << "Failed to publish data track"; + auto local_track = publish_result.value(); + + std::atomic pushing{true}; + std::thread pusher([&]() { + DataTrackFrame frame; + frame.payload.assign(32, 0x5A); + while (pushing.load(std::memory_order_relaxed)) { + (void)local_track->tryPush(frame); + std::this_thread::sleep_for(50ms); + } + }); + + EXPECT_TRUE(waitFor([&]() { return disconnect_returned.load(); }, kNoDeadlockTimeout)) + << "Room::disconnect() from inside the data callback never returned"; + EXPECT_TRUE(disconnect_result.load()) << "disconnect() from inside the callback reported failure"; + EXPECT_EQ(receiver_room.connectionState(), ConnectionState::Disconnected); + EXPECT_EQ(RoomTestAccess::activeDataReaderCount(receiver_room), 0u); + + pushing.store(false, std::memory_order_relaxed); + pusher.join(); + + // Give the detached reader a moment to run off the end of its loop before the + // room and this test's state go out of scope. + EXPECT_TRUE(waitFor([&]() { return callback_exited.load(); }, kNoDeadlockTimeout)); + std::this_thread::sleep_for(200ms); + + local_track->unpublishDataTrack(); } } // namespace livekit::test diff --git a/src/tests/integration/test_frame_callbacks.cpp b/src/tests/integration/test_frame_callbacks.cpp index d9b09c69..a3427be6 100644 --- a/src/tests/integration/test_frame_callbacks.cpp +++ b/src/tests/integration/test_frame_callbacks.cpp @@ -14,108 +14,354 @@ * limitations under the License. */ +/// @file test_frame_callbacks.cpp +/// @brief Regression coverage for GitHub issue #235. +/// +/// A frame callback registered *after* the track_subscribed event -- from a +/// GUI thread, say, once RoomDelegate::onTrackSubscribed has already returned +/// -- used to register fine but never start a reader thread, so the callback +/// was never invoked. Registration must start a reader from the retained +/// subscription regardless of which side of the event it lands on, and +/// regardless of whether the track is audio or video. + #include #include #include -#include #include -#include +#include +#include +#include #include +#include "tests/common/audio_utils.h" +#include "tests/common/room_test_access.h" #include "tests/common/test_common.h" namespace livekit::test { using namespace std::chrono_literals; -class FrameCallbackServerTest : public LiveKitTestBase {}; +namespace { -TEST_F(FrameCallbackServerTest, VideoCallbackRegisteredAfterSubscriptionReceivesFrames) { - failIfNotConfigured(); +constexpr auto kSubscribeTimeout = 15s; +constexpr auto kFrameTimeout = 15s; +constexpr int kFrameWidth = 16; +constexpr int kFrameHeight = 16; + +template +bool waitFor(Predicate predicate, std::chrono::milliseconds timeout) { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline) { + if (predicate()) { + return true; + } + std::this_thread::sleep_for(10ms); + } + return predicate(); +} + +/// Wait until @p room reports a subscribed track named @p track_name of @p kind +/// published by @p identity. This is the state the issue describes: the +/// subscription event has been fully processed and the delegate has returned. +bool waitForSubscribedTrack(Room& room, const std::string& identity, const std::string& track_name, TrackKind kind, + std::chrono::milliseconds timeout) { + return waitFor( + [&]() { + auto participant = room.remoteParticipant(identity).lock(); + if (participant == nullptr) { + return false; + } + for (const auto& [sid, publication] : participant->trackPublications()) { + (void)sid; + if (publication == nullptr || publication->name() != track_name || publication->kind() != kind) { + continue; + } + if (publication->subscribed() && publication->track() != nullptr) { + return true; + } + } + return false; + }, + timeout); +} - Room receiver_room; - Room sender_room; - RoomOptions options; - options.auto_subscribe = true; +/// Run @p action on a freshly spawned thread and wait for it -- the shape of +/// the original report, where registration came from a GUI thread rather than +/// from the FFI event thread that delivers onTrackSubscribed. +void registerFromAnotherThread(const std::function& action) { + std::thread registrar(action); + registrar.join(); +} - ASSERT_TRUE(receiver_room.connect(config_.url, config_.token_b, options)); - ASSERT_TRUE(sender_room.connect(config_.url, config_.token_a, options)); +/// Two connected rooms. The receiver has no frame callback registered until a +/// test explicitly asks for one; the sender can publish one video and one audio +/// track and keeps them fed until stopped. +class LateRegistrationFixture { +public: + LateRegistrationFixture(const std::string& url, const std::string& token_a, const std::string& token_b) { + RoomOptions options; + options.auto_subscribe = true; + connected_ = receiver_.connect(url, token_b, options) && sender_.connect(url, token_a, options); + if (!connected_) { + return; + } + if (sender_.localParticipant().expired() || receiver_.localParticipant().expired()) { + connected_ = false; + return; + } + sender_identity_ = lockLocalParticipant(sender_)->identity(); + connected_ = waitForParticipant(&receiver_, sender_identity_, kSubscribeTimeout); + } - const std::string sender_identity = lockLocalParticipant(sender_room)->identity(); - ASSERT_TRUE(waitForParticipant(&receiver_room, sender_identity, 10s)); + LateRegistrationFixture(const LateRegistrationFixture&) = delete; + LateRegistrationFixture& operator=(const LateRegistrationFixture&) = delete; - const std::string track_name = "late-video-callback"; - auto source = std::make_shared(16, 16); - auto track = LocalVideoTrack::createLocalVideoTrack(track_name, source); - - TrackPublishOptions publish_options; - publish_options.source = TrackSource::SOURCE_CAMERA; - publish_options.simulcast = false; - ASSERT_NO_THROW(lockLocalParticipant(sender_room)->publishTrack(track, publish_options)); - - const auto subscription_deadline = std::chrono::steady_clock::now() + 10s; - bool subscribed = false; - while (std::chrono::steady_clock::now() < subscription_deadline && !subscribed) { - auto sender_on_receiver = receiver_room.remoteParticipant(sender_identity).lock(); - if (sender_on_receiver != nullptr) { - for (const auto& [sid, publication] : sender_on_receiver->trackPublications()) { - (void)sid; - if (publication != nullptr && publication->name() == track_name && publication->subscribed() && - publication->track() != nullptr) { - subscribed = true; + ~LateRegistrationFixture() { stop(); } + + bool connected() const { return connected_; } + Room& receiver() { return receiver_; } + Room& sender() { return sender_; } + const std::string& senderIdentity() const { return sender_identity_; } + + /// Publish a video track and block until the receiver reports it subscribed. + bool publishVideoAndAwaitSubscription(const std::string& track_name) { + video_source_ = std::make_shared(kFrameWidth, kFrameHeight); + video_track_ = LocalVideoTrack::createLocalVideoTrack(track_name, video_source_); + + TrackPublishOptions publish_options; + publish_options.source = TrackSource::SOURCE_CAMERA; + publish_options.simulcast = false; + lockLocalParticipant(sender_)->publishTrack(video_track_, publish_options); + + video_thread_ = std::thread([this]() { + VideoFrame frame = VideoFrame::create(kFrameWidth, kFrameHeight, VideoBufferType::RGBA); + std::fill(frame.data(), frame.data() + frame.dataSize(), 0x7f); + while (running_.load(std::memory_order_relaxed)) { + try { + video_source_->captureFrame(frame); + } catch (...) { break; } + std::this_thread::sleep_for(50ms); } + }); + + return waitForSubscribedTrack(receiver_, sender_identity_, track_name, TrackKind::KIND_VIDEO, kSubscribeTimeout); + } + + /// Publish an audio track and block until the receiver reports it subscribed. + bool publishAudioAndAwaitSubscription(const std::string& track_name) { + audio_source_ = std::make_shared(kDefaultAudioSampleRate, kDefaultAudioChannels); + audio_track_ = LocalAudioTrack::createLocalAudioTrack(track_name, audio_source_); + + TrackPublishOptions publish_options; + publish_options.source = TrackSource::SOURCE_MICROPHONE; + lockLocalParticipant(sender_)->publishTrack(audio_track_, publish_options); + + audio_thread_ = std::thread([this]() { runToneLoop(audio_source_, running_, 440.0, /*siren_mode=*/false); }); + + return waitForSubscribedTrack(receiver_, sender_identity_, track_name, TrackKind::KIND_AUDIO, kSubscribeTimeout); + } + + /// Stop feeding frames, drop the receiver's callbacks, and unpublish. + void stop() { + running_.store(false, std::memory_order_relaxed); + if (video_thread_.joinable()) { + video_thread_.join(); } - if (!subscribed) { - std::this_thread::sleep_for(10ms); + if (audio_thread_.joinable()) { + audio_thread_.join(); + } + if (video_track_ != nullptr) { + receiver_.clearOnVideoFrameCallback(sender_identity_, video_track_->name()); + if (video_track_->publication()) { + lockLocalParticipant(sender_)->unpublishTrack(video_track_->publication()->sid()); + } + video_track_.reset(); } + if (audio_track_ != nullptr) { + receiver_.clearOnAudioFrameCallback(sender_identity_, audio_track_->name()); + if (audio_track_->publication()) { + lockLocalParticipant(sender_)->unpublishTrack(audio_track_->publication()->sid()); + } + audio_track_.reset(); + } + } + +private: + Room sender_; + Room receiver_; + std::string sender_identity_; + bool connected_ = false; + std::atomic running_{true}; + std::shared_ptr video_source_; + std::shared_ptr video_track_; + std::thread video_thread_; + std::shared_ptr audio_source_; + std::shared_ptr audio_track_; + std::thread audio_thread_; +}; + +/// Registers the video callback from inside onTrackSubscribed -- the ordering +/// that always worked and must keep working. +class RegisterInDelegate : public RoomDelegate { +public: + RegisterInDelegate(std::string track_name, std::atomic& frames) + : track_name_(std::move(track_name)), frames_(frames) {} + + void onTrackSubscribed(Room& room, const TrackSubscribedEvent& event) override { + if (event.publication == nullptr || event.participant == nullptr || event.publication->name() != track_name_) { + return; + } + room.setOnVideoFrameCallback(event.participant->identity(), track_name_, + [this](const VideoFrame&, std::int64_t) { frames_.fetch_add(1); }); + registered_.store(true); } - ASSERT_TRUE(subscribed) << "Timed out waiting for the remote video subscription"; - - std::mutex frame_mutex; - std::condition_variable frame_cv; - int received_frames = 0; - std::thread registrar([&]() { - receiver_room.setOnVideoFrameCallback(sender_identity, track_name, [&](const VideoFrame&, std::int64_t) { - { - const std::scoped_lock lock(frame_mutex); - ++received_frames; + + bool registered() const { return registered_.load(); } + +private: + std::string track_name_; + std::atomic& frames_; + std::atomic registered_{false}; +}; + +} // namespace + +class FrameCallbackServerTest : public LiveKitTestBase {}; + +// The exact scenario from the issue: the receiver only registers once the +// subscription is complete, and does so from a thread that is not the one that +// delivered the event. +TEST_F(FrameCallbackServerTest, VideoCallbackRegisteredAfterSubscriptionReceivesFrames) { + failIfNotConfigured(); + + LateRegistrationFixture fixture(config_.url, config_.token_a, config_.token_b); + ASSERT_TRUE(fixture.connected()); + + const std::string track_name = "late-video-callback"; + ASSERT_TRUE(fixture.publishVideoAndAwaitSubscription(track_name)) + << "Timed out waiting for the remote video subscription"; + ASSERT_TRUE(RoomTestAccess::hasRetainedSubscribedTrack(fixture.receiver(), fixture.senderIdentity(), track_name)) + << "The dispatcher must retain the subscription so a late registration can start a reader"; + ASSERT_EQ(RoomTestAccess::activeReaderCount(fixture.receiver()), 0u) << "No callback yet, so no reader"; + + std::atomic received_frames{0}; + registerFromAnotherThread([&]() { + fixture.receiver().setOnVideoFrameCallback(fixture.senderIdentity(), track_name, + [&](const VideoFrame&, std::int64_t) { received_frames.fetch_add(1); }); + }); + + EXPECT_EQ(RoomTestAccess::activeReaderCount(fixture.receiver()), 1u) + << "Late registration must start the reader immediately, not wait for another subscribe event"; + EXPECT_TRUE(waitFor([&]() { return received_frames.load() > 0; }, kFrameTimeout)) + << "No video frames arrived after late callback registration"; +} + +TEST_F(FrameCallbackServerTest, VideoFrameEventCallbackRegisteredAfterSubscriptionReceivesFrames) { + failIfNotConfigured(); + + LateRegistrationFixture fixture(config_.url, config_.token_a, config_.token_b); + ASSERT_TRUE(fixture.connected()); + + const std::string track_name = "late-video-event-callback"; + ASSERT_TRUE(fixture.publishVideoAndAwaitSubscription(track_name)); + ASSERT_EQ(RoomTestAccess::activeReaderCount(fixture.receiver()), 0u); + + std::atomic received_events{0}; + registerFromAnotherThread([&]() { + fixture.receiver().setOnVideoFrameEventCallback(fixture.senderIdentity(), track_name, + [&](const VideoFrameEvent&) { received_events.fetch_add(1); }); + }); + + EXPECT_EQ(RoomTestAccess::activeReaderCount(fixture.receiver()), 1u); + EXPECT_TRUE(waitFor([&]() { return received_events.load() > 0; }, kFrameTimeout)) + << "No video frame events arrived after late callback registration"; +} + +TEST_F(FrameCallbackServerTest, AudioCallbackRegisteredAfterSubscriptionReceivesFrames) { + failIfNotConfigured(); + + LateRegistrationFixture fixture(config_.url, config_.token_a, config_.token_b); + ASSERT_TRUE(fixture.connected()); + + const std::string track_name = "late-audio-callback"; + ASSERT_TRUE(fixture.publishAudioAndAwaitSubscription(track_name)) + << "Timed out waiting for the remote audio subscription"; + ASSERT_TRUE(RoomTestAccess::hasRetainedSubscribedTrack(fixture.receiver(), fixture.senderIdentity(), track_name)); + ASSERT_EQ(RoomTestAccess::activeReaderCount(fixture.receiver()), 0u); + + std::atomic received_frames{0}; + registerFromAnotherThread([&]() { + fixture.receiver().setOnAudioFrameCallback(fixture.senderIdentity(), track_name, [&](const AudioFrame& frame) { + if (frame.totalSamples() > 0) { + received_frames.fetch_add(1); } - frame_cv.notify_all(); }); }); - registrar.join(); - std::atomic publishing{true}; - std::thread publisher([&]() { - VideoFrame frame = VideoFrame::create(16, 16, VideoBufferType::RGBA); - std::fill(frame.data(), frame.data() + frame.dataSize(), 0x7f); - while (publishing.load(std::memory_order_relaxed)) { - try { - source->captureFrame(frame); - } catch (...) { - publishing.store(false, std::memory_order_relaxed); - break; - } - std::this_thread::sleep_for(50ms); - } + EXPECT_EQ(RoomTestAccess::activeReaderCount(fixture.receiver()), 1u); + EXPECT_TRUE(waitFor([&]() { return received_frames.load() > 0; }, kFrameTimeout)) + << "No audio frames arrived after late callback registration"; +} + +// The subscription event is retained, not consumed: a late registration must +// also survive being cleared and registered again without another event. +TEST_F(FrameCallbackServerTest, ClearingAndReRegisteringAfterSubscriptionRestartsDelivery) { + failIfNotConfigured(); + + LateRegistrationFixture fixture(config_.url, config_.token_a, config_.token_b); + ASSERT_TRUE(fixture.connected()); + + const std::string track_name = "late-video-reregister"; + ASSERT_TRUE(fixture.publishVideoAndAwaitSubscription(track_name)); + + std::atomic first_frames{0}; + fixture.receiver().setOnVideoFrameCallback(fixture.senderIdentity(), track_name, + [&](const VideoFrame&, std::int64_t) { first_frames.fetch_add(1); }); + ASSERT_TRUE(waitFor([&]() { return first_frames.load() > 0; }, kFrameTimeout)); + + fixture.receiver().clearOnVideoFrameCallback(fixture.senderIdentity(), track_name); + EXPECT_EQ(RoomTestAccess::activeReaderCount(fixture.receiver()), 0u); + EXPECT_TRUE(RoomTestAccess::hasRetainedSubscribedTrack(fixture.receiver(), fixture.senderIdentity(), track_name)) + << "Clearing the callback must not forget the subscription"; + + std::atomic second_frames{0}; + registerFromAnotherThread([&]() { + fixture.receiver().setOnVideoFrameCallback(fixture.senderIdentity(), track_name, + [&](const VideoFrame&, std::int64_t) { second_frames.fetch_add(1); }); }); + EXPECT_EQ(RoomTestAccess::activeReaderCount(fixture.receiver()), 1u); + EXPECT_TRUE(waitFor([&]() { return second_frames.load() > 0; }, kFrameTimeout)) + << "Re-registering after a clear never received a frame"; +} - bool received = false; - { - std::unique_lock lock(frame_mutex); - received = frame_cv.wait_for(lock, 10s, [&]() { return received_frames > 0; }); - } +// The ordering that has always worked -- registering from inside +// onTrackSubscribed -- must keep working alongside the late path. Room calls +// the dispatcher after the delegate returns, so the reader must start exactly +// once rather than being duplicated by the registration's own start. +TEST_F(FrameCallbackServerTest, VideoCallbackRegisteredInsideOnTrackSubscribedReceivesFrames) { + failIfNotConfigured(); - publishing.store(false, std::memory_order_relaxed); - publisher.join(); - receiver_room.clearOnVideoFrameCallback(sender_identity, track_name); - if (track->publication()) { - lockLocalParticipant(sender_room)->unpublishTrack(track->publication()->sid()); - } + LateRegistrationFixture fixture(config_.url, config_.token_a, config_.token_b); + ASSERT_TRUE(fixture.connected()); + + const std::string track_name = "in-delegate-video-callback"; + std::atomic received_frames{0}; + RegisterInDelegate delegate(track_name, received_frames); + fixture.receiver().setDelegate(&delegate); + + ASSERT_TRUE(fixture.publishVideoAndAwaitSubscription(track_name)); + ASSERT_TRUE(waitFor([&]() { return delegate.registered(); }, kSubscribeTimeout)) + << "onTrackSubscribed never fired for the published track"; + + EXPECT_TRUE(waitFor([&]() { return received_frames.load() > 0; }, kFrameTimeout)) + << "No video frames arrived for a callback registered inside onTrackSubscribed"; + EXPECT_EQ(RoomTestAccess::activeReaderCount(fixture.receiver()), 1u) + << "Registering inside the delegate must not start a second reader for the same subscription"; - EXPECT_TRUE(received) << "No video frames arrived after late callback registration"; + fixture.stop(); + fixture.receiver().setDelegate(nullptr); } } // namespace livekit::test diff --git a/src/tests/unit/test_room_callbacks.cpp b/src/tests/unit/test_room_callbacks.cpp index ad89be14..b4f0f068 100644 --- a/src/tests/unit/test_room_callbacks.cpp +++ b/src/tests/unit/test_room_callbacks.cpp @@ -43,12 +43,15 @@ TEST_F(RoomCallbackTest, FrameCallbackRegistrationByTrackNameIsAccepted) { EXPECT_NO_THROW(room.clearOnVideoFrameCallback("alice", "cam-main")); } -TEST_F(RoomCallbackTest, TrySetOnAudioReturnsTrueWithoutSubscription) { - // Without a subscribed track, registration succeeds and no reader starts. +TEST_F(RoomCallbackTest, ReRegisteringFrameCallbacksWithoutSubscriptionIsAccepted) { + // Without a subscribed track, registration is stored for a deferred start + // and no reader exists; registering the same key again simply replaces the + // stored callback. Neither call may throw or block. Room room; - room.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); - // Re-registering the same key while no reader is active is allowed. - room.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {}); + EXPECT_NO_THROW(room.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {})); + EXPECT_NO_THROW(room.setOnAudioFrameCallback("alice", "mic-main", [](const AudioFrame&) {})); + EXPECT_NO_THROW(room.setOnVideoFrameCallback("alice", "cam-main", [](const VideoFrame&, std::int64_t) {})); + EXPECT_NO_THROW(room.setOnVideoFrameEventCallback("alice", "cam-main", [](const VideoFrameEvent&) {})); } TEST_F(RoomCallbackTest, DataCallbackRegistrationReturnsUsableIds) { diff --git a/src/tests/unit/test_subscription_thread_dispatcher.cpp b/src/tests/unit/test_subscription_thread_dispatcher.cpp index 0a520358..2d314a60 100644 --- a/src/tests/unit/test_subscription_thread_dispatcher.cpp +++ b/src/tests/unit/test_subscription_thread_dispatcher.cpp @@ -23,9 +23,12 @@ #include #include #include +#include +#include #include #include #include +#include #include #include #include @@ -98,6 +101,7 @@ class SubscriptionThreadDispatcherTest : public ::testing::Test { static auto& dataCallbacks(SubscriptionThreadDispatcher& dispatcher) { return dispatcher.data_callbacks_; } static auto& activeDataReaders(SubscriptionThreadDispatcher& dispatcher) { return dispatcher.active_data_readers_; } static auto& remoteDataTracks(SubscriptionThreadDispatcher& dispatcher) { return dispatcher.remote_data_tracks_; } + static auto& drainingReaders(SubscriptionThreadDispatcher& dispatcher) { return dispatcher.draining_readers_; } static int maxActiveReaders() { return SubscriptionThreadDispatcher::kMaxActiveReaders; } static bool isSelfThread(std::thread::id id) { return SubscriptionThreadDispatcher::isSelfThread(id); } static std::size_t activeReaderCount(SubscriptionThreadDispatcher& dispatcher) { @@ -114,9 +118,12 @@ class SubscriptionThreadDispatcherTest : public ::testing::Test { return dispatcher.extractDataReaderThreadLocked(id); } - static void markDataReaderFinishedIfCurrent(SubscriptionThreadDispatcher& dispatcher, DataFrameCallbackId id, - const std::shared_ptr& reader) { - dispatcher.markDataReaderFinishedIfCurrent(id, reader); + static void markDataReaderFinished(const std::shared_ptr& reader) { + SubscriptionThreadDispatcher::markDataReaderFinished(reader); + } + + static void disposeReaderThread(SubscriptionThreadDispatcher& dispatcher, std::thread&& thread) { + dispatcher.disposeReaderThread(std::move(thread), "SubscriptionThreadDispatcherTest"); } static std::thread startDataReader(SubscriptionThreadDispatcher& dispatcher, DataFrameCallbackId id, @@ -125,6 +132,33 @@ class SubscriptionThreadDispatcherTest : public ::testing::Test { return dispatcher.startDataReaderLocked(id, key, track, [](const std::vector&, std::optional) {}); } + + /// Seed an active media reader for @p key whose thread does real (trivial) + /// work and records that it ran. Joining it is the only way the recorded + /// flag is guaranteed visible, so a passing assertion on @p ran after a + /// lifecycle call proves that call joined the previous reader. + static void seedJoinableReader(SubscriptionThreadDispatcher& dispatcher, const CallbackKey& key, + std::atomic& ran, const std::string& sid = "TR_seeded") { + auto& reader = activeReaders(dispatcher)[key]; + reader.track_sid = sid; + reader.thread = std::thread([&ran]() { ran.store(true); }); + reader.thread_id = reader.thread.get_id(); + } + + /// Seed an active media reader for @p key whose thread runs @p body, so a + /// test can drive a lifecycle call from *inside* the reader's own thread -- + /// the re-entrant case where joining would be a self-join. @p ready gates the + /// body until the std::thread has been fully assigned into the slot. + static void seedSelfCallingReader(SubscriptionThreadDispatcher& dispatcher, const CallbackKey& key, + std::shared_future ready, std::function body) { + auto& reader = activeReaders(dispatcher)[key]; + reader.track_sid = "TR_self"; + reader.thread = std::thread([ready = std::move(ready), body = std::move(body)]() { + ready.wait(); + body(); + }); + reader.thread_id = reader.thread.get_id(); + } }; // ============================================================================ @@ -650,31 +684,51 @@ TEST_F(SubscriptionThreadDispatcherTest, ExtractMissingDataReaderIsNoOp) { EXPECT_FALSE(extracted.joinable()); } -TEST_F(SubscriptionThreadDispatcherTest, MarkDataReaderFinishedIfCurrentKeepsMatchingEntry) { +TEST_F(SubscriptionThreadDispatcherTest, MarkDataReaderFinishedSetsFlagAndDropsStream) { SubscriptionThreadDispatcher dispatcher; auto reader = std::make_shared(); activeDataReaders(dispatcher)[0] = reader; - markDataReaderFinishedIfCurrent(dispatcher, 0, reader); + markDataReaderFinished(reader); + EXPECT_TRUE(reader->finished.load()); + EXPECT_EQ(reader->stream, nullptr); + // The reader thread only marks itself; the dispatcher's slot is left for a + // lifecycle path to reap, which is what lets a finished reader be replaced. ASSERT_EQ(activeDataReaders(dispatcher).size(), 1u); EXPECT_EQ(activeDataReaders(dispatcher)[0], reader); - EXPECT_TRUE(reader->finished); } -TEST_F(SubscriptionThreadDispatcherTest, MarkDataReaderFinishedIfCurrentLeavesReplacedEntry) { +TEST_F(SubscriptionThreadDispatcherTest, MarkDataReaderFinishedDoesNotTouchAReplacementInTheSameSlot) { SubscriptionThreadDispatcher dispatcher; auto original = std::make_shared(); auto replacement = std::make_shared(); activeDataReaders(dispatcher)[0] = replacement; - // The original reader exited after being replaced; it must not mark the - // newer reader that now owns the same callback id. - markDataReaderFinishedIfCurrent(dispatcher, 0, original); + // The original reader exited after being replaced; it marks only itself and + // must not affect the newer reader that now owns the same callback id. + markDataReaderFinished(original); + EXPECT_TRUE(original->finished.load()); ASSERT_EQ(activeDataReaders(dispatcher).size(), 1u); EXPECT_EQ(activeDataReaders(dispatcher)[0], replacement); - EXPECT_FALSE(replacement->finished); + EXPECT_FALSE(replacement->finished.load()); +} + +// Removing a data callback from inside its own callback reaches this extract +// on the reader's own thread. It must still cancel, close, and release the +// slot -- the thread is then detached by the caller instead of self-joined. +TEST_F(SubscriptionThreadDispatcherTest, ExtractDataReaderFromItsOwnThreadStillCancelsAndRemovesEntry) { + SubscriptionThreadDispatcher dispatcher; + auto reader = std::make_shared(); + reader->thread_id = std::this_thread::get_id(); + activeDataReaders(dispatcher)[3] = reader; + + auto extracted = extractDataReader(dispatcher, 3); + + EXPECT_TRUE(reader->cancelled.load()) << "A re-entrant removal must still stop delivery"; + EXPECT_TRUE(activeDataReaders(dispatcher).empty()) << "A re-entrant removal must still release the slot"; + EXPECT_FALSE(extracted.joinable()); } TEST_F(SubscriptionThreadDispatcherTest, ExtractFinishedDataReaderRemovesEntryAndReturnsJoinableThread) { @@ -1108,6 +1162,383 @@ TEST_F(SubscriptionThreadDispatcherTest, ConcurrentDataCallbackRegistrationDoesN "register/remove"; } +// ============================================================================ +// Late registration (GitHub issue #235) +// +// A callback registered after the track_subscribed event -- e.g. from a GUI +// thread once RoomDelegate::onTrackSubscribed has returned -- must start a +// reader from the retained subscription instead of waiting for an event that +// will never come again. +// +// The fake track carries an invalid FFI handle, so AudioStream::fromTrack / +// VideoStream::fromTrack throw when reader startup is attempted. That throw +// is the deterministic evidence that startup was reached rather than skipped; +// a no-throw means the dispatcher never tried to start a reader. +// ============================================================================ + +TEST_F(SubscriptionThreadDispatcherTest, SetOnAudioAfterTrackSubscribedStartsReaderImmediately) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.handleTrackSubscribed("alice", "mic", + std::make_shared("TR_audio_1", TrackKind::KIND_AUDIO)); + ASSERT_TRUE(activeReaders(dispatcher).empty()) << "No callback registered yet, so no reader"; + + EXPECT_ANY_THROW(dispatcher.setOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {})) + << "Registering after the subscription must attempt to start a reader right away"; + EXPECT_EQ(audioCallbacks(dispatcher).size(), 1u) << "The registration must survive a failed startup"; +} + +TEST_F(SubscriptionThreadDispatcherTest, SetOnVideoAfterTrackSubscribedStartsReaderImmediately) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.handleTrackSubscribed("alice", "cam", + std::make_shared("TR_video_1", TrackKind::KIND_VIDEO)); + ASSERT_TRUE(activeReaders(dispatcher).empty()); + + EXPECT_ANY_THROW(dispatcher.setOnVideoFrameCallback("alice", "cam", [](const VideoFrame&, std::int64_t) {})) + << "Registering after the subscription must attempt to start a reader right away"; + EXPECT_EQ(videoCallbacks(dispatcher).size(), 1u); +} + +TEST_F(SubscriptionThreadDispatcherTest, SetOnVideoEventAfterTrackSubscribedStartsReaderImmediately) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.handleTrackSubscribed("alice", "cam", + std::make_shared("TR_video_1", TrackKind::KIND_VIDEO)); + ASSERT_TRUE(activeReaders(dispatcher).empty()); + + EXPECT_ANY_THROW(dispatcher.setOnVideoFrameEventCallback("alice", "cam", [](const VideoFrameEvent&) {})) + << "Registering after the subscription must attempt to start a reader right away"; + EXPECT_EQ(videoCallbacks(dispatcher).size(), 1u); +} + +// The counterpart: registering first and subscribing second must also start +// exactly once, on the subscribe. Together with the tests above this pins both +// orderings the issue contrasts. +TEST_F(SubscriptionThreadDispatcherTest, SetOnAudioBeforeTrackSubscribedStartsReaderOnSubscribe) { + SubscriptionThreadDispatcher dispatcher; + EXPECT_NO_THROW(dispatcher.setOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {})) + << "Nothing is subscribed yet, so registration must not try to start a reader"; + EXPECT_TRUE(activeReaders(dispatcher).empty()); + + EXPECT_ANY_THROW(dispatcher.handleTrackSubscribed( + "alice", "mic", std::make_shared("TR_audio_1", TrackKind::KIND_AUDIO))) + << "The subscribe event must start the reader for the pending registration"; +} + +TEST_F(SubscriptionThreadDispatcherTest, SetOnAudioAfterVideoTrackSubscribedDoesNotStartAReader) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.handleTrackSubscribed("alice", "cam", + std::make_shared("TR_video_1", TrackKind::KIND_VIDEO)); + + // The retained track is video and only an audio callback exists: nothing to + // bind, so startup must be skipped rather than attempted against the wrong + // kind. + EXPECT_NO_THROW(dispatcher.setOnAudioFrameCallback("alice", "cam", [](const AudioFrame&) {})); + EXPECT_TRUE(activeReaders(dispatcher).empty()); +} + +TEST_F(SubscriptionThreadDispatcherTest, SetOnAudioAfterUnsubscribeDoesNotStartAReader) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.handleTrackSubscribed("alice", "mic", + std::make_shared("TR_audio_1", TrackKind::KIND_AUDIO)); + dispatcher.handleTrackUnsubscribed("alice", TrackSource::SOURCE_MICROPHONE, "mic"); + + EXPECT_NO_THROW(dispatcher.setOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {})) + << "Unsubscribe must drop the retained track so late registration defers again"; + EXPECT_TRUE(activeReaders(dispatcher).empty()); + EXPECT_EQ(audioCallbacks(dispatcher).size(), 1u) << "The registration is kept for the next subscribe"; +} + +// ============================================================================ +// Resubscribe with a new SID (republish under the same track name) +// ============================================================================ + +TEST_F(SubscriptionThreadDispatcherTest, ResubscribeWithNewSidStopsPreviousReaderAndRestarts) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.setOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {}); + + const CallbackKey key{"alice", "mic"}; + activeReaders(dispatcher)[key].track_sid = "TR_audio_1"; + ASSERT_EQ(activeReaderCount(dispatcher), 1u); + + // A different SID is a new publication: the stale reader must go and a fresh + // start must be attempted (the throw is that attempt). + EXPECT_ANY_THROW(dispatcher.handleTrackSubscribed( + "alice", "mic", std::make_shared("TR_audio_2", TrackKind::KIND_AUDIO))); + + EXPECT_TRUE(activeReaders(dispatcher).empty()) << "The reader for the old SID must have been extracted"; + ASSERT_EQ(subscribedTracks(dispatcher).count(key), 1u); + EXPECT_EQ(subscribedTracks(dispatcher).at(key)->sid(), "TR_audio_2"); + EXPECT_TRUE(drainingReaders(dispatcher).empty()); +} + +// ============================================================================ +// Drain-before-restart +// +// Replacing, clearing, unsubscribing, and resubscribing all stop the previous +// reader, join it with the lock released, and only then start a replacement. +// While that join is in progress the key is marked draining and nothing may +// start a reader for it, so the old and new callbacks never run concurrently. +// ============================================================================ + +TEST_F(SubscriptionThreadDispatcherTest, ReaderStartIsDeferredWhileKeyIsDraining) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.setOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {}); + + const CallbackKey key{"alice", "mic"}; + drainingReaders(dispatcher)[key] = 1; + + // With a registration and a subscribed track this would normally attempt a + // start (and throw). A draining key must defer instead. + EXPECT_NO_THROW(dispatcher.handleTrackSubscribed( + "alice", "mic", std::make_shared("TR_audio_1", TrackKind::KIND_AUDIO))); + EXPECT_TRUE(activeReaders(dispatcher).empty()); + EXPECT_EQ(subscribedTracks(dispatcher).count(key), 1u) << "The subscription is retained for the drain owner"; + EXPECT_EQ(drainingReaders(dispatcher).at(key), 1) << "A caller that did not drain must not clear the mark"; + + // Once the drain owner clears the mark, the deferred start goes ahead. + drainingReaders(dispatcher).clear(); + EXPECT_ANY_THROW(dispatcher.setOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {})); +} + +TEST_F(SubscriptionThreadDispatcherTest, SetOnAudioJoinsPreviousReaderAndClearsDrainMark) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.setOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {}); + + const CallbackKey key{"alice", "mic"}; + std::atomic previous_ran{false}; + seedJoinableReader(dispatcher, key, previous_ran); + + dispatcher.setOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {}); + + EXPECT_TRUE(previous_ran.load()) << "The setter must have joined the previous reader before returning"; + EXPECT_TRUE(activeReaders(dispatcher).empty()) << "No subscribed track, so nothing restarts"; + EXPECT_TRUE(drainingReaders(dispatcher).empty()) << "The drain mark must be cleared once the join completes"; +} + +TEST_F(SubscriptionThreadDispatcherTest, SetOnVideoJoinsPreviousReaderAndClearsDrainMark) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.setOnVideoFrameCallback("alice", "cam", [](const VideoFrame&, std::int64_t) {}); + + const CallbackKey key{"alice", "cam"}; + std::atomic previous_ran{false}; + seedJoinableReader(dispatcher, key, previous_ran); + + dispatcher.setOnVideoFrameEventCallback("alice", "cam", [](const VideoFrameEvent&) {}); + + EXPECT_TRUE(previous_ran.load()); + EXPECT_TRUE(activeReaders(dispatcher).empty()); + EXPECT_TRUE(drainingReaders(dispatcher).empty()); +} + +TEST_F(SubscriptionThreadDispatcherTest, ClearAudioCallbackJoinsPreviousReaderAndClearsDrainMark) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.setOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {}); + + const CallbackKey key{"alice", "mic"}; + std::atomic previous_ran{false}; + seedJoinableReader(dispatcher, key, previous_ran); + + dispatcher.clearOnAudioFrameCallback("alice", "mic"); + + EXPECT_TRUE(previous_ran.load()); + EXPECT_TRUE(activeReaders(dispatcher).empty()); + EXPECT_TRUE(drainingReaders(dispatcher).empty()); + EXPECT_TRUE(audioCallbacks(dispatcher).empty()); +} + +TEST_F(SubscriptionThreadDispatcherTest, HandleTrackUnsubscribedJoinsPreviousReaderAndClearsDrainMark) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.setOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {}); + + const CallbackKey key{"alice", "mic"}; + std::atomic previous_ran{false}; + seedJoinableReader(dispatcher, key, previous_ran); + + dispatcher.handleTrackUnsubscribed("alice", TrackSource::SOURCE_MICROPHONE, "mic"); + + EXPECT_TRUE(previous_ran.load()); + EXPECT_TRUE(activeReaders(dispatcher).empty()); + EXPECT_TRUE(drainingReaders(dispatcher).empty()); + EXPECT_EQ(audioCallbacks(dispatcher).size(), 1u) << "Unsubscribe keeps the registration"; +} + +// A subscribe event that arrives for the key while it is draining is retained +// and honoured by the drain owner's restart, not lost. +TEST_F(SubscriptionThreadDispatcherTest, DrainOwnerRestartsReaderSubscribedDuringDrain) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.setOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {}); + + const CallbackKey key{"alice", "mic"}; + drainingReaders(dispatcher)[key] = 1; + dispatcher.handleTrackSubscribed("alice", "mic", + std::make_shared("TR_audio_1", TrackKind::KIND_AUDIO)); + ASSERT_TRUE(activeReaders(dispatcher).empty()) << "Deferred while draining"; + + // Hand the drain to a lifecycle call: seed a joinable reader so the clear + // both drains and, on completion, decrements the mark we planted plus its + // own. The subsequent restart then reaches stream construction (throws). + drainingReaders(dispatcher).clear(); + std::atomic previous_ran{false}; + seedJoinableReader(dispatcher, key, previous_ran, "TR_audio_0"); + EXPECT_ANY_THROW(dispatcher.setOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {})); + EXPECT_TRUE(previous_ran.load()) << "The previous reader must be joined before the restart is attempted"; + EXPECT_TRUE(drainingReaders(dispatcher).empty()); +} + +// ============================================================================ +// Self-join avoidance +// +// Every lifecycle path disposes of stopped reader threads through one helper. +// Called from any other thread it joins; called from the reader's own thread +// (a re-entrant call from inside a frame callback, or Room::disconnect() from +// inside a data callback) it detaches, because a self-join throws +// std::system_error and a joinable std::thread destroyed during the unwind +// terminates the process. +// ============================================================================ + +TEST_F(SubscriptionThreadDispatcherTest, DisposeReaderThreadJoinsWhenCalledFromAnotherThread) { + SubscriptionThreadDispatcher dispatcher; + std::atomic ran{false}; + std::thread worker([&ran]() { ran.store(true); }); + + disposeReaderThread(dispatcher, std::move(worker)); + + EXPECT_TRUE(ran.load()) << "Dispose from another thread must join, guaranteeing the thread finished"; + EXPECT_FALSE(worker.joinable()); +} + +TEST_F(SubscriptionThreadDispatcherTest, DisposeReaderThreadDetachesWhenCalledFromThatThread) { + SubscriptionThreadDispatcher dispatcher; + std::promise ready; + std::promise done; + auto done_future = done.get_future(); + auto holder = std::make_shared(); + + *holder = std::thread([&dispatcher, holder, ready = ready.get_future().share(), &done]() { + ready.wait(); // *holder now refers to this very thread + EXPECT_NO_THROW(disposeReaderThread(dispatcher, std::move(*holder))); + EXPECT_FALSE(holder->joinable()) << "A self-dispose must detach, leaving nothing joinable to destroy"; + done.set_value(); + }); + ready.set_value(); + + ASSERT_EQ(done_future.wait_for(5s), std::future_status::ready) << "Self-dispose hung instead of detaching"; +} + +TEST_F(SubscriptionThreadDispatcherTest, SetOnAudioFromInsideItsOwnReaderDetachesAndReplaces) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.setOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {}); + + const CallbackKey key{"alice", "mic"}; + std::promise ready; + std::promise done; + auto done_future = done.get_future(); + std::atomic replacement_invocations{0}; + + seedSelfCallingReader(dispatcher, key, ready.get_future().share(), [&]() { + // What a frame callback that re-registers itself does. + dispatcher.setOnAudioFrameCallback( + "alice", "mic", [&replacement_invocations](const AudioFrame&) { replacement_invocations.fetch_add(1); }); + done.set_value(); + }); + ready.set_value(); + + ASSERT_EQ(done_future.wait_for(5s), std::future_status::ready) + << "Re-entrant setOnAudioFrameCallback never returned; the reader self-joined"; + EXPECT_TRUE(activeReaders(dispatcher).empty()); + EXPECT_TRUE(drainingReaders(dispatcher).empty()); + ASSERT_EQ(audioCallbacks(dispatcher).size(), 1u); + audioCallbacks(dispatcher)[key].callback(makeAudioFrame()); + EXPECT_EQ(replacement_invocations.load(), 1) << "The re-entrant registration must still take effect"; +} + +TEST_F(SubscriptionThreadDispatcherTest, ClearOnAudioFromInsideItsOwnReaderDetaches) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.setOnAudioFrameCallback("alice", "mic", [](const AudioFrame&) {}); + + const CallbackKey key{"alice", "mic"}; + std::promise ready; + std::promise done; + auto done_future = done.get_future(); + + seedSelfCallingReader(dispatcher, key, ready.get_future().share(), [&]() { + dispatcher.clearOnAudioFrameCallback("alice", "mic"); + done.set_value(); + }); + ready.set_value(); + + ASSERT_EQ(done_future.wait_for(5s), std::future_status::ready) + << "Re-entrant clearOnAudioFrameCallback never returned; the reader self-joined"; + EXPECT_TRUE(activeReaders(dispatcher).empty()); + EXPECT_TRUE(audioCallbacks(dispatcher).empty()); + EXPECT_TRUE(drainingReaders(dispatcher).empty()); +} + +TEST_F(SubscriptionThreadDispatcherTest, RemoveDataCallbackFromInsideItsOwnReaderDetachesAndStopsIt) { + SubscriptionThreadDispatcher dispatcher; + const auto id = dispatcher.addOnDataFrameCallback( + "alice", "data", [](const std::vector&, std::optional) {}); + + std::promise ready; + std::promise done; + auto done_future = done.get_future(); + auto reader = std::make_shared(); + reader->thread = std::thread([&dispatcher, id, ready = ready.get_future().share(), &done]() { + ready.wait(); + dispatcher.removeOnDataFrameCallback(id); // from inside "its own" data callback + done.set_value(); + }); + reader->thread_id = reader->thread.get_id(); + activeDataReaders(dispatcher)[id] = reader; + ready.set_value(); + + ASSERT_EQ(done_future.wait_for(5s), std::future_status::ready) + << "Re-entrant removeOnDataFrameCallback never returned; the data reader self-joined"; + EXPECT_TRUE(reader->cancelled.load()) << "The removal must stop the reader, not just refuse"; + EXPECT_TRUE(activeDataReaders(dispatcher).empty()); + EXPECT_TRUE(dataCallbacks(dispatcher).empty()); +} + +// Room::disconnect() from inside a data frame callback reaches stopAll() on the +// reader's own thread. Before the fix this self-joined, threw, and the joinable +// std::thread destroyed during unwinding took the whole process down. +TEST_F(SubscriptionThreadDispatcherTest, StopAllFromInsideDataReaderThreadDetachesInsteadOfSelfJoining) { + SubscriptionThreadDispatcher dispatcher; + std::promise ready; + std::promise done; + auto done_future = done.get_future(); + auto reader = std::make_shared(); + reader->thread = std::thread([&dispatcher, ready = ready.get_future().share(), &done]() { + ready.wait(); + EXPECT_NO_THROW(dispatcher.stopAll()); + done.set_value(); + }); + reader->thread_id = reader->thread.get_id(); + activeDataReaders(dispatcher)[9] = reader; + ready.set_value(); + + ASSERT_EQ(done_future.wait_for(5s), std::future_status::ready) << "stopAll() from a data reader thread hung"; + EXPECT_TRUE(reader->cancelled.load()); + EXPECT_TRUE(activeDataReaders(dispatcher).empty()); +} + +TEST_F(SubscriptionThreadDispatcherTest, StopAllFromInsideMediaReaderThreadDetachesInsteadOfSelfJoining) { + SubscriptionThreadDispatcher dispatcher; + dispatcher.setOnVideoFrameCallback("alice", "cam", [](const VideoFrame&, std::int64_t) {}); + + std::promise ready; + std::promise done; + auto done_future = done.get_future(); + seedSelfCallingReader(dispatcher, CallbackKey{"alice", "cam"}, ready.get_future().share(), [&]() { + EXPECT_NO_THROW(dispatcher.stopAll()); + done.set_value(); + }); + ready.set_value(); + + ASSERT_EQ(done_future.wait_for(5s), std::future_status::ready) << "stopAll() from a media reader thread hung"; + EXPECT_TRUE(activeReaders(dispatcher).empty()); + EXPECT_TRUE(videoCallbacks(dispatcher).empty()); +} + } // namespace livekit #if defined(__clang__) || defined(__GNUC__) From b5db4507abfc2945b1c4d95a9fc9a1d96139b68d Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Mon, 14 Sep 2026 15:25:27 -0600 Subject: [PATCH 6/8] c1 --- .../livekit/subscription_thread_dispatcher.h | 43 ++++--------------- src/room.cpp | 18 +------- src/subscription_thread_dispatcher.cpp | 15 ++----- 3 files changed, 13 insertions(+), 63 deletions(-) diff --git a/include/livekit/subscription_thread_dispatcher.h b/include/livekit/subscription_thread_dispatcher.h index 9c2eeafa..fdc4f20d 100644 --- a/include/livekit/subscription_thread_dispatcher.h +++ b/include/livekit/subscription_thread_dispatcher.h @@ -84,24 +84,10 @@ class LIVEKIT_DEPRECATED( /// subscribed. /// /// Registering again for a key that already has an active reader replaces the - /// callback in place: the previous reader's stream is closed and its thread - /// is joined, and only then -- if the track is still subscribed -- is a fresh - /// reader started bound to the new callback. The old and new callbacks are - /// therefore never invoked concurrently, and when this call returns the - /// previous callback has finished executing and its copy has been destroyed. - /// While the previous reader is being joined, no other caller (a concurrent - /// registration or a subscription event) can start a reader for the key. + /// callback in place. /// /// @warning This call blocks until any in-flight invocation of the previous - /// callback returns. A slow callback makes registration slow; a - /// callback that never returns blocks this call indefinitely. - /// - /// @warning Calling this from inside a frame callback for the same key is - /// discouraged. Joining the reader would be a self-join, so the - /// dispatcher logs a warning and detaches that reader instead. The - /// replacement still takes effect, but the in-flight invocation of - /// the previous callback only finishes after this call returns, so - /// the no-overlap guarantee above does not hold for that invocation. + /// callback returns. Calling this from inside a frame callback for the same key is not supported. /// /// @param participant_identity Identity of the remote participant. /// @param track_name Track name to match. @@ -113,17 +99,11 @@ class LIVEKIT_DEPRECATED( /// Register or replace a video frame callback for a remote subscription. /// - /// The callback is keyed by remote participant identity plus @p track_name. - /// If the matching remote video track is already subscribed, this starts a - /// reader immediately. Otherwise, the reader starts when the track is - /// subscribed. - /// /// Registering again for a key that already has an active reader replaces the /// callback in place; see @ref setOnAudioFrameCallback for the full - /// replacement semantics, blocking behavior, and re-entrancy caveat. Note - /// that this shares its registration slot with - /// @ref setOnVideoFrameEventCallback -- registering either one replaces the - /// other for the same key. + /// replacement semantics, blocking behavior, and re-entrancy caveat. + // Note: this shares its registration slot with @ref setOnVideoFrameEventCallback -- registering either one + // replaces the other for the same key. /// /// @param participant_identity Identity of the remote participant. /// @param track_name Track name to match. @@ -136,17 +116,12 @@ class LIVEKIT_DEPRECATED( /// Register or replace a rich video frame event callback for a remote /// subscription. /// - /// The callback is keyed by remote participant identity plus @p track_name. - /// If the matching remote video track is already subscribed, this starts a - /// reader immediately. Otherwise, the reader starts when the track is - /// subscribed. - /// /// Registering again for a key that already has an active reader replaces the /// callback in place; see @ref setOnAudioFrameCallback for the full - /// replacement semantics, blocking behavior, and re-entrancy caveat. Note - /// that this shares its registration slot with @ref setOnVideoFrameCallback - /// -- registering either one replaces the other for the same key. - /// + /// replacement semantics, blocking behavior, and re-entrancy caveat. + // Note: this shares its registration slot with @ref setOnVideoFrameCallback -- registering either one replaces the + // other for the same key. + // /// @param participant_identity Identity of the remote participant. /// @param track_name Track name to match. /// @param callback Function invoked for each decoded video frame diff --git a/src/room.cpp b/src/room.cpp index 31b3af57..a5254321 100644 --- a/src/room.cpp +++ b/src/room.cpp @@ -423,23 +423,13 @@ void Room::setOnAudioFrameCallback(const std::string& participant_identity, cons LK_LOG_ERROR("Room::setOnAudioFrameCallback: subscription_thread_dispatcher_ is nullptr"); return; } - // Installs the callback, stops and joins any reader still dispatching to the - // previous one, and -- if the dispatcher has already retained the subscribed - // track -- starts a fresh reader bound to the new callback. subscription_thread_dispatcher_->setOnAudioFrameCallback(participant_identity, track_name, std::move(callback), opts); - // The dispatcher only retains the track once onEvent has forwarded the - // subscribe event to it, which happens *after* RoomDelegate::onTrackSubscribed - // returns. A callback registered from inside that delegate therefore finds no - // retained track, so resolve the publication here as well. When the dispatcher - // already started the reader this is a same-SID no-op. The video setters below - // follow the same pattern. + // If we've already subscribed to the track, handle it immediately auto track = findSubscribedRemoteTrack(participant_identity, track_name); if (track) { subscription_thread_dispatcher_->handleTrackSubscribed(participant_identity, track_name, track); } else { - // The track is not subscribed yet. The callback is registered; the reader - // starts when the track is subscribed (see kTrackSubscribed in onEvent). LK_LOG_DEBUG( "Room::setOnAudioFrameCallback: track not yet subscribed for participant={} track_name={}; " "callback registered for deferred start", @@ -455,13 +445,10 @@ void Room::setOnVideoFrameCallback(const std::string& participant_identity, cons } subscription_thread_dispatcher_->setOnVideoFrameCallback(participant_identity, track_name, std::move(callback), opts); - // If we've already subscribed to the track, handle it immediately auto track = findSubscribedRemoteTrack(participant_identity, track_name); if (track) { subscription_thread_dispatcher_->handleTrackSubscribed(participant_identity, track_name, track); } else { - // The track is not subscribed yet. The callback is registered; the reader - // starts when the track is subscribed (see kTrackSubscribed in onEvent). LK_LOG_DEBUG( "Room::setOnVideoFrameCallback: track not yet subscribed for participant={} track_name={}; " "callback registered for deferred start", @@ -478,13 +465,10 @@ void Room::setOnVideoFrameEventCallback(const std::string& participant_identity, subscription_thread_dispatcher_->setOnVideoFrameEventCallback(participant_identity, track_name, std::move(callback), opts); - // If we've already subscribed to the track, handle it immediately auto track = findSubscribedRemoteTrack(participant_identity, track_name); if (track) { subscription_thread_dispatcher_->handleTrackSubscribed(participant_identity, track_name, track); } else { - // The track is not subscribed yet. The callback is registered; the reader - // starts when the track is subscribed (see kTrackSubscribed in onEvent). LK_LOG_DEBUG( "Room::setOnVideoFrameEventCallback: track not yet subscribed for participant={} track_name={}; " "callback registered for deferred start", diff --git a/src/subscription_thread_dispatcher.cpp b/src/subscription_thread_dispatcher.cpp index 30e3e2fa..cecafadd 100644 --- a/src/subscription_thread_dispatcher.cpp +++ b/src/subscription_thread_dispatcher.cpp @@ -66,10 +66,8 @@ void SubscriptionThreadDispatcher::disposeReaderThread(std::thread&& thread, con return; } if (isSelfThread(thread.get_id())) { - // The caller IS this reader, so it reached us from inside its own frame - // callback. Joining here would be a self-join (std::system_error, and a - // still-joinable std::thread destroyed during unwinding would terminate - // the process). Detaching is safe: no reader lambda captures `this`; each + // The caller IS this reader so this function was called from the set callback. + // Joining here would be a self-join. Detaching is safe: no reader lambda captures `this`; each // owns its stream, callback, and per-reader state by value, so once // extracted the thread touches nothing owned by the dispatcher. LK_LOG_WARN( @@ -87,10 +85,6 @@ void SubscriptionThreadDispatcher::disposeReaderThread(std::thread&& thread, con std::thread SubscriptionThreadDispatcher::extractReaderForDrainLocked(const CallbackKey& key) { std::thread old_thread = extractReaderThreadLocked(key); if (old_thread.joinable()) { - // Block every start for this key until finishReaderDrainAndRestart has - // joined (or detached) this thread. Without this, a replacement reader - // could begin invoking the new callback while the old callback is still - // mid-invocation on the thread we are about to join. ++draining_readers_[key]; } return old_thread; @@ -124,9 +118,6 @@ void SubscriptionThreadDispatcher::setOnAudioFrameCallback(const std::string& pa std::thread old_thread; { const std::scoped_lock lock(lock_); - // Stop any reader still dispatching to the previous callback. Reader threads - // hold their own copy of the callback, so overwriting the registration alone - // would leave the old callback receiving frames. old_thread = extractReaderForDrainLocked(key); const bool replacing = audio_callbacks_.find(key) != audio_callbacks_.end(); audio_callbacks_[key] = RegisteredAudioCallback{std::move(callback), opts}; @@ -431,7 +422,7 @@ std::thread SubscriptionThreadDispatcher::extractReaderThreadLocked(const Callba if (it == active_readers_.end()) { LK_LOG_TRACE("No active reader to extract for participant={} track_name={}", key.participant_identity, key.track_name); - return {}; + return std::thread(); } LK_LOG_DEBUG("Extracting active reader for participant={} track_name={}", key.participant_identity, key.track_name); From a52c7d7a220d17a4f629a7b5760d42d51182e111 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Mon, 14 Sep 2026 15:58:13 -0600 Subject: [PATCH 7/8] c2 --- .../livekit/subscription_thread_dispatcher.h | 126 +++++------------- 1 file changed, 31 insertions(+), 95 deletions(-) diff --git a/include/livekit/subscription_thread_dispatcher.h b/include/livekit/subscription_thread_dispatcher.h index fdc4f20d..4ee7b8ef 100644 --- a/include/livekit/subscription_thread_dispatcher.h +++ b/include/livekit/subscription_thread_dispatcher.h @@ -102,7 +102,7 @@ class LIVEKIT_DEPRECATED( /// Registering again for a key that already has an active reader replaces the /// callback in place; see @ref setOnAudioFrameCallback for the full /// replacement semantics, blocking behavior, and re-entrancy caveat. - // Note: this shares its registration slot with @ref setOnVideoFrameEventCallback -- registering either one + /// @note this shares its registration slot with @ref setOnVideoFrameEventCallback -- registering either one // replaces the other for the same key. /// /// @param participant_identity Identity of the remote participant. @@ -119,7 +119,7 @@ class LIVEKIT_DEPRECATED( /// Registering again for a key that already has an active reader replaces the /// callback in place; see @ref setOnAudioFrameCallback for the full /// replacement semantics, blocking behavior, and re-entrancy caveat. - // Note: this shares its registration slot with @ref setOnVideoFrameCallback -- registering either one replaces the + /// @note this shares its registration slot with @ref setOnVideoFrameCallback -- registering either one replaces the // other for the same key. // /// @param participant_identity Identity of the remote participant. @@ -134,13 +134,9 @@ class LIVEKIT_DEPRECATED( /// Remove an audio callback registration and stop any active reader. /// /// If an audio reader thread is active for the given key, its stream is - /// closed and the thread is joined before this call returns. Replacing a - /// callback does not require clearing first -- see - /// @ref setOnAudioFrameCallback. + /// closed and the thread is joined before this call returns. /// - /// @warning Blocks until any in-flight callback invocation returns. See - /// @ref setOnAudioFrameCallback for the caveat on calling this from - /// inside a frame callback for the same key. + /// @warning Calling this from inside a frame callback for the same key is not supported. /// /// @param participant_identity Identity of the remote participant. /// @param track_name Track name to clear. @@ -149,13 +145,9 @@ class LIVEKIT_DEPRECATED( /// Remove a video callback registration and stop any active reader. /// /// If a video reader thread is active for the given key, its stream is - /// closed and the thread is joined before this call returns. Replacing a - /// callback does not require clearing first -- see - /// @ref setOnVideoFrameCallback. + /// closed and the thread is joined before this call returns. /// - /// @warning Blocks until any in-flight callback invocation returns. See - /// @ref setOnAudioFrameCallback for the caveat on calling this from - /// inside a frame callback for the same key. + /// @warning Calling this from inside a frame callback for the same key is not supported. /// /// @param participant_identity Identity of the remote participant. /// @param track_name Track name to clear. @@ -164,36 +156,25 @@ class LIVEKIT_DEPRECATED( /// Start or restart reader dispatch for a newly subscribed remote audio or /// video track. /// - /// @ref Room calls this after it has processed a track-subscription event and - /// updated its publication state. If a matching audio or video callback - /// registration exists, the dispatcher creates the appropriate @ref - /// AudioStream or @ref VideoStream and launches a reader thread for the - /// `(participant, track_name)` key. A repeated event for the track SID a - /// reader is already serving is a no-op; a different SID (a republish) - /// stops and joins the previous reader before starting the new one. + /// A repeated event for the track SID a reader is already serving is a no-op; + // A different SID (a republish) stops and joins the previous reader before starting the new one. /// /// The dispatcher retains the subscription until it receives /// @ref handleTrackUnsubscribed. This lets a callback registered after this - /// method returns start a reader immediately. Remote data tracks are handled - /// separately via @ref handleDataTrackPublished. If @p track is not audio or - /// video, no reader is started. + /// method returns start a reader immediately. A reader only starts for audio and video tracks. /// /// @param participant_identity Identity of the remote participant. /// @param track_name Track name associated with the subscription. - /// @param track Subscribed remote audio or video track to read - /// from. + /// @param track Subscribed remote audio or video track to read from. void handleTrackSubscribed(const std::string& participant_identity, const std::string& track_name, const std::shared_ptr& track); - /// Stop reader dispatch for an unsubscribed remote audio or video track. + /// Stop reader dispatch for an unsubscribed remote track. /// - /// @ref Room calls this when a remote audio or video track is unsubscribed. - /// Any active reader stream for the given `(participant, track_name)` key is - /// closed and its thread is joined. Callback registration is preserved so - /// future re-subscription can start dispatch again automatically. - /// - /// Remote data tracks are handled separately via @ref - /// handleDataTrackUnpublished. + /// @ref Room calls this when a remote track is unsubscribed. Any active + /// reader stream for the given `(participant, track_name)` key is closed and its + /// thread is joined. Callback registration is preserved so future + /// re-subscription can start dispatch again automatically. /// /// @param participant_identity Identity of the remote participant. /// @param source Track source associated with the subscription. @@ -201,10 +182,6 @@ class LIVEKIT_DEPRECATED( void handleTrackUnsubscribed(const std::string& participant_identity, TrackSource source, const std::string& track_name); - // --------------------------------------------------------------- - // Data track callbacks - // --------------------------------------------------------------- - /// Add a callback for data frames from a specific remote participant's /// data track. /// @@ -293,12 +270,9 @@ class LIVEKIT_DEPRECATED( std::shared_ptr audio_stream; std::shared_ptr video_stream; std::thread thread; - /// SID of the subscribed track backing this reader, used to skip redundant - /// reader restarts when the same publication is re-subscribed. + /// SID of the subscribed track backing this reader std::string track_sid; - /// ID of @ref thread, captured at construction. Used to detect a re-entrant - /// call made from inside this reader's own frame callback, where joining - /// would be a self-join. + /// ID of @ref thread, captured at construction. Used to block a self-join. std::thread::id thread_id; }; @@ -330,20 +304,15 @@ class LIVEKIT_DEPRECATED( /// Active read-side resources for one data track stream subscription. struct ActiveDataReader { std::shared_ptr remote_track; - /// Set true when this reader is being replaced or torn down so the reader - /// thread can abort a subscription that is still in flight. + /// Set true when this reader is being replaced or torn down. std::atomic cancelled{false}; - /// Set true by the reader thread itself when it exits (failed, cancelled, - /// or terminal subscription). A finished reader still occupying its slot - /// is replaced rather than deduplicated on the next same-SID publish. Only + /// Set true by the reader thread itself when it exits (failed, cancelled, or terminal subscription). Only /// dispatcher lifecycle paths erase the slot and join or detach the thread. std::atomic finished{false}; std::mutex sub_mutex; std::shared_ptr stream; // guarded by sub_mutex std::thread thread; - /// ID of @ref thread, captured at construction. Used to detect a re-entrant - /// call made from inside this reader's own data frame callback, where - /// joining would be a self-join. + /// ID of @ref thread, captured at construction. Used to block a self-join. std::thread::id thread_id; }; @@ -366,28 +335,15 @@ class LIVEKIT_DEPRECATED( /// must be disposed of after releasing the lock. std::thread extractReaderThreadLocked(const CallbackKey& key); - /// Remove and close the active reader for @p key and, if a thread came out, - /// mark the key as draining so that no reader for it is started until - /// @ref finishReaderDrainAndRestart has disposed of that thread. - /// - /// Every audio/video path that stops a reader goes through this, so a - /// concurrent registration or subscription event for the same key cannot - /// start a replacement while the previous callback may still be executing. + /// Wrapper around @ref extractReaderThreadLocked. If extractReaderThreadLocked returns a thread the key is marked as + /// draining. /// /// Must be called with @ref lock_ held. std::thread extractReaderForDrainLocked(const CallbackKey& key); - /// Second half of every audio/video reader restart. + /// Dispose of the old reader thread, clear from the drain, and start a new reader. /// - /// Disposes of @p old_thread (obtained from @ref extractReaderForDrainLocked) - /// with @ref lock_ released, then re-acquires the lock, clears the draining - /// mark, and starts a reader for @p key if a callback is registered, a - /// subscribed track is retained, and no other caller is still draining the - /// key. Because the start happens only after the join, the previous and the - /// new callback never run concurrently. - /// - /// Must be called with @ref lock_ released. @p operation names the public - /// entry point for diagnostics. + /// Must be called with @ref lock_ released. void finishReaderDrainAndRestart(const CallbackKey& key, std::thread old_thread, const char* operation); /// True when @p id identifies the calling thread, i.e. joining that thread @@ -395,35 +351,19 @@ class LIVEKIT_DEPRECATED( static bool isSelfThread(std::thread::id id) { return id == std::this_thread::get_id(); } /// Dispose of an extracted reader thread (audio, video, or data). - /// - /// Normally joins, so the caller is guaranteed the reader has stopped and its - /// callback copy has been destroyed. If the caller *is* that reader -- a - /// re-entrant call from inside its own frame callback -- joining would be a - /// self-join, so this logs a warning naming @p operation and detaches - /// instead. Detaching is safe because no reader lambda captures @c this: - /// each owns its stream, callback, and (for data) its @ref ActiveDataReader - /// by value, so a detached thread touches nothing owned by the dispatcher. - /// + /// @param thread The thread to dispose of. If this is a self thread, detatch and return. + /// @param operation for logging /// Must be called with @ref lock_ released. void disposeReaderThread(std::thread&& thread, const char* operation); - /// Select the appropriate reader startup path for the media @p track. - /// - /// Looks up the callback registration matching the track's kind and starts - /// an audio or video reader bound to it. If no callback is registered for - /// that kind, or the kind is unsupported, this is a no-op. - /// - /// Precondition: no reader is active for @p key. Every caller stops the - /// previous reader through the drain protocol first, so a reader found here - /// is a bug; it is logged and left untouched rather than replaced. + /// Starts the respective media reader thread for @p track. /// - /// Must be called with @ref lock_ held. + /// This is a no-op if: no callback is registered for the track's kind, the track is not audio or video, or a read is + /// active for the key Must be called with @ref lock_ held. void startReaderLocked(const CallbackKey& key, const std::shared_ptr& track); /// Start a reader for @p key if one should be running: a subscribed track is /// retained for the key, no reader is active, and the key is not draining. - /// Whether a callback of the matching kind is registered is decided by - /// @ref startReaderLocked. /// /// Must be called with @ref lock_ held. void startReaderForSubscribedTrackLocked(const CallbackKey& key); @@ -452,12 +392,8 @@ class LIVEKIT_DEPRECATED( const std::shared_ptr& track, const DataFrameCallback& cb); /// Mark @p reader finished and release its stream. - /// - /// Called by the reader thread itself, on every exit path, so a slot still - /// holding a dead reader is recognised as replaceable. Deliberately touches - /// only @p reader (never the dispatcher), which is what makes data reader - /// threads safe to detach. Reader threads must not erase, detach, or join - /// their own @ref std::thread. + /// Reader threads must not self join. + /// @param reader The reader to mark as finished. static void markDataReaderFinished(const std::shared_ptr& reader); /// Protects callback registration maps and active reader state. From 020d795b188f7805c4d47009053f7da2cef642a0 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Mon, 14 Sep 2026 16:11:00 -0600 Subject: [PATCH 8/8] c3 --- .../livekit/subscription_thread_dispatcher.h | 37 +++++-------------- 1 file changed, 9 insertions(+), 28 deletions(-) diff --git a/include/livekit/subscription_thread_dispatcher.h b/include/livekit/subscription_thread_dispatcher.h index 4ee7b8ef..2b870e6e 100644 --- a/include/livekit/subscription_thread_dispatcher.h +++ b/include/livekit/subscription_thread_dispatcher.h @@ -78,14 +78,6 @@ class LIVEKIT_DEPRECATED( /// Register or replace an audio frame callback for a remote subscription. /// - /// The callback is keyed by remote participant identity plus @p track_name. - /// If the matching remote audio track is already subscribed, this starts a - /// reader immediately. Otherwise, the reader starts when the track is - /// subscribed. - /// - /// Registering again for a key that already has an active reader replaces the - /// callback in place. - /// /// @warning This call blocks until any in-flight invocation of the previous /// callback returns. Calling this from inside a frame callback for the same key is not supported. /// @@ -99,9 +91,8 @@ class LIVEKIT_DEPRECATED( /// Register or replace a video frame callback for a remote subscription. /// - /// Registering again for a key that already has an active reader replaces the - /// callback in place; see @ref setOnAudioFrameCallback for the full - /// replacement semantics, blocking behavior, and re-entrancy caveat. + /// @warning This call blocks until any in-flight invocation of the previous + /// callback returns. Calling this from inside a frame callback for the same key is not supported. /// @note this shares its registration slot with @ref setOnVideoFrameEventCallback -- registering either one // replaces the other for the same key. /// @@ -116,9 +107,8 @@ class LIVEKIT_DEPRECATED( /// Register or replace a rich video frame event callback for a remote /// subscription. /// - /// Registering again for a key that already has an active reader replaces the - /// callback in place; see @ref setOnAudioFrameCallback for the full - /// replacement semantics, blocking behavior, and re-entrancy caveat. + /// @warning This call blocks until any in-flight invocation of the previous + /// callback returns. Calling this from inside a frame callback for the same key is not supported. /// @note this shares its registration slot with @ref setOnVideoFrameCallback -- registering either one replaces the // other for the same key. // @@ -205,13 +195,8 @@ class LIVEKIT_DEPRECATED( /// for this subscription. /// No-op if the ID is not (or no longer) registered. /// - /// @warning Blocks until any in-flight invocation of the callback returns. - /// - /// @warning Calling this from inside the data frame callback it removes is - /// discouraged. Joining the reader would be a self-join, so the - /// dispatcher logs a warning and detaches the reader instead. The - /// removal still takes effect: the reader's stream is closed and it - /// exits as soon as the in-flight callback invocation returns. + /// @warning This call blocks until any in-flight invocation of the previous + /// callback returns. Calling this from inside a frame callback for the same key is not supported. /// /// @param id The identifier returned by addOnDataFrameCallback(). void removeOnDataFrameCallback(DataFrameCallbackId id); @@ -235,11 +220,8 @@ class LIVEKIT_DEPRECATED( /// Stop all readers and clear all callback registrations. /// - /// This is used during room teardown or EOS handling to ensure no reader - /// thread survives beyond the lifetime of the owning @ref Room. If called - /// from inside a frame callback (for example `Room::disconnect()` invoked - /// from a data frame callback), the calling reader is detached rather than - /// self-joined; it exits once that callback invocation returns. + /// This is used during room teardown or EOS handling to ensure no reader thread survives beyond the lifetime of the + /// owning @ref Room If called from inside a frame callback the calling reader is detached rather than self-joined. void stopAll(); private: @@ -411,8 +393,7 @@ class LIVEKIT_DEPRECATED( /// Currently subscribed remote audio/video tracks keyed by @ref CallbackKey. std::unordered_map, CallbackKeyHash> subscribed_tracks_; - /// Keys whose previous reader has been extracted but not yet joined, with the - /// number of such in-progress drains. No reader is started for a key while + /// Keys whose previous reader has been extracted but not yet joined. A reader is not started for a key while /// it has an entry here. See @ref extractReaderForDrainLocked. std::unordered_map draining_readers_;