Skip to content

Commit 9196330

Browse files
ensure signal connections times out properly and retries (#895)
* ensure signal connections times out properly and retries * generated protobuf --------- Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 8b3f35a commit 9196330

9 files changed

Lines changed: 235 additions & 39 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

livekit-api/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,6 @@ isahc = { version = "1.7.2", default-features = false, features = [ "json", "tex
103103

104104
scopeguard = "1.2.0"
105105
rand = { workspace = true }
106+
107+
[dev-dependencies]
108+
tokio = { workspace = true, features = ["rt", "rt-multi-thread", "net", "time", "macros", "io-util"] }

livekit-api/src/signal_client/mod.rs

Lines changed: 130 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ pub type SignalEvents = mpsc::UnboundedReceiver<SignalEvent>;
4949
pub type SignalResult<T> = Result<T, SignalError>;
5050

5151
pub 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);
5255
pub 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

101106
impl 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\nContent-Type: application/json\r\nContent-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
}

livekit-api/src/signal_client/region.rs

Lines changed: 72 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use serde::Deserialize;
1717

1818
use crate::http_client;
1919

20-
use super::{get_livekit_url, SignalError, SignalResult};
20+
use super::{SignalError, SignalResult, REGION_FETCH_TIMEOUT};
2121

2222
pub struct RegionUrlProvider;
2323

@@ -36,36 +36,44 @@ pub struct RegionUrlInfo {
3636
impl RegionUrlProvider {
3737
pub async fn fetch_region_urls(url: &str, token: &str) -> SignalResult<Vec<String>> {
3838
if is_cloud_url(url)? {
39-
let client = http_client::Client::new();
40-
let mut headers = HeaderMap::new();
41-
headers.insert(
42-
AUTHORIZATION,
43-
HeaderValue::from_str(&format!("Bearer {}", token)).unwrap(),
44-
);
45-
let res = client
46-
.get(region_endpoint(url)?)
47-
.headers(headers)
48-
.send()
49-
.await
50-
.map_err(|e| SignalError::RegionError(e.to_string()))?;
51-
52-
if !res.status().is_success() {
53-
return Err(SignalError::Client(
54-
res.status(),
55-
res.text().await.unwrap_or_default(),
56-
));
57-
}
58-
let res = res
59-
.json::<RegionUrlResponse>()
60-
.await
61-
.map_err(|e| SignalError::RegionError(e.to_string()))?;
62-
Ok(res.regions.into_iter().map(|i| i.url).collect())
39+
let endpoint = region_endpoint(url)?;
40+
fetch_from_endpoint(&endpoint, token).await
6341
} else {
6442
Ok(vec![])
6543
}
6644
}
6745
}
6846

47+
pub(crate) async fn fetch_from_endpoint(
48+
endpoint_url: &str,
49+
token: &str,
50+
) -> SignalResult<Vec<String>> {
51+
let fetch_fut = async {
52+
let client = http_client::Client::new();
53+
let mut headers = HeaderMap::new();
54+
headers.insert(AUTHORIZATION, HeaderValue::from_str(&format!("Bearer {}", token)).unwrap());
55+
let res = client
56+
.get(endpoint_url)
57+
.headers(headers)
58+
.send()
59+
.await
60+
.map_err(|e| SignalError::RegionError(e.to_string()))?;
61+
62+
if !res.status().is_success() {
63+
return Err(SignalError::Client(res.status(), res.text().await.unwrap_or_default()));
64+
}
65+
let res = res
66+
.json::<RegionUrlResponse>()
67+
.await
68+
.map_err(|e| SignalError::RegionError(e.to_string()))?;
69+
Ok(res.regions.into_iter().map(|i| i.url).collect())
70+
};
71+
72+
livekit_runtime::timeout(REGION_FETCH_TIMEOUT, fetch_fut)
73+
.await
74+
.map_err(|_| SignalError::RegionError("region fetch timed out".into()))?
75+
}
76+
6977
fn is_cloud_url(url: &str) -> SignalResult<bool> {
7078
let url = url::Url::parse(url).map_err(|err| SignalError::UrlParse(err.to_string()))?;
7179
let host = match url.host() {
@@ -89,3 +97,42 @@ fn region_endpoint(url: &str) -> SignalResult<String> {
8997

9098
Ok(url.to_string())
9199
}
100+
101+
#[cfg(test)]
102+
mod tests {
103+
use super::*;
104+
105+
#[test]
106+
fn test_is_cloud_url() {
107+
assert!(is_cloud_url("wss://myapp.livekit.cloud").unwrap());
108+
assert!(is_cloud_url("wss://myapp.livekit.run").unwrap());
109+
assert!(is_cloud_url("https://myapp.livekit.cloud").unwrap());
110+
111+
assert!(!is_cloud_url("wss://localhost:7880").unwrap());
112+
assert!(!is_cloud_url("wss://example.com").unwrap());
113+
assert!(!is_cloud_url("wss://livekit.cloud.example.com").unwrap());
114+
}
115+
116+
#[test]
117+
fn test_region_endpoint() {
118+
assert_eq!(
119+
region_endpoint("wss://myapp.livekit.cloud").unwrap(),
120+
"https://myapp.livekit.cloud/settings/regions"
121+
);
122+
assert_eq!(
123+
region_endpoint("ws://myapp.livekit.run").unwrap(),
124+
"http://myapp.livekit.run/settings/regions"
125+
);
126+
assert_eq!(
127+
region_endpoint("https://myapp.livekit.cloud").unwrap(),
128+
"https://myapp.livekit.cloud/settings/regions"
129+
);
130+
}
131+
132+
#[tokio::test]
133+
async fn test_fetch_non_cloud_url_returns_empty() {
134+
let result =
135+
RegionUrlProvider::fetch_region_urls("wss://localhost:7880", "fake-token").await;
136+
assert_eq!(result.unwrap(), Vec::<String>::new());
137+
}
138+
}

livekit-api/src/signal_client/signal_stream.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use futures_util::{
1919
use livekit_protocol as proto;
2020
use livekit_runtime::{JoinHandle, TcpStream};
2121
use prost::Message as ProtoMessage;
22-
use std::{env, io};
22+
use std::{env, io, time::Duration};
2323

2424
use tokio::sync::{mpsc, oneshot};
2525

@@ -88,6 +88,17 @@ impl SignalStream {
8888
pub async fn connect(
8989
url: url::Url,
9090
token: &str,
91+
connect_timeout: Duration,
92+
) -> SignalResult<(Self, mpsc::UnboundedReceiver<Box<proto::signal_response::Message>>)> {
93+
let connect_fut = Self::connect_inner(url, token);
94+
livekit_runtime::timeout(connect_timeout, connect_fut)
95+
.await
96+
.map_err(|_| SignalError::Timeout("signal connection timed out".into()))?
97+
}
98+
99+
async fn connect_inner(
100+
url: url::Url,
101+
token: &str,
91102
) -> SignalResult<(Self, mpsc::UnboundedReceiver<Box<proto::signal_response::Message>>)> {
92103
log::info!("connecting to {}", url);
93104
let mut request = url.clone().into_client_request()?;

livekit-ffi-node-bindings/src/proto/room_pb.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2478,6 +2478,13 @@ export class RoomOptions extends Message<RoomOptions> {
24782478
*/
24792479
singlePeerConnection?: boolean;
24802480

2481+
/**
2482+
* timeout in milliseconds for each signal connection attempt (default: 5000)
2483+
*
2484+
* @generated from field: optional uint64 connect_timeout_ms = 9;
2485+
*/
2486+
connectTimeoutMs?: bigint;
2487+
24812488
constructor(data?: PartialMessage<RoomOptions>) {
24822489
super();
24832490
proto2.util.initPartial(data, this);
@@ -2494,6 +2501,7 @@ export class RoomOptions extends Message<RoomOptions> {
24942501
{ no: 6, name: "join_retries", kind: "scalar", T: 13 /* ScalarType.UINT32 */, opt: true },
24952502
{ no: 7, name: "encryption", kind: "message", T: E2eeOptions, opt: true },
24962503
{ no: 8, name: "single_peer_connection", kind: "scalar", T: 8 /* ScalarType.BOOL */, opt: true },
2504+
{ no: 9, name: "connect_timeout_ms", kind: "scalar", T: 4 /* ScalarType.UINT64 */, opt: true },
24972505
]);
24982506

24992507
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): RoomOptions {

livekit-ffi/protocol/room.proto

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,7 @@ message RoomOptions {
311311
optional uint32 join_retries = 6;
312312
optional E2eeOptions encryption = 7;
313313
optional bool single_peer_connection = 8; // use single peer connection for both publish/subscribe (default: true)
314+
optional uint64 connect_timeout_ms = 9; // timeout in milliseconds for each signal connection attempt (default: 5000)
314315
}
315316

316317
//

0 commit comments

Comments
 (0)