@@ -47,6 +47,11 @@ class FakeMediaTrack : public Track {
4747 : Track(FfiHandle(0 ), std::move(sid), " track" , kind, StreamState::STATE_ACTIVE , false , true ) {}
4848};
4949
50+ // / Minimal frames used to invoke a stored callback directly, so tests can prove
51+ // / which callback a registration slot actually holds.
52+ AudioFrame makeAudioFrame () { return AudioFrame::create (48000 , 1 , 480 ); }
53+ VideoFrame makeVideoFrame () { return VideoFrame::create (16 , 16 , VideoBufferType::RGBA ); }
54+
5055template <typename Predicate>
5156bool waitFor (Predicate predicate, std::chrono::milliseconds timeout) {
5257 const auto start = std::chrono::steady_clock::now ();
@@ -233,23 +238,54 @@ TEST_F(SubscriptionThreadDispatcherTest, ClearNonExistentCallbackIsNoOp) {
233238 EXPECT_NO_THROW (dispatcher.clearOnVideoFrameCallback (" nobody" , " missing" ));
234239}
235240
236- TEST_F (SubscriptionThreadDispatcherTest, OverwriteAudioCallbackKeepsSingleEntry ) {
241+ TEST_F (SubscriptionThreadDispatcherTest, OverwriteAudioCallbackStoresTheNewCallback ) {
237242 SubscriptionThreadDispatcher dispatcher;
238- std::atomic<int > counter1 {0 };
239- std::atomic<int > counter2 {0 };
243+ std::atomic<int > first {0 };
244+ std::atomic<int > second {0 };
240245
241- dispatcher.setOnAudioFrameCallback (" alice" , " mic-main" , [&counter1 ](const AudioFrame&) { counter1 ++; });
242- dispatcher.setOnAudioFrameCallback (" alice" , " mic-main" , [&counter2 ](const AudioFrame&) { counter2 ++; });
246+ dispatcher.setOnAudioFrameCallback (" alice" , " mic-main" , [&first ](const AudioFrame&) { first ++; });
247+ dispatcher.setOnAudioFrameCallback (" alice" , " mic-main" , [&second ](const AudioFrame&) { second ++; });
243248
244249 EXPECT_EQ (audioCallbacks (dispatcher).size (), 1u ) << " Re-registering with the same key should overwrite, not add" ;
250+
251+ // Invoke what the slot actually holds: size alone would not catch a setter
252+ // that tore down the reader but forgot to install the new callback.
253+ const CallbackKey key{" alice" , " mic-main" };
254+ audioCallbacks (dispatcher)[key].callback (makeAudioFrame ());
255+ EXPECT_EQ (first.load (), 0 ) << " The replaced callback must not be the one stored" ;
256+ EXPECT_EQ (second.load (), 1 );
245257}
246258
247- TEST_F (SubscriptionThreadDispatcherTest, OverwriteVideoCallbackKeepsSingleEntry ) {
259+ TEST_F (SubscriptionThreadDispatcherTest, OverwriteVideoCallbackStoresTheNewCallback ) {
248260 SubscriptionThreadDispatcher dispatcher;
249- dispatcher.setOnVideoFrameCallback (" alice" , " cam-main" , [](const VideoFrame&, std::int64_t ) {});
250- dispatcher.setOnVideoFrameCallback (" alice" , " cam-main" , [](const VideoFrame&, std::int64_t ) {});
261+ std::atomic<int > first{0 };
262+ std::atomic<int > second{0 };
263+
264+ dispatcher.setOnVideoFrameCallback (" alice" , " cam-main" , [&first](const VideoFrame&, std::int64_t ) { first++; });
265+ dispatcher.setOnVideoFrameCallback (" alice" , " cam-main" , [&second](const VideoFrame&, std::int64_t ) { second++; });
251266
252267 EXPECT_EQ (videoCallbacks (dispatcher).size (), 1u );
268+
269+ const CallbackKey key{" alice" , " cam-main" };
270+ videoCallbacks (dispatcher)[key].legacy_callback (makeVideoFrame (), 0 );
271+ EXPECT_EQ (first.load (), 0 );
272+ EXPECT_EQ (second.load (), 1 );
273+ }
274+
275+ TEST_F (SubscriptionThreadDispatcherTest, OverwriteAudioCallbackStoresTheNewStreamOptions) {
276+ SubscriptionThreadDispatcher dispatcher;
277+ AudioStream::Options first_opts;
278+ first_opts.capacity = 4 ;
279+ AudioStream::Options second_opts;
280+ second_opts.capacity = 32 ;
281+
282+ dispatcher.setOnAudioFrameCallback (" alice" , " mic-main" , [](const AudioFrame&) {}, first_opts);
283+ dispatcher.setOnAudioFrameCallback (" alice" , " mic-main" , [](const AudioFrame&) {}, second_opts);
284+
285+ // The options travel with the callback into the next reader, so a stale copy
286+ // would silently rebuild the stream with the wrong queue behavior.
287+ const CallbackKey key{" alice" , " mic-main" };
288+ EXPECT_EQ (audioCallbacks (dispatcher)[key].options .capacity , 32u );
253289}
254290
255291TEST_F (SubscriptionThreadDispatcherTest, MultipleDistinctCallbacksAreIndependent) {
@@ -664,11 +700,15 @@ TEST_F(SubscriptionThreadDispatcherTest, SetOnAudioWhileReaderActiveReplacesRegi
664700 activeReaders (dispatcher)[key].track_sid = " TR_audio_1" ;
665701 ASSERT_EQ (activeReaderCount (dispatcher), 1u );
666702
667- dispatcher.setOnAudioFrameCallback (" alice" , " mic" , [](const AudioFrame&) {});
703+ std::atomic<int > replacement_invocations{0 };
704+ dispatcher.setOnAudioFrameCallback (" alice" , " mic" ,
705+ [&replacement_invocations](const AudioFrame&) { replacement_invocations++; });
668706
669707 EXPECT_EQ (activeReaderCount (dispatcher), 0u ) << " The stale reader must be extracted so it stops dispatching to the "
670708 " callback it captured by value" ;
671- EXPECT_EQ (audioCallbacks (dispatcher).size (), 1u );
709+ ASSERT_EQ (audioCallbacks (dispatcher).size (), 1u );
710+ audioCallbacks (dispatcher)[key].callback (makeAudioFrame ());
711+ EXPECT_EQ (replacement_invocations.load (), 1 ) << " The replacement callback must be the one now stored" ;
672712}
673713
674714TEST_F (SubscriptionThreadDispatcherTest, SetOnVideoWhileReaderActiveReplacesRegistrationAndStopsReader) {
@@ -679,10 +719,14 @@ TEST_F(SubscriptionThreadDispatcherTest, SetOnVideoWhileReaderActiveReplacesRegi
679719 activeReaders (dispatcher)[key].track_sid = " TR_video_1" ;
680720 ASSERT_EQ (activeReaderCount (dispatcher), 1u );
681721
682- dispatcher.setOnVideoFrameCallback (" alice" , " cam" , [](const VideoFrame&, std::int64_t ) {});
722+ std::atomic<int > replacement_invocations{0 };
723+ dispatcher.setOnVideoFrameCallback (
724+ " alice" , " cam" , [&replacement_invocations](const VideoFrame&, std::int64_t ) { replacement_invocations++; });
683725
684726 EXPECT_EQ (activeReaderCount (dispatcher), 0u );
685- EXPECT_EQ (videoCallbacks (dispatcher).size (), 1u );
727+ ASSERT_EQ (videoCallbacks (dispatcher).size (), 1u );
728+ videoCallbacks (dispatcher)[key].legacy_callback (makeVideoFrame (), 0 );
729+ EXPECT_EQ (replacement_invocations.load (), 1 ) << " The replacement callback must be the one now stored" ;
686730}
687731
688732TEST_F (SubscriptionThreadDispatcherTest, SetOnVideoEventWhileReaderActiveReplacesRegistrationAndStopsReader) {
@@ -727,6 +771,25 @@ TEST_F(SubscriptionThreadDispatcherTest, SetOnAudioAfterReplacementRestartsOnNex
727771 EXPECT_EQ (audioCallbacks (dispatcher).size (), 1u );
728772}
729773
774+ TEST_F (SubscriptionThreadDispatcherTest, SetOnVideoAfterReplacementRestartsOnNextSubscribe) {
775+ SubscriptionThreadDispatcher dispatcher;
776+ dispatcher.setOnVideoFrameCallback (" alice" , " cam" , [](const VideoFrame&, std::int64_t ) {});
777+
778+ const CallbackKey key{" alice" , " cam" };
779+ activeReaders (dispatcher)[key].track_sid = " TR_video_1" ;
780+ ASSERT_EQ (activeReaderCount (dispatcher), 1u );
781+
782+ dispatcher.setOnVideoFrameCallback (" alice" , " cam" , [](const VideoFrame&, std::int64_t ) {});
783+ ASSERT_EQ (activeReaderCount (dispatcher), 0u );
784+
785+ // As in the audio case, the throw from VideoStream::fromTrack on the invalid
786+ // fake handle is the evidence that the guard no longer short-circuits startup.
787+ auto track = std::make_shared<FakeMediaTrack>(" TR_video_1" , TrackKind::KIND_VIDEO );
788+ EXPECT_ANY_THROW (dispatcher.handleTrackSubscribed (" alice" , " cam" , track));
789+
790+ EXPECT_EQ (videoCallbacks (dispatcher).size (), 1u );
791+ }
792+
730793TEST_F (SubscriptionThreadDispatcherTest, SetOnAudioWithoutReplacementLeavesSidGuardIntact) {
731794 SubscriptionThreadDispatcher dispatcher;
732795 dispatcher.setOnAudioFrameCallback (" alice" , " mic" , [](const AudioFrame&) {});
@@ -742,31 +805,94 @@ TEST_F(SubscriptionThreadDispatcherTest, SetOnAudioWithoutReplacementLeavesSidGu
742805 EXPECT_EQ (activeReaderCount (dispatcher), 1u );
743806}
744807
745- TEST_F (SubscriptionThreadDispatcherTest, SetOnAudioWithoutActiveReaderOverwritesRegistration) {
808+ // Distinct from ClearAudioCallbackRemovesRegistration, which clears a key that
809+ // has no reader: this covers clearing while a reader is active.
810+ TEST_F (SubscriptionThreadDispatcherTest, ClearAudioCallbackWithActiveReaderStopsReader) {
746811 SubscriptionThreadDispatcher dispatcher;
747812 dispatcher.setOnAudioFrameCallback (" alice" , " mic" , [](const AudioFrame&) {});
748- // No reader is active, so re-registering the same key simply overwrites the
749- // stored callback.
750- dispatcher.setOnAudioFrameCallback (" alice" , " mic" , [](const AudioFrame&) {});
751- EXPECT_EQ (audioCallbacks (dispatcher).size (), 1u );
813+
814+ const CallbackKey key{" alice" , " mic" };
815+ activeReaders (dispatcher)[key].track_sid = " TR_audio_1" ;
816+ ASSERT_EQ (activeReaderCount (dispatcher), 1u );
817+
818+ dispatcher.clearOnAudioFrameCallback (" alice" , " mic" );
752819 EXPECT_EQ (activeReaderCount (dispatcher), 0u );
820+ EXPECT_TRUE (audioCallbacks (dispatcher).empty ());
753821}
754822
755- TEST_F (SubscriptionThreadDispatcherTest, ClearThenSetOnAudioRegistersNewCallback) {
823+ TEST_F (SubscriptionThreadDispatcherTest, ClearVideoCallbackWithActiveReaderStopsReader) {
824+ SubscriptionThreadDispatcher dispatcher;
825+ dispatcher.setOnVideoFrameCallback (" alice" , " cam" , [](const VideoFrame&, std::int64_t ) {});
826+
827+ const CallbackKey key{" alice" , " cam" };
828+ activeReaders (dispatcher)[key].track_sid = " TR_video_1" ;
829+ ASSERT_EQ (activeReaderCount (dispatcher), 1u );
830+
831+ dispatcher.clearOnVideoFrameCallback (" alice" , " cam" );
832+ EXPECT_EQ (activeReaderCount (dispatcher), 0u );
833+ EXPECT_TRUE (videoCallbacks (dispatcher).empty ());
834+ }
835+
836+ // The reverse of SetOnVideoEventWhileReaderActiveReplacesRegistrationAndStopsReader:
837+ // the legacy setter must displace a stored event callback, not merge with it.
838+ TEST_F (SubscriptionThreadDispatcherTest, SetOnVideoDisplacesStoredEventCallback) {
839+ SubscriptionThreadDispatcher dispatcher;
840+ dispatcher.setOnVideoFrameEventCallback (" alice" , " cam" , [](const VideoFrameEvent&) {});
841+
842+ const CallbackKey key{" alice" , " cam" };
843+ activeReaders (dispatcher)[key].track_sid = " TR_video_1" ;
844+ ASSERT_EQ (activeReaderCount (dispatcher), 1u );
845+
846+ dispatcher.setOnVideoFrameCallback (" alice" , " cam" , [](const VideoFrame&, std::int64_t ) {});
847+
848+ EXPECT_EQ (activeReaderCount (dispatcher), 0u );
849+ ASSERT_EQ (videoCallbacks (dispatcher).size (), 1u );
850+ EXPECT_TRUE (static_cast <bool >(videoCallbacks (dispatcher)[key].legacy_callback ));
851+ EXPECT_FALSE (static_cast <bool >(videoCallbacks (dispatcher)[key].event_callback ));
852+ }
853+
854+ // Replacement must be scoped to its own key; an unrelated subscription's reader
855+ // is extracted by key, so a bug there would tear down the wrong stream.
856+ TEST_F (SubscriptionThreadDispatcherTest, ReplacementLeavesOtherKeysReadersUntouched) {
857+ SubscriptionThreadDispatcher dispatcher;
858+ dispatcher.setOnAudioFrameCallback (" alice" , " mic" , [](const AudioFrame&) {});
859+ dispatcher.setOnAudioFrameCallback (" bob" , " mic" , [](const AudioFrame&) {});
860+
861+ const CallbackKey alice{" alice" , " mic" };
862+ const CallbackKey bob{" bob" , " mic" };
863+ activeReaders (dispatcher)[alice].track_sid = " TR_audio_1" ;
864+ activeReaders (dispatcher)[bob].track_sid = " TR_audio_2" ;
865+ ASSERT_EQ (activeReaderCount (dispatcher), 2u );
866+
867+ dispatcher.setOnAudioFrameCallback (" alice" , " mic" , [](const AudioFrame&) {});
868+
869+ EXPECT_EQ (activeReaderCount (dispatcher), 1u );
870+ EXPECT_EQ (activeReaders (dispatcher).count (alice), 0u );
871+ ASSERT_EQ (activeReaders (dispatcher).count (bob), 1u );
872+ EXPECT_EQ (activeReaders (dispatcher)[bob].track_sid , " TR_audio_2" ) << " Replacing one key must not disturb another" ;
873+ EXPECT_EQ (audioCallbacks (dispatcher).size (), 2u );
874+ }
875+
876+ // Unsubscribe stops the reader but keeps the registration, so a replacement made
877+ // while unsubscribed is the one that binds on the next subscribe.
878+ TEST_F (SubscriptionThreadDispatcherTest, ReplacementWhileUnsubscribedKeepsRegistrationForNextSubscribe) {
756879 SubscriptionThreadDispatcher dispatcher;
757880 dispatcher.setOnAudioFrameCallback (" alice" , " mic" , [](const AudioFrame&) {});
758881
759882 const CallbackKey key{" alice" , " mic" };
760883 activeReaders (dispatcher)[key].track_sid = " TR_audio_1" ;
761884 ASSERT_EQ (activeReaderCount (dispatcher), 1u );
762885
763- // Clearing first is no longer required, but must still work.
764- dispatcher.clearOnAudioFrameCallback (" alice" , " mic" );
886+ dispatcher.handleTrackUnsubscribed (" alice" , TrackSource::SOURCE_MICROPHONE , " mic" );
765887 EXPECT_EQ (activeReaderCount (dispatcher), 0u );
766- EXPECT_TRUE (audioCallbacks (dispatcher).empty ()) ;
888+ EXPECT_EQ (audioCallbacks (dispatcher).size (), 1u ) << " Unsubscribe must preserve the registration " ;
767889
768- dispatcher.setOnAudioFrameCallback (" alice" , " mic" , [](const AudioFrame&) {});
769- EXPECT_EQ (audioCallbacks (dispatcher).size (), 1u );
890+ std::atomic<int > replacement_invocations{0 };
891+ dispatcher.setOnAudioFrameCallback (" alice" , " mic" ,
892+ [&replacement_invocations](const AudioFrame&) { replacement_invocations++; });
893+ ASSERT_EQ (audioCallbacks (dispatcher).size (), 1u );
894+ audioCallbacks (dispatcher)[key].callback (makeAudioFrame ());
895+ EXPECT_EQ (replacement_invocations.load (), 1 );
770896}
771897
772898// ============================================================================
@@ -783,21 +909,6 @@ TEST_F(SubscriptionThreadDispatcherTest, IsSelfThreadIdentifiesTheCallingThread)
783909 EXPECT_FALSE (isSelfThread (other_id));
784910}
785911
786- TEST_F (SubscriptionThreadDispatcherTest, ReplacingReaderWithDefaultThreadIdDisposesCleanly) {
787- SubscriptionThreadDispatcher dispatcher;
788- dispatcher.setOnAudioFrameCallback (" alice" , " mic" , [](const AudioFrame&) {});
789-
790- // A fabricated reader carries a default-constructed thread_id and a
791- // non-joinable thread; disposal must treat it as someone else's thread and
792- // simply drop it rather than taking the self-join path.
793- const CallbackKey key{" alice" , " mic" };
794- activeReaders (dispatcher)[key].track_sid = " TR_audio_1" ;
795- ASSERT_EQ (activeReaders (dispatcher)[key].thread_id , std::thread::id{});
796-
797- dispatcher.setOnAudioFrameCallback (" alice" , " mic" , [](const AudioFrame&) {});
798- EXPECT_EQ (activeReaderCount (dispatcher), 0u );
799- }
800-
801912// ============================================================================
802913// SID deduplication: data reader start is skipped for the same SID and
803914// replaced (stopping the previous reader) for a new SID
0 commit comments