livekit 0.9.6

Rust Client SDK for LiveKit
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
// Copyright 2025 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//! Reconnection Tests
//!
//! Exercises the engine reconnection paths end-to-end against a running
//! `livekit-server --dev` (or LiveKit Cloud), via [`Room::simulate_scenario`]:
//!
//! - `SignalReconnect` drives the lightweight *resume* path.
//! - `FullReconnect` asks the server to issue `LeaveRequest{Reconnect}`, forcing
//!   a *full* reconnect (new session, republish).
//!
//! Both should surface `Reconnecting` then `Reconnected` and return the room to
//! `Connected`. These guard the lifecycle-event and recovery behaviour the
//! engine's reconnect loop is responsible for.
//!
//! Environment variables (same as the other e2e suites):
//! - LIVEKIT_URL (default ws://localhost:7880)
//! - LIVEKIT_API_KEY (default "devkey")
//! - LIVEKIT_API_SECRET (default "secret")
//!
//! Run:
//!   cargo test -p livekit --features "__lk-e2e-test,native-tls" --test reconnection_test -- --nocapture

#![cfg(feature = "__lk-e2e-test")]

#[cfg(feature = "__lk-e2e-test")]
use {
    anyhow::{anyhow, bail, Result},
    common::{
        audio::{SineParameters, SineTrack},
        test_rooms,
    },
    libwebrtc::{
        native::create_random_uuid,
        prelude::{RtcVideoSource, VideoResolution},
        video_source::native::NativeVideoSource,
    },
    livekit::{
        options::TrackPublishOptions,
        track::{LocalTrack, LocalVideoTrack},
        ConnectionState, Room, RoomEvent, RoomOptions, SimulateScenario,
    },
    livekit_api::services::room::RoomClient,
    livekit_token::{AccessToken, VideoGrants},
    std::{env, net::SocketAddr, sync::Arc, time::Duration},
    tokio::{
        net::{TcpListener, TcpStream},
        sync::{mpsc::UnboundedReceiver, watch},
        time::{self, timeout},
    },
};

mod common;

/// Drives a reconnection via `scenario` and asserts the room reports
/// `Reconnecting`, then `Reconnected`, and ends up `Connected` again.
#[cfg(feature = "__lk-e2e-test")]
async fn assert_recovers(
    room: Room,
    mut events: UnboundedReceiver<RoomEvent>,
    scenario: SimulateScenario,
) -> Result<()> {
    assert_eq!(room.connection_state(), ConnectionState::Connected);

    // Kick off the reconnection. These scenarios return promptly (they close the
    // local signal channel or ask the server to issue a Leave); recovery then
    // proceeds asynchronously and surfaces as room events, which are buffered on
    // an unbounded channel until we observe them below.
    room.simulate_scenario(scenario)
        .await
        .map_err(|e| anyhow!("simulate_scenario failed: {e:?}"))?;

    // Expect Reconnecting, then Reconnected. Ignore unrelated events in between.
    let observe = async {
        let mut saw_reconnecting = false;
        while let Some(event) = events.recv().await {
            match event {
                RoomEvent::Reconnecting => saw_reconnecting = true,
                RoomEvent::Reconnected => {
                    if !saw_reconnecting {
                        bail!("received Reconnected without a preceding Reconnecting");
                    }
                    return Ok(());
                }
                RoomEvent::Disconnected { reason } => {
                    bail!("room disconnected during reconnection: {reason:?}");
                }
                _ => {}
            }
        }
        bail!("event stream ended before the room reconnected");
    };

    timeout(Duration::from_secs(30), observe).await??;

    assert_eq!(
        room.connection_state(),
        ConnectionState::Connected,
        "room should be Connected after recovery"
    );
    Ok(())
}

