@@ -49,6 +49,9 @@ pub type SignalEvents = mpsc::UnboundedReceiver<SignalEvent>;
4949pub type SignalResult < T > = Result < T , SignalError > ;
5050
5151pub const JOIN_RESPONSE_TIMEOUT : Duration = Duration :: from_secs ( 5 ) ;
52+ pub const SIGNAL_CONNECT_TIMEOUT : Duration = Duration :: from_secs ( 5 ) ;
53+ const REGION_FETCH_TIMEOUT : Duration = Duration :: from_secs ( 3 ) ;
54+ const VALIDATE_TIMEOUT : Duration = Duration :: from_secs ( 3 ) ;
5255pub const PROTOCOL_VERSION : u32 = 16 ;
5356
5457#[ derive( Error , Debug ) ]
@@ -96,6 +99,8 @@ pub struct SignalOptions {
9699 pub sdk_options : SignalSdkOptions ,
97100 /// Enable single peer connection mode
98101 pub single_peer_connection : bool ,
102+ /// Timeout for each individual signal connection attempt
103+ pub connect_timeout : Duration ,
99104}
100105
101106impl Default for SignalOptions {
@@ -105,6 +110,7 @@ impl Default for SignalOptions {
105110 adaptive_stream : false ,
106111 sdk_options : SignalSdkOptions :: default ( ) ,
107112 single_peer_connection : true ,
113+ connect_timeout : SIGNAL_CONNECT_TIMEOUT ,
108114 }
109115 }
110116}
@@ -268,7 +274,7 @@ impl SignalInner {
268274 let lk_url = get_livekit_url ( url, & options, use_v1_path, false , None , "" ) ?;
269275 // Try to connect to the SignalClient
270276 let ( stream, mut events, single_pc_mode_active) =
271- match SignalStream :: connect ( lk_url. clone ( ) , token) . await {
277+ match SignalStream :: connect ( lk_url. clone ( ) , token, options . connect_timeout ) . await {
272278 Ok ( ( new_stream, stream_events) ) => {
273279 log:: debug!(
274280 "signal connection successful: path={}, single_pc_mode={}" ,
@@ -297,7 +303,13 @@ impl SignalInner {
297303 if use_v1_path && is_not_found {
298304 let lk_url_v0 = get_livekit_url ( url, & options, false , false , None , "" ) ?;
299305 log:: warn!( "v1 path not found (404), falling back to v0 path" ) ;
300- match SignalStream :: connect ( lk_url_v0. clone ( ) , token) . await {
306+ match SignalStream :: connect (
307+ lk_url_v0. clone ( ) ,
308+ token,
309+ options. connect_timeout ,
310+ )
311+ . await
312+ {
301313 Ok ( ( new_stream, stream_events) ) => ( new_stream, stream_events, false ) ,
302314 Err ( err) => {
303315 log:: error!( "v0 fallback also failed: {:?}" , err) ;
@@ -338,18 +350,24 @@ impl SignalInner {
338350 async fn validate ( ws_url : url:: Url ) -> SignalResult < ( ) > {
339351 let validate_url = get_validate_url ( ws_url) ;
340352
341- if let Ok ( res) = http_client:: get ( validate_url. as_str ( ) ) . await {
342- let status = res. status ( ) ;
343- let body = res. text ( ) . await . ok ( ) . unwrap_or_default ( ) ;
353+ let validate_fut = async {
354+ if let Ok ( res) = http_client:: get ( validate_url. as_str ( ) ) . await {
355+ let status = res. status ( ) ;
356+ let body = res. text ( ) . await . ok ( ) . unwrap_or_default ( ) ;
344357
345- if status. is_client_error ( ) {
346- return Err ( SignalError :: Client ( status, body) ) ;
347- } else if status. is_server_error ( ) {
348- return Err ( SignalError :: Server ( status, body) ) ;
358+ if status. is_client_error ( ) {
359+ return Err ( SignalError :: Client ( status, body) ) ;
360+ } else if status. is_server_error ( ) {
361+ return Err ( SignalError :: Server ( status, body) ) ;
362+ }
349363 }
350- }
351364
352- Ok ( ( ) )
365+ Ok ( ( ) )
366+ } ;
367+
368+ livekit_runtime:: timeout ( VALIDATE_TIMEOUT , validate_fut)
369+ . await
370+ . map_err ( |_| SignalError :: Timeout ( "validate request timed out" . into ( ) ) ) ?
353371 }
354372
355373 /// Returns whether single peer connection mode is active
@@ -383,7 +401,8 @@ impl SignalInner {
383401 get_livekit_url ( & self . url , & self . options , self . single_pc_mode_active , true , None , sid)
384402 . unwrap ( ) ;
385403
386- let ( new_stream, mut events) = SignalStream :: connect ( lk_url, & token) . await ?;
404+ let ( new_stream, mut events) =
405+ SignalStream :: connect ( lk_url, & token, self . options . connect_timeout ) . await ?;
387406 let reconnect_response = get_reconnect_response ( & mut events) . await ?;
388407 * stream = Some ( new_stream) ;
389408
@@ -755,4 +774,103 @@ mod tests {
755774 assert_eq ! ( validate_url. path( ) , "/rtc/validate" ) ;
756775 assert_eq ! ( validate_url. scheme( ) , "https" ) ;
757776 }
777+
778+ #[ cfg( feature = "signal-client-tokio" ) ]
779+ #[ tokio:: test]
780+ async fn signal_stream_connect_timeout ( ) {
781+ use tokio:: net:: TcpListener ;
782+
783+ // Bind a TCP listener that accepts connections but never sends data
784+ let listener = TcpListener :: bind ( "127.0.0.1:0" ) . await . unwrap ( ) ;
785+ let addr = listener. local_addr ( ) . unwrap ( ) ;
786+
787+ // Spawn a task that accepts connections but does nothing (simulates a hanging server)
788+ let _accept_task = tokio:: spawn ( async move {
789+ loop {
790+ let Ok ( ( _socket, _) ) = listener. accept ( ) . await else {
791+ break ;
792+ } ;
793+ // Hold the connection open but never write anything
794+ tokio:: time:: sleep ( Duration :: from_secs ( 60 ) ) . await ;
795+ }
796+ } ) ;
797+
798+ let url = url:: Url :: parse ( & format ! ( "ws://127.0.0.1:{}" , addr. port( ) ) ) . unwrap ( ) ;
799+ let result = SignalStream :: connect ( url, "fake-token" , Duration :: from_millis ( 500 ) ) . await ;
800+
801+ assert ! ( result. is_err( ) ) ;
802+ let err = result. unwrap_err ( ) ;
803+ assert ! ( matches!( err, SignalError :: Timeout ( _) ) , "expected Timeout error, got: {:?}" , err) ;
804+ }
805+
806+ #[ cfg( feature = "signal-client-tokio" ) ]
807+ #[ tokio:: test]
808+ async fn region_fetch_parses_response ( ) {
809+ use tokio:: io:: AsyncWriteExt ;
810+ use tokio:: net:: TcpListener ;
811+
812+ let listener = TcpListener :: bind ( "127.0.0.1:0" ) . await . unwrap ( ) ;
813+ let addr = listener. local_addr ( ) . unwrap ( ) ;
814+
815+ // Spawn a task that serves a hand-crafted HTTP response with region JSON
816+ tokio:: spawn ( async move {
817+ let ( mut socket, _) = listener. accept ( ) . await . unwrap ( ) ;
818+
819+ // Read the request (consume it so the connection doesn't stall)
820+ let mut buf = [ 0u8 ; 4096 ] ;
821+ let _ = tokio:: io:: AsyncReadExt :: read ( & mut socket, & mut buf) . await ;
822+
823+ let body = r#"{"regions":[{"region":"us-east-1","url":"wss://us-east.livekit.cloud","distance":"100"},{"region":"eu-west-1","url":"wss://eu-west.livekit.cloud","distance":"200"}]}"# ;
824+
825+ let response = format ! (
826+ "HTTP/1.1 200 OK\r \n Content-Type: application/json\r \n Content-Length: {}\r \n \r \n {}" ,
827+ body. len( ) ,
828+ body
829+ ) ;
830+ socket. write_all ( response. as_bytes ( ) ) . await . unwrap ( ) ;
831+ } ) ;
832+
833+ let endpoint = format ! ( "http://127.0.0.1:{}/settings/regions" , addr. port( ) ) ;
834+ let result = region:: fetch_from_endpoint ( & endpoint, "fake-token" ) . await ;
835+
836+ let urls = result. unwrap ( ) ;
837+ assert_eq ! (
838+ urls,
839+ vec![
840+ "wss://us-east.livekit.cloud" . to_string( ) ,
841+ "wss://eu-west.livekit.cloud" . to_string( ) ,
842+ ]
843+ ) ;
844+ }
845+
846+ #[ cfg( feature = "signal-client-tokio" ) ]
847+ #[ tokio:: test]
848+ async fn region_fetch_timeout ( ) {
849+ use tokio:: net:: TcpListener ;
850+
851+ // Bind a listener that accepts but never responds
852+ let listener = TcpListener :: bind ( "127.0.0.1:0" ) . await . unwrap ( ) ;
853+ let addr = listener. local_addr ( ) . unwrap ( ) ;
854+
855+ tokio:: spawn ( async move {
856+ loop {
857+ let Ok ( ( _socket, _) ) = listener. accept ( ) . await else {
858+ break ;
859+ } ;
860+ // Hold connection open, never write
861+ tokio:: time:: sleep ( Duration :: from_secs ( 60 ) ) . await ;
862+ }
863+ } ) ;
864+
865+ let endpoint = format ! ( "http://127.0.0.1:{}/settings/regions" , addr. port( ) ) ;
866+ let result = region:: fetch_from_endpoint ( & endpoint, "fake-token" ) . await ;
867+
868+ assert ! ( result. is_err( ) ) ;
869+ let err = result. unwrap_err ( ) ;
870+ assert ! (
871+ matches!( err, SignalError :: RegionError ( ref msg) if msg. contains( "timed out" ) ) ,
872+ "expected RegionError with 'timed out', got: {:?}" ,
873+ err
874+ ) ;
875+ }
758876}
0 commit comments