Skip to content

Commit c273839

Browse files
committed
align with spec and add tests
1 parent 59a96a7 commit c273839

2 files changed

Lines changed: 131 additions & 7 deletions

File tree

livekit-api/src/signal_client/mod.rs

Lines changed: 127 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ mod signal_stream;
4242
#[cfg(all(test, feature = "signal-client-tokio", feature = "access-token"))]
4343
mod 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+
4550
pub use region_url_provider::RegionUrlProvider;
4651

4752
pub 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();

livekit-api/src/signal_client/signal_test.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -371,8 +371,8 @@ async fn close_before_join() {
371371
.err()
372372
.expect("connect must fail when the server closes before the join");
373373
assert!(
374-
matches!(err, SignalError::WsError(_)),
375-
"expected a WS error for a close before join, got {err:?}"
374+
matches!(err, SignalError::Closed),
375+
"a server that closes before answering is a close, not a timeout, got {err:?}"
376376
);
377377
}
378378

@@ -429,8 +429,8 @@ async fn server_unreachable() {
429429
.err()
430430
.expect("connecting to a dead port must fail");
431431
assert!(
432-
matches!(err, SignalError::WsError(_)),
433-
"expected a transport (WS) error for an unreachable server, got {err:?}"
432+
matches!(err, SignalError::Connection(_)),
433+
"expected a transport connection error for an unreachable server, got {err:?}"
434434
);
435435
}
436436

0 commit comments

Comments
 (0)