#[cfg(feature = "__lk-e2e-test")]
#[test_log::test(tokio::test)]
async fn test_signal_reconnect_resumes() -> Result<()> {
    let (room, events) = test_rooms(1).await?.pop().unwrap();
    assert_recovers(room, events, SimulateScenario::SignalReconnect).await
}

// `FullReconnect` forces a full reconnect (new session, republish) — driven
// client-side, so it does not depend on the server echoing a leave.
#[cfg(feature = "__lk-e2e-test")]
#[test_log::test(tokio::test)]
async fn test_full_reconnect_recovers() -> Result<()> {
    let (room, events) = test_rooms(1).await?.pop().unwrap();
    assert_recovers(room, events, SimulateScenario::FullReconnect).await
}

/// A full reconnect republishes tracks from a detached task that marks the room `Connected` when
/// it finishes. Closing the room while that task waits on the server must not let it bring the
/// room back: once closed, the room stays `Disconnected`.
#[cfg(feature = "__lk-e2e-test")]
#[test_log::test(tokio::test)]
async fn test_close_during_republish_stays_disconnected() -> Result<()> {
    let (room, mut events) = test_rooms(1).await?.pop().unwrap();
    let room = Arc::new(room);
    let params =
        SineParameters { sample_rate: 48_000, freq: 440.0, amplitude: 1.0, num_channels: 1 };
    SineTrack::new(room.clone(), params).publish().await?;

    room.simulate_scenario(SimulateScenario::FullReconnect)
        .await
        .map_err(|e| anyhow!("simulate_scenario failed: {e:?}"))?;

    // The republish task unpublishes first, then waits for the new publication.
    timeout(Duration::from_secs(15), async {
        while let Some(event) = events.recv().await {
            if matches!(event, RoomEvent::LocalTrackUnpublished { .. }) {
                return;
            }
        }
    })
    .await
    .map_err(|_| anyhow!("the full reconnect never started republishing"))?;
    room.close().await?;

    // Past the 10 s publish timeout, when the orphaned republish gives up and finishes.
    for _ in 0..120 {
        assert_eq!(room.connection_state(), ConnectionState::Disconnected);
        time::sleep(Duration::from_millis(100)).await;
    }
    Ok(())
}

// The server drops the signalling link during the resume, so the resume cannot
// complete and the engine must escalate to a full reconnect. Recovery here is
// only possible via that escalation, so a successful Reconnecting → Reconnected
// exercises the resume→full path (and the Restarting emitted on escalation,
// which drives the Room's remote-participant cleanup before the full reconnect).
#[cfg(feature = "__lk-e2e-test")]
#[test_log::test(tokio::test)]
async fn test_resume_failure_escalates_to_full_reconnect() -> Result<()> {
    let (room, events) = test_rooms(1).await?.pop().unwrap();
    assert_recovers(room, events, SimulateScenario::DisconnectSignalOnResume).await
}

