@@ -259,6 +259,9 @@ 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 so a subscription still in flight aborts
263+ // even if the stream has not been created yet.
264+ reader->cancelled = true ;
262265 {
263266 const std::scoped_lock<std::mutex> sub_guard (reader->sub_mutex );
264267 if (reader->stream ) {
@@ -312,6 +315,10 @@ void SubscriptionThreadDispatcher::stopAll() {
312315 video_callbacks_.clear ();
313316
314317 for (auto & [id, reader] : active_data_readers_) {
318+ // Mark cancelled before closing so a subscription still in flight aborts
319+ // even if the stream has not been created yet, avoiding a join that never
320+ // completes during teardown.
321+ reader->cancelled = true ;
315322 {
316323 const std::scoped_lock<std::mutex> sub_guard (reader->sub_mutex );
317324 if (reader->stream ) {
@@ -397,6 +404,16 @@ std::thread SubscriptionThreadDispatcher::startAudioReaderLocked(const CallbackK
397404 const AudioFrameCallback& cb,
398405 const AudioStream::Options& opts) {
399406 LK_LOG_DEBUG (" Starting audio reader for participant={} track_name={}" , key.participant_identity , key.track_name );
407+
408+ auto existing = active_readers_.find (key);
409+ if (existing != active_readers_.end () && existing->second .track_sid == track->sid ()) {
410+ LK_LOG_DEBUG (
411+ " Skipping audio reader start for participant={} track_name={} because a "
412+ " reader for sid={} is already active" ,
413+ key.participant_identity , key.track_name , track->sid ());
414+ return {};
415+ }
416+
400417 auto old_thread = extractReaderThreadLocked (key);
401418
402419 if (static_cast <int >(active_readers_.size ()) >= kMaxActiveReaders ) {
@@ -415,6 +432,7 @@ std::thread SubscriptionThreadDispatcher::startAudioReaderLocked(const CallbackK
415432
416433 ActiveReader reader;
417434 reader.audio_stream = stream;
435+ reader.track_sid = track->sid ();
418436 const std::string participant_identity = key.participant_identity ;
419437 const std::string track_name = key.track_name ;
420438 // NOLINTBEGIN(bugprone-lambda-function-name,bugprone-exception-escape)
@@ -454,6 +472,16 @@ std::thread SubscriptionThreadDispatcher::startVideoReaderLocked(const CallbackK
454472 const std::shared_ptr<Track>& track,
455473 const RegisteredVideoCallback& callback) {
456474 LK_LOG_DEBUG (" Starting video reader for participant={} track_name={}" , key.participant_identity , key.track_name );
475+
476+ auto existing = active_readers_.find (key);
477+ if (existing != active_readers_.end () && existing->second .track_sid == track->sid ()) {
478+ LK_LOG_DEBUG (
479+ " Skipping video reader start for participant={} track_name={} because a "
480+ " reader for sid={} is already active" ,
481+ key.participant_identity , key.track_name , track->sid ());
482+ return {};
483+ }
484+
457485 auto old_thread = extractReaderThreadLocked (key);
458486
459487 if (static_cast <int >(active_readers_.size ()) >= kMaxActiveReaders ) {
@@ -472,6 +500,7 @@ std::thread SubscriptionThreadDispatcher::startVideoReaderLocked(const CallbackK
472500
473501 ActiveReader reader;
474502 reader.video_stream = stream;
503+ reader.track_sid = track->sid ();
475504 auto legacy_cb = callback.legacy_callback ;
476505 auto event_cb = callback.event_callback ;
477506 const std::string participant_identity = key.participant_identity ;
@@ -522,6 +551,9 @@ std::thread SubscriptionThreadDispatcher::extractDataReaderThreadLocked(DataFram
522551 }
523552 auto reader = std::move (it->second );
524553 active_data_readers_.erase (it);
554+ // Mark cancelled before closing so a subscription still in flight aborts even
555+ // if the stream has not been created yet.
556+ reader->cancelled = true ;
525557 {
526558 const std::scoped_lock<std::mutex> guard (reader->sub_mutex );
527559 if (reader->stream ) {
@@ -531,28 +563,36 @@ std::thread SubscriptionThreadDispatcher::extractDataReaderThreadLocked(DataFram
531563 return std::move (reader->thread );
532564}
533565
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- }
566+ void SubscriptionThreadDispatcher::eraseDataReaderIfCurrent (DataFrameCallbackId id,
567+ const std::shared_ptr<ActiveDataReader>& reader) {
568+ const std::scoped_lock<std::mutex> lock (lock_);
569+ auto it = active_data_readers_.find (id);
570+ if (it == active_data_readers_.end () || it->second != reader) {
571+ // The slot was already extracted or replaced; the owner joins that thread.
572+ return ;
549573 }
550- return {};
574+ // Detach so the reader can be destroyed by its own thread's final shared_ptr
575+ // release without tripping std::thread's joinable-at-destruction check. The
576+ // thread is already returning when this runs, so nothing is left to join.
577+ if (reader->thread .joinable ()) {
578+ reader->thread .detach ();
579+ }
580+ active_data_readers_.erase (it);
551581}
552582
553583std::thread SubscriptionThreadDispatcher::startDataReaderLocked (DataFrameCallbackId id, const DataCallbackKey& key,
554584 const std::shared_ptr<RemoteDataTrack>& track,
555585 const DataFrameCallback& cb) {
586+ auto existing = active_data_readers_.find (id);
587+ if (existing != active_data_readers_.end () && existing->second ->remote_track &&
588+ existing->second ->remote_track ->info ().sid == track->info ().sid ) {
589+ LK_LOG_DEBUG (
590+ " Skipping data reader start for \" {}\" track=\" {}\" because a reader for "
591+ " sid={} is already active" ,
592+ key.participant_identity , key.track_name , track->info ().sid );
593+ return {};
594+ }
595+
556596 auto old_thread = extractDataReaderThreadLocked (id);
557597
558598 const int total_active = static_cast <int >(active_readers_.size ()) + static_cast <int >(active_data_readers_.size ());
@@ -571,7 +611,7 @@ std::thread SubscriptionThreadDispatcher::startDataReaderLocked(DataFrameCallbac
571611 auto identity = key.participant_identity ;
572612 auto track_name = key.track_name ;
573613 // NOLINTBEGIN(bugprone-lambda-function-name)
574- reader->thread = std::thread ([reader, track, cb, identity, track_name]() {
614+ reader->thread = std::thread ([this , id, reader, track, cb, identity, track_name]() {
575615 LK_LOG_INFO (" Data reader thread: subscribing to \" {}\" track=\" {}\" " , identity, track_name);
576616 std::shared_ptr<DataTrackStream> stream;
577617 auto subscribe_result = track->subscribe ();
@@ -581,13 +621,21 @@ std::thread SubscriptionThreadDispatcher::startDataReaderLocked(DataFrameCallbac
581621 " Failed to subscribe to data track \" {}\" from \" {}\" : code={} "
582622 " message={}" ,
583623 track_name, identity, static_cast <std::uint32_t >(error.code ), error.message );
624+ eraseDataReaderIfCurrent (id, reader);
584625 return ;
585626 }
586627 stream = subscribe_result.value ();
587628 LK_LOG_INFO (" Data reader thread: subscribed to \" {}\" track=\" {}\" " , identity, track_name);
588629
589630 {
590631 const std::scoped_lock<std::mutex> guard (reader->sub_mutex );
632+ // A replacement or teardown may have cancelled this reader while the
633+ // subscribe was in flight. Close the fresh stream and bail so we do not
634+ // leave a second live subscription behind.
635+ if (reader->cancelled .load ()) {
636+ stream->close ();
637+ return ;
638+ }
591639 reader->stream = stream;
592640 }
593641
@@ -606,6 +654,9 @@ std::thread SubscriptionThreadDispatcher::startDataReaderLocked(DataFrameCallbac
606654 " \" {}\" : code={} message={}" ,
607655 track_name, identity, static_cast <std::uint32_t >(error->code ), error->message );
608656 }
657+ // Clean our own slot if the stream ended on its own (server EOS) and no
658+ // extract/teardown already claimed it. A no-op when we were extracted.
659+ eraseDataReaderIfCurrent (id, reader);
609660 LK_LOG_INFO (" Data reader thread exiting for \" {}\" track=\" {}\" " , identity, track_name);
610661 });
611662 // NOLINTEND(bugprone-lambda-function-name)
0 commit comments