@@ -259,6 +259,8 @@ void SubscriptionThreadDispatcher::handleDataTrackUnpublished(const std::string&
259259 for (auto it = active_data_readers_.begin (); it != active_data_readers_.end ();) {
260260 auto & reader = it->second ;
261261 if (reader->remote_track && reader->remote_track ->info ().sid == sid) {
262+ // Mark cancelled before closing to guard in flight subscriptions
263+ reader->cancelled = true ;
262264 {
263265 const std::scoped_lock<std::mutex> sub_guard (reader->sub_mutex );
264266 if (reader->stream ) {
@@ -312,6 +314,8 @@ void SubscriptionThreadDispatcher::stopAll() {
312314 video_callbacks_.clear ();
313315
314316 for (auto & [id, reader] : active_data_readers_) {
317+ // Mark cancelled before closing to guard in flight subscriptions
318+ reader->cancelled = true ;
315319 {
316320 const std::scoped_lock<std::mutex> sub_guard (reader->sub_mutex );
317321 if (reader->stream ) {
@@ -397,6 +401,16 @@ std::thread SubscriptionThreadDispatcher::startAudioReaderLocked(const CallbackK
397401 const AudioFrameCallback& cb,
398402 const AudioStream::Options& opts) {
399403 LK_LOG_DEBUG (" Starting audio reader for participant={} track_name={}" , key.participant_identity , key.track_name );
404+
405+ auto existing = active_readers_.find (key);
406+ if (existing != active_readers_.end () && existing->second .track_sid == track->sid ()) {
407+ LK_LOG_DEBUG (
408+ " Skipping audio reader start for participant={} track_name={} because a "
409+ " reader for sid={} is already active" ,
410+ key.participant_identity , key.track_name , track->sid ());
411+ return {};
412+ }
413+
400414 auto old_thread = extractReaderThreadLocked (key);
401415
402416 if (static_cast <int >(active_readers_.size ()) >= kMaxActiveReaders ) {
@@ -415,6 +429,7 @@ std::thread SubscriptionThreadDispatcher::startAudioReaderLocked(const CallbackK
415429
416430 ActiveReader reader;
417431 reader.audio_stream = stream;
432+ reader.track_sid = track->sid ();
418433 const std::string participant_identity = key.participant_identity ;
419434 const std::string track_name = key.track_name ;
420435 // NOLINTBEGIN(bugprone-lambda-function-name,bugprone-exception-escape)
@@ -454,6 +469,16 @@ std::thread SubscriptionThreadDispatcher::startVideoReaderLocked(const CallbackK
454469 const std::shared_ptr<Track>& track,
455470 const RegisteredVideoCallback& callback) {
456471 LK_LOG_DEBUG (" Starting video reader for participant={} track_name={}" , key.participant_identity , key.track_name );
472+
473+ auto existing = active_readers_.find (key);
474+ if (existing != active_readers_.end () && existing->second .track_sid == track->sid ()) {
475+ LK_LOG_DEBUG (
476+ " Skipping video reader start for participant={} track_name={} because a "
477+ " reader for sid={} is already active" ,
478+ key.participant_identity , key.track_name , track->sid ());
479+ return {};
480+ }
481+
457482 auto old_thread = extractReaderThreadLocked (key);
458483
459484 if (static_cast <int >(active_readers_.size ()) >= kMaxActiveReaders ) {
@@ -472,6 +497,7 @@ std::thread SubscriptionThreadDispatcher::startVideoReaderLocked(const CallbackK
472497
473498 ActiveReader reader;
474499 reader.video_stream = stream;
500+ reader.track_sid = track->sid ();
475501 auto legacy_cb = callback.legacy_callback ;
476502 auto event_cb = callback.event_callback ;
477503 const std::string participant_identity = key.participant_identity ;
@@ -522,6 +548,8 @@ std::thread SubscriptionThreadDispatcher::extractDataReaderThreadLocked(DataFram
522548 }
523549 auto reader = std::move (it->second );
524550 active_data_readers_.erase (it);
551+ // Mark cancelled before closing to guard in flight subscriptions
552+ reader->cancelled = true ;
525553 {
526554 const std::scoped_lock<std::mutex> guard (reader->sub_mutex );
527555 if (reader->stream ) {
@@ -531,28 +559,36 @@ std::thread SubscriptionThreadDispatcher::extractDataReaderThreadLocked(DataFram
531559 return std::move (reader->thread );
532560}
533561
534- std::thread SubscriptionThreadDispatcher::extractDataReaderThreadLocked (const DataCallbackKey& key) {
535- for (auto it = active_data_readers_.begin (); it != active_data_readers_.end (); ++it) {
536- if (it->second && it->second ->remote_track &&
537- it->second ->remote_track ->publisherIdentity () == key.participant_identity &&
538- it->second ->remote_track ->info ().name == key.track_name ) {
539- auto reader = std::move (it->second );
540- active_data_readers_.erase (it);
541- {
542- const std::scoped_lock<std::mutex> guard (reader->sub_mutex );
543- if (reader->stream ) {
544- reader->stream ->close ();
545- }
546- }
547- return std::move (reader->thread );
548- }
562+ void SubscriptionThreadDispatcher::eraseDataReaderIfCurrent (DataFrameCallbackId id,
563+ const std::shared_ptr<ActiveDataReader>& reader) {
564+ const std::scoped_lock<std::mutex> lock (lock_);
565+ auto it = active_data_readers_.find (id);
566+ if (it == active_data_readers_.end () || it->second != reader) {
567+ // The slot was already extracted or replaced; the owner joins that thread.
568+ return ;
549569 }
550- return {};
570+ // Detach so the reader can be destroyed by its own thread's final shared_ptr
571+ // release without tripping std::thread's joinable-at-destruction check. The
572+ // thread is already returning when this runs, so nothing is left to join.
573+ if (reader->thread .joinable ()) {
574+ reader->thread .detach ();
575+ }
576+ active_data_readers_.erase (it);
551577}
552578
553579std::thread SubscriptionThreadDispatcher::startDataReaderLocked (DataFrameCallbackId id, const DataCallbackKey& key,
554580 const std::shared_ptr<RemoteDataTrack>& track,
555581 const DataFrameCallback& cb) {
582+ auto existing = active_data_readers_.find (id);
583+ if (existing != active_data_readers_.end () && existing->second ->remote_track &&
584+ existing->second ->remote_track ->info ().sid == track->info ().sid ) {
585+ LK_LOG_DEBUG (
586+ " Skipping data reader start for \" {}\" track=\" {}\" because a reader for "
587+ " sid={} is already active" ,
588+ key.participant_identity , key.track_name , track->info ().sid );
589+ return {};
590+ }
591+
556592 auto old_thread = extractDataReaderThreadLocked (id);
557593
558594 const int total_active = static_cast <int >(active_readers_.size ()) + static_cast <int >(active_data_readers_.size ());
@@ -571,7 +607,7 @@ std::thread SubscriptionThreadDispatcher::startDataReaderLocked(DataFrameCallbac
571607 auto identity = key.participant_identity ;
572608 auto track_name = key.track_name ;
573609 // NOLINTBEGIN(bugprone-lambda-function-name)
574- reader->thread = std::thread ([reader, track, cb, identity, track_name]() {
610+ reader->thread = std::thread ([this , id, reader, track, cb, identity, track_name]() {
575611 LK_LOG_INFO (" Data reader thread: subscribing to \" {}\" track=\" {}\" " , identity, track_name);
576612 std::shared_ptr<DataTrackStream> stream;
577613 auto subscribe_result = track->subscribe ();
@@ -581,13 +617,21 @@ std::thread SubscriptionThreadDispatcher::startDataReaderLocked(DataFrameCallbac
581617 " Failed to subscribe to data track \" {}\" from \" {}\" : code={} "
582618 " message={}" ,
583619 track_name, identity, static_cast <std::uint32_t >(error.code ), error.message );
620+ eraseDataReaderIfCurrent (id, reader);
584621 return ;
585622 }
586623 stream = subscribe_result.value ();
587624 LK_LOG_INFO (" Data reader thread: subscribed to \" {}\" track=\" {}\" " , identity, track_name);
588625
589626 {
590627 const std::scoped_lock<std::mutex> guard (reader->sub_mutex );
628+ // A replacement or teardown may have cancelled this reader while the
629+ // subscribe was in flight. Close the fresh stream and bail so we do not
630+ // leave a second live subscription behind.
631+ if (reader->cancelled .load ()) {
632+ stream->close ();
633+ return ;
634+ }
591635 reader->stream = stream;
592636 }
593637
@@ -606,6 +650,9 @@ std::thread SubscriptionThreadDispatcher::startDataReaderLocked(DataFrameCallbac
606650 " \" {}\" : code={} message={}" ,
607651 track_name, identity, static_cast <std::uint32_t >(error->code ), error->message );
608652 }
653+ // Clean our own slot if the stream ended on its own (server EOS) and no
654+ // extract/teardown already claimed it. A no-op when we were extracted.
655+ eraseDataReaderIfCurrent (id, reader);
609656 LK_LOG_INFO (" Data reader thread exiting for \" {}\" track=\" {}\" " , identity, track_name);
610657 });
611658 // NOLINTEND(bugprone-lambda-function-name)
0 commit comments