// A participant disconnects while our signal link is down: the SFU broadcast
// its DISCONNECTED update into our dead socket, so it is lost. (Modern OSS
// servers replay recent disconnects on resume from a bounded per-connection
// cache — sendDisconnectUpdatesForReconnect — but that is best-effort: it is
// an LRU keyed off a last-seen-signal heuristic and does not exist where the
// resume is served without the previous connection's state, as observed on
// LiveKit Cloud. The `drop_disconnected_updates` fault simulates that loss.)
// On resume the server sends a full participant snapshot; the room must
// notice the participant is absent from it and synthesize
// ParticipantDisconnected — otherwise `remote_participants` keeps a ghost
// entry forever and the application never learns the participant left.
#[cfg(feature = "__lk-e2e-test")]
#[test_log::test(tokio::test)]
async fn test_resume_synthesizes_disconnect_for_participant_that_left() -> Result<()> {
    let api_key = env::var("LIVEKIT_API_KEY").unwrap_or_else(|_| "devkey".into());
    let api_secret = env::var("LIVEKIT_API_SECRET").unwrap_or_else(|_| "secret".into());
    let server_url = env::var("LIVEKIT_URL").unwrap_or_else(|_| "ws://localhost:7880".into());

    let room_name = format!("test_room_{}", create_random_uuid());
    let token = |identity: &str| {
        AccessToken::with_api_key(&api_key, &api_secret)
            .with_ttl(Duration::from_secs(30 * 60))
            .with_grants(VideoGrants {
                room_join: true,
                room: room_name.clone(),
                ..Default::default()
            })
            .with_identity(identity)
            .with_name(identity)
            .to_jwt()
    };

    // Three participants: the observer (whose view we assert on), the leaver
    // (disconnects mid-test), and a witness who stays — proving the
    // reconciliation only removes participants who actually left.
    let (observer, mut events) =
        Room::connect(&server_url, &token("observer")?, RoomOptions::default()).await?;
    let (leaver, _leaver_events) =
        Room::connect(&server_url, &token("leaver")?, RoomOptions::default()).await?;
    let (_witness, mut witness_events) =
        Room::connect(&server_url, &token("witness")?, RoomOptions::default()).await?;

    // Wait until the observer knows both remote participants.
    let saw_joins = async {
        let mut pending: std::collections::HashSet<&str> =
            ["leaver", "witness"].into_iter().collect();
        while let Some(event) = events.recv().await {
            if let RoomEvent::ParticipantConnected(p) = event {
                pending.remove(p.identity().as_str());
                if pending.is_empty() {
                    return Ok(());
                }
            }
        }
        bail!("event stream ended before all participants joined");
    };
    timeout(Duration::from_secs(15), saw_joins).await??;
    let sid_before = observer.local_participant().sid();

    // From here on the observer never receives an explicit DISCONNECTED
    // entry, exactly as if every delivery (and resume-time replay) attempt of
    // the leaver's disconnect had been lost with a dead connection.
    observer.drop_disconnected_updates(true);

    // The leaver leaves; the witness confirms the server processed it (so the
    // resume snapshot below no longer contains the leaver).
    leaver.close().await?;
    let saw_leave = async {
        while let Some(event) = witness_events.recv().await {
            if let RoomEvent::ParticipantDisconnected(p) = event {
                if p.identity().as_str() == "leaver" {
                    return Ok(());
                }
            }
        }
        bail!("witness event stream ended before the leaver's disconnect was broadcast");
    };
    timeout(Duration::from_secs(15), saw_leave).await??;

    // Sanity: the observer still holds the ghost entry.
    assert!(
        observer.remote_participants().keys().any(|identity| identity.as_str() == "leaver"),
        "observer should still hold the (stale) leaver before resuming"
    );

    // Resume the signal connection. The post-resume participant snapshot no
    // longer lists the leaver, and the reconciliation at the end of the
    // resume must synthesize its ParticipantDisconnected.
    observer
        .simulate_scenario(SimulateScenario::SignalReconnect)
        .await
        .map_err(|e| anyhow!("simulate_scenario failed: {e:?}"))?;

    let observe = async {
        let mut reconnected = false;
        let mut leaver_disconnected = false;
        while let Some(event) = events.recv().await {
            match event {
                RoomEvent::Reconnected => reconnected = true,
                RoomEvent::ParticipantDisconnected(p) => {
                    if p.identity().as_str() == "leaver" {
                        leaver_disconnected = true;
                    } else {
                        bail!(
                            "reconciliation disconnected a participant that never left: {}",
                            p.identity()
                        );
                    }
                }
                RoomEvent::Disconnected { reason } => {
                    bail!("observer disconnected during resume: {reason:?}");
                }
                _ => {}
            }
            if reconnected && leaver_disconnected {
                return Ok(());
            }
        }
        bail!("event stream ended before ParticipantDisconnected was synthesized");
    };
    timeout(Duration::from_secs(30), observe).await??;

    let remaining = observer.remote_participants();
    assert!(
        !remaining.keys().any(|identity| identity.as_str() == "leaver"),
        "remote_participants must not keep a ghost entry for a participant that left"
    );
    assert!(
        remaining.keys().any(|identity| identity.as_str() == "witness"),
        "reconciliation must keep participants that are still in the room"
    );
    // An unchanged local sid proves this recovered via resume — a full
    // reconnect (which clears participants anyway) would assign a new one and
    // would not exercise the resume-time reconciliation under test.
    assert_eq!(
        observer.local_participant().sid(),
        sid_before,
        "expected the lightweight resume path, but the session was fully restarted"
    );
    Ok(())
}

