@@ -42,6 +42,11 @@ mod signal_stream;
4242#[ cfg( all( test, feature = "signal-client-tokio" , feature = "access-token" ) ) ]
4343mod signal_test;
4444
45+ // Shared mock WsClient/HttpClient for the unit tests below. Gated on the signal client alone,
46+ // since the tests that use it do not need access-token.
47+ #[ cfg( all( test, feature = "signal-client-tokio" ) ) ]
48+ mod test_transport;
49+
4550pub use region_url_provider:: RegionUrlProvider ;
4651
4752pub type SignalEmitter = mpsc:: UnboundedSender < SignalEvent > ;
@@ -1055,7 +1060,10 @@ macro_rules! get_async_message {
10551060 }
10561061 }
10571062
1058- Err ( SignalError :: Timeout ( "connection closed before message received" . into( ) ) )
1063+ // The channel only ends when the read task does, i.e. the transport went away
1064+ // before the server answered. That is a close, not a timeout — nothing waited.
1065+ // Only the `livekit_runtime::timeout` wrapper below is a genuine timeout.
1066+ Err ( SignalError :: Closed )
10591067 } ;
10601068
10611069 livekit_runtime:: timeout( JOIN_RESPONSE_TIMEOUT , join) . await . map_err( |_| {
@@ -1088,7 +1096,10 @@ async fn get_reconnect_response(
10881096 }
10891097 }
10901098
1091- Err ( SignalError :: Timeout ( "connection closed before message received" . into ( ) ) )
1099+ // The channel only ends when the read task does, i.e. the transport went away
1100+ // before the server answered. That is a close, not a timeout — nothing waited.
1101+ // Only the `livekit_runtime::timeout` wrapper below is a genuine timeout.
1102+ Err ( SignalError :: Closed )
10921103 } ;
10931104
10941105 livekit_runtime:: timeout ( JOIN_RESPONSE_TIMEOUT , join) . await . map_err ( |_| {
@@ -1134,19 +1145,61 @@ mod tests {
11341145 /// which is fine — these tests only assert which side of the queue each
11351146 /// message lands on.
11361147 fn make_stub_inner ( ) -> Arc < SignalInner > {
1148+ make_stub_inner_with ( proto:: JoinResponse :: default ( ) )
1149+ }
1150+
1151+ /// As `make_stub_inner`, with a join response — `restart` reads the participant sid from it.
1152+ fn make_stub_inner_with ( join_response : proto:: JoinResponse ) -> Arc < SignalInner > {
11371153 Arc :: new ( SignalInner {
11381154 stream : AsyncRwLock :: new ( None ) ,
11391155 token : Mutex :: new ( String :: new ( ) ) ,
11401156 reconnecting : AtomicBool :: new ( false ) ,
11411157 queue : Default :: default ( ) ,
11421158 url : "wss://localhost:7880" . to_string ( ) ,
11431159 options : SignalOptions :: default ( ) ,
1144- join_response : proto :: JoinResponse :: default ( ) ,
1160+ join_response,
11451161 request_id : AtomicU32 :: new ( 1 ) ,
11461162 single_pc_mode_active : false ,
11471163 } )
11481164 }
11491165
1166+ fn mute ( sid : & str ) -> proto:: signal_request:: Message {
1167+ proto:: signal_request:: Message :: Mute ( proto:: MuteTrackRequest {
1168+ sid : sid. into ( ) ,
1169+ muted : true ,
1170+ } )
1171+ }
1172+
1173+ /// The sids of the queued mute requests, in queue order.
1174+ async fn queued_sids ( inner : & Arc < SignalInner > ) -> Vec < String > {
1175+ inner
1176+ . queue
1177+ . lock ( )
1178+ . await
1179+ . iter ( )
1180+ . filter_map ( |signal| match signal {
1181+ proto:: signal_request:: Message :: Mute ( m) => Some ( m. sid . clone ( ) ) ,
1182+ _ => None ,
1183+ } )
1184+ . collect ( )
1185+ }
1186+
1187+ /// A live stream over the shared mock transport, for the tests that need the
1188+ /// difference between "held because we are reconnecting" and "held because
1189+ /// there is nowhere to send".
1190+ async fn mock_stream ( ) -> SignalStream {
1191+ use crate :: signal_client:: test_transport:: install_mock_transport;
1192+ install_mock_transport ( ) ;
1193+ SignalStream :: connect (
1194+ url:: Url :: parse ( "wss://localhost:7880/rtc" ) . unwrap ( ) ,
1195+ "" ,
1196+ Duration :: from_secs ( 1 ) ,
1197+ )
1198+ . await
1199+ . expect ( "the mock transport always connects" )
1200+ . 0
1201+ }
1202+
11501203 #[ cfg( feature = "signal-client-tokio" ) ]
11511204 #[ tokio:: test]
11521205 async fn send_queues_queueable_signals_during_reconnect ( ) {
@@ -1228,6 +1281,77 @@ mod tests {
12281281 assert ! ( !inner. reconnecting. load( Ordering :: Acquire ) , "flag must be cleared" ) ;
12291282 }
12301283
1284+ /// The queue is FIFO, and the release order is the send order. The existing
1285+ /// `send_queues_queueable_signals_during_reconnect` only counts what landed there.
1286+ #[ cfg( feature = "signal-client-tokio" ) ]
1287+ #[ tokio:: test]
1288+ async fn queued_signals_keep_their_order ( ) {
1289+ let inner = make_stub_inner ( ) ;
1290+ inner. reconnecting . store ( true , Ordering :: Release ) ;
1291+
1292+ inner. send ( mute ( "first" ) ) . await ;
1293+ inner. send ( mute ( "second" ) ) . await ;
1294+ inner. send ( mute ( "third" ) ) . await ;
1295+
1296+ assert_eq ! ( queued_sids( & inner) . await , vec![ "first" , "second" , "third" ] ) ;
1297+ }
1298+
1299+ /// A resume is not complete when the transport comes back — the engine calls
1300+ /// `set_reconnected` once the media path is back too, which is seconds later. A
1301+ /// session-scoped send in that window must queue behind what is already waiting rather
1302+ /// than overtake it.
1303+ ///
1304+ /// Distinct from `send_queues_queueable_signals_during_reconnect`: that one runs with no
1305+ /// stream at all, so its message could have been queued merely because there was nowhere
1306+ /// to send it. Here the stream is live and only the `reconnecting` flag holds the message.
1307+ #[ cfg( feature = "signal-client-tokio" ) ]
1308+ #[ tokio:: test]
1309+ async fn send_still_queues_after_the_transport_returns ( ) {
1310+ let inner = make_stub_inner ( ) ;
1311+ inner. reconnecting . store ( true , Ordering :: Release ) ;
1312+
1313+ // issued while the resume is in flight
1314+ inner. send ( mute ( "held-during-resume" ) ) . await ;
1315+
1316+ // the resume has answered and its transport is installed; `restart` deliberately
1317+ // leaves `reconnecting` set until the engine reports in
1318+ * inner. stream . write ( ) . await = Some ( mock_stream ( ) . await ) ;
1319+ assert ! ( inner. reconnecting. load( Ordering :: Acquire ) , "restart leaves the flag set" ) ;
1320+
1321+ inner. send ( mute ( "issued-while-catching-up" ) ) . await ;
1322+
1323+ assert_eq ! (
1324+ queued_sids( & inner) . await ,
1325+ vec![ "held-during-resume" , "issued-while-catching-up" ] ,
1326+ "a live transport must not let a later send overtake a held one"
1327+ ) ;
1328+ }
1329+
1330+ /// A failed resume has to leave the flag clear, or every later attempt would route its
1331+ /// sends to a queue that nothing drains.
1332+ #[ cfg( feature = "signal-client-tokio" ) ]
1333+ #[ tokio:: test]
1334+ async fn restart_failure_resets_the_flag_so_a_retry_can_re_enter ( ) {
1335+ let _ = mock_stream ( ) . await ; // installs the shared mock transport
1336+ let inner = make_stub_inner_with ( proto:: JoinResponse {
1337+ participant : Some ( proto:: ParticipantInfo {
1338+ sid : "PA_test" . into ( ) ,
1339+ ..Default :: default ( )
1340+ } ) ,
1341+ ..Default :: default ( )
1342+ } ) ;
1343+
1344+ // The mock yields one Pong and then ends the stream, so no ReconnectResponse ever
1345+ // arrives and the resume fails.
1346+ let err =
1347+ inner. restart ( ) . await . err ( ) . expect ( "restart must fail without a reconnect answer" ) ;
1348+ assert ! ( matches!( err, SignalError :: Closed ) , "expected a close, got {err:?}" ) ;
1349+ assert ! (
1350+ !inner. reconnecting. load( Ordering :: Acquire ) ,
1351+ "a failed restart must clear the flag so the next attempt can re-enter"
1352+ ) ;
1353+ }
1354+
12311355 #[ test]
12321356 fn livekit_url_test ( ) {
12331357 let io = SignalOptions :: default ( ) ;
0 commit comments