/// A minimal TCP proxy in front of the signalling server that can be killed.
/// Sending `true` on the returned channel closes the in-flight connection and
/// stops accepting, so the client's reconnect attempts all fail (connection
/// refused). Used to drive the engine's reconnect loop to exhaustion.
#[cfg(feature = "__lk-e2e-test")]
async fn start_killable_proxy(target_host_port: String) -> (SocketAddr, watch::Sender<bool>) {
    let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind proxy");
    let addr = listener.local_addr().expect("proxy addr");
    let (kill_tx, kill_rx) = watch::channel(false);

    tokio::spawn(async move {
        loop {
            let mut kr = kill_rx.clone();
            tokio::select! {
                _ = kr.changed() => break, // kill: stop accepting; dropping `listener` refuses new connects
                accepted = listener.accept() => {
                    let Ok((mut inbound, _)) = accepted else { break };
                    let target = target_host_port.clone();
                    let mut kr2 = kill_rx.clone();
                    tokio::spawn(async move {
                        if let Ok(mut outbound) = TcpStream::connect(&target).await {
                            tokio::select! {
                                _ = kr2.changed() => {} // kill: drop both streams, severing the client link
                                _ = tokio::io::copy_bidirectional(&mut inbound, &mut outbound) => {}
                            }
                        }
                    });
                }
            }
        }
    });

    (addr, kill_tx)
}

// When every reconnect attempt fails, the engine must exhaust its bounded
// retries and emit Disconnected — it must NOT stay stuck in Reconnecting
// forever. We connect through a killable proxy, kill it, and assert the room
// reaches Disconnected after a reconnection was attempted. (The reason is
// UnknownReason here because a dropped signal link carries no richer cause; the
// #2b improvement surfaces a meaningful cause only when the triggering event
// has one.) Slow by design: it waits out the full bounded backoff sequence.
#[cfg(feature = "__lk-e2e-test")]
#[test_log::test(tokio::test)]
async fn test_reconnect_exhaustion_disconnects() -> Result<()> {
    let api_key = env::var("LIVEKIT_API_KEY").unwrap_or_else(|_| "devkey".into());
    let api_secret = env::var("LIVEKIT_API_SECRET").unwrap_or_else(|_| "secret".into());
    let server_url = env::var("LIVEKIT_URL").unwrap_or_else(|_| "ws://localhost:7880".into());

    // Derive host:port for raw TCP forwarding (the WS upgrade rides over it).
    let target = server_url
        .split("://")
        .last()
        .and_then(|rest| rest.split('/').next())
        .unwrap_or("localhost:7880")
        .to_string();

    let (proxy_addr, kill) = start_killable_proxy(target).await;
    let proxy_url = format!("ws://{proxy_addr}");

    let room_name = format!("test_room_{}", create_random_uuid());
    let token = AccessToken::with_api_key(&api_key, &api_secret)
        .with_ttl(Duration::from_secs(30 * 60))
        .with_grants(VideoGrants { room_join: true, room: room_name, ..Default::default() })
        .with_identity("p0")
        .with_name("Participant 0")
        .to_jwt()?;

    let (room, mut events) = Room::connect(&proxy_url, &token, RoomOptions::default()).await?;
    assert_eq!(room.connection_state(), ConnectionState::Connected);

    // Sever the link and refuse all reconnects.
    kill.send(true).ok();

    let observe = async {
        let mut saw_reconnecting = false;
        while let Some(event) = events.recv().await {
            match event {
                RoomEvent::Reconnecting => saw_reconnecting = true,
                RoomEvent::Disconnected { reason } => {
                    if !saw_reconnecting {
                        bail!("disconnected without attempting reconnection first");
                    }
                    return Ok(reason);
                }
                _ => {}
            }
        }
        bail!("event stream ended before the room reported Disconnected");
    };

    // Generous timeout: the engine works through its full bounded backoff before
    // giving up.
    let _reason = timeout(Duration::from_secs(90), observe).await??;

    assert_eq!(
        room.connection_state(),
        ConnectionState::Disconnected,
        "room must reach Disconnected after reconnection is exhausted, not hang in Reconnecting"
    );
    Ok(())
}

// A server-side room deletion is a terminal Leave: the room must report
// Disconnected { RoomDeleted } without ever entering Reconnecting, and the client
// has nothing to say back on a signalling socket the server is already closing.
#[cfg(feature = "__lk-e2e-test")]
#[test_log::test(tokio::test)]
async fn test_room_deleted_disconnects_without_reconnect() -> Result<()> {
    let api_key = env::var("LIVEKIT_API_KEY").unwrap_or_else(|_| "devkey".into());
    let api_secret = env::var("LIVEKIT_API_SECRET").unwrap_or_else(|_| "secret".into());
    let server_url = env::var("LIVEKIT_URL").unwrap_or_else(|_| "ws://localhost:7880".into());

    let room_name = format!("test_room_{}", create_random_uuid());
    let token = AccessToken::with_api_key(&api_key, &api_secret)
        .with_ttl(Duration::from_secs(30 * 60))
        .with_grants(VideoGrants { room_join: true, room: room_name.clone(), ..Default::default() })
        .with_identity("p0")
        .with_name("Participant 0")
        .to_jwt()?;

    let (room, mut events) = Room::connect(&server_url, &token, RoomOptions::default()).await?;
    assert_eq!(room.connection_state(), ConnectionState::Connected);

    let session_dropped = room.drop_probe();

    let source = NativeVideoSource::new(VideoResolution { width: 16, height: 16 }, false);
    let track = LocalVideoTrack::create_video_track(
        "server-deleted-track",
        RtcVideoSource::Native(source.clone()),
    );
    let publication = room
        .local_participant()
        .publish_track(LocalTrack::Video(track), TrackPublishOptions::default())
        .await?;
    assert!(publication.track().is_some());

    let http_url = server_url.replacen("ws", "http", 1);
    RoomClient::with_api_key(&http_url, &api_key, &api_secret).delete_room(&room_name).await?;

    let observe = async {
        while let Some(event) = events.recv().await {
            match event {
                RoomEvent::Reconnecting => bail!("a deleted room must not be reconnected to"),
                RoomEvent::Disconnected { reason } => return Ok(reason),
                _ => {}
            }
        }
        bail!("event stream ended before the room reported Disconnected");
    };
    let reason = timeout(Duration::from_secs(30), observe).await??;

    assert_eq!(reason, livekit::DisconnectReason::RoomDeleted);
    assert_eq!(room.connection_state(), ConnectionState::Disconnected);

    timeout(Duration::from_secs(5), async {
        while publication.track().is_some() {
            time::sleep(Duration::from_millis(10)).await;
        }
    })
    .await
    .map_err(|_| anyhow!("server deletion did not detach the published local track"))?;

    drop(publication);
    drop(source);
    drop(events);
    drop(room);

    timeout(Duration::from_secs(5), async {
        while !session_dropped() {
            time::sleep(Duration::from_millis(10)).await;
        }
    })
    .await
    .map_err(|_| anyhow!("published track retained the room session after server deletion"))?;
    Ok(())
}