diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1b765f8e..509496c1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -271,6 +271,10 @@ jobs: env: RUST_LOG: "metrics=debug" LIVEKIT_CREATE_TOKEN_URL: ${{ steps.token_server.outputs.token-url }} + # Dev-server credentials so the admin-driven server-disconnect tests + # (which shell out to `lk`) run instead of skipping. + LIVEKIT_API_KEY: devkey + LIVEKIT_API_SECRET: secret run: | set -euo pipefail source .token_helpers/set_data_track_test_tokens.bash diff --git a/docs/connection-lifecycle.md b/docs/connection-lifecycle.md new file mode 100644 index 00000000..5eb03765 --- /dev/null +++ b/docs/connection-lifecycle.md @@ -0,0 +1,221 @@ +# Connection Lifecycle: Connect/Disconnect Paths and Test Coverage + +This document maps every path by which a `livekit::Room` can become connected or +disconnected, how each path flows through the SDK layers, and where each path is +(or is not) covered by tests. + +Layers involved: + +``` +Application code + │ + ▼ +livekit::Room (src/room.cpp) ← public API, state machine, delegate callbacks + │ + ▼ +livekit::FfiClient (src/ffi_client.cpp) ← request/response + async event routing, singleton + │ + ▼ +livekit_ffi (Rust, client-sdk-rust) ← actual signaling/WebRTC, emits proto::FfiEvent +``` + +## Connect paths + +| # | Path | Entry point | Flow | Failure behavior | +|---|------|-------------|------|------------------| +| C1 | Explicit connect | `Room::connect(url, token, options)` (`src/room.cpp:92`) | Guard: FFI initialized, state == `Disconnected` → state = `Reconnecting`* → `addListener` → `FfiClient::connectAsync().get()` (blocking) → build participants/E2EE → publish state under lock, state = `Connected` → `readyForRoomEvent` | Any throw → state reset to `Disconnected`, listener removed, participant `shutdown()`, all state cleared; returns `false` | +| C2 | Connect via token source | `Room::connect(url, TokenSource&, opts)` (`src/token_source*.cpp`) | Fetches token, then delegates to C1 | Fetch error/throw → returns `false` without touching room state | +| C3 | Connect while already connected | `Room::connect` | State guard | Throws `std::runtime_error("already connected")`; existing session untouched | +| C4 | Connect before `livekit::initialize()` | `Room::connect` | `FfiClient::isInitialized()` guard | Returns `false`, logs error | + +\* Note: the SDK has no `Connecting` state; during connect the state reads as +`Reconnecting` (`ConnectionState` in `include/livekit/room_event_types.h:49`). + +## Disconnect paths + +### Client-initiated + +| # | Path | Trigger | What happens | Delegate callbacks | Reason seen by app | +|---|------|---------|--------------|--------------------|--------------------| +| D1 | Explicit disconnect | `Room::disconnect(reason)` (`src/room.cpp:213`) | State flipped to `Disconnected` under lock (wins any race with the event path), participant RPC drain (`LocalParticipant::shutdown`, 5 s cap), `FfiClient::disconnectAsync().get()`, dispatcher stopped, listener removed, moved-out state destructs | `onDisconnected` exactly once | Caller-supplied (default `ClientInitiated`) | +| D2 | Destructor | `Room::~Room()` (`src/room.cpp:76`) | Calls D1; swallows and logs exceptions | Same as D1 | `ClientInitiated` | +| D3 | Double disconnect / never connected | `disconnect()` when state already `Disconnected` | Early return `false`; no side effects | None | — | +| D4 | Global SDK shutdown | `livekit::shutdown()` → `FfiClient::shutdown()` (`src/ffi_client.cpp:175`) | Cancels all pending async ops, drains all listeners, `livekit_ffi_dispose()`. Does **not** notify `Room` objects: a still-connected `Room` keeps state `Connected` with dead handles | None | — | +| D5 | Process signals (SIGINT/SIGTERM) | OS | **The SDK installs no signal handlers.** Default handler terminates the process; no disconnect runs, no leave message is sent — server times the participant out | None | Server-side: timeout | +| D6 | Process exit without shutdown | `exit()` / `main` return | `FfiClient::~FfiClient` prints a warning to stderr if still initialized (`src/ffi_client.cpp:166`); static-destruction order means `Room` members may already be gone | None | — | + +### Server/network-initiated + +All arrive on the FFI event thread as `proto::RoomEvent` and route through +`Room::onEvent` (`src/room.cpp:424`), gated on matching `room_handle`. + +| # | Path | Event | What happens | Delegate callbacks | Reason seen by app | +|---|------|-------|--------------|--------------------|--------------------| +| S1 | Server closes room / kicks participant / duplicate identity / server shutdown / signal close, etc. | `kDisconnected` (`src/room.cpp:1170`) | If state already `Disconnected` (client won the race) → skipped. Otherwise state = `Disconnected` and delegate fired. **No teardown here** — participants, room handle, and listener stay alive until S2 arrives or the user calls `disconnect()`/destructor | `onDisconnected` once | Mapped via `toDisconnectReason` (`src/room_proto_converter.cpp:118`): `RoomDeleted`, `ParticipantRemoved`, `DuplicateIdentity`, `ServerShutdown`, `SignalClose`, `RoomClosed`, … | +| S2 | FFI event-stream end | `kEos` (`src/room.cpp:1210`) | The actual server-side teardown: dispatcher stopped, listener removed, participant RPC drain, all state moved out and destroyed, state = `Disconnected` | `onRoomEos` | — | +| S3 | Automatic reconnection | `kReconnecting` / `kReconnected` (`src/room.cpp:1189`) | Pass-through to delegate only; `connection_state_` is **not** updated (stays `Connected` while the Rust layer reconnects) | `onReconnecting` / `onReconnected` | — | +| S4 | Connection state change | `kConnectionStateChanged` (`src/room.cpp:1153`) | Pass-through to delegate only; `connection_state_` not updated (open TODO in code) | `onConnectionStateChanged` | — | +| S5 | FFI panic | `event.has_panic()` in `ffiEventCallback` (`src/ffi_client.cpp:417`) | Logs critical, flushes logger, `std::raise(SIGTERM)` — process-fatal by design | None | — | +| S6 | Peer participant leaves | `kParticipantDisconnected` (`src/room.cpp:488`) | Not a room disconnect; removes the remote participant | `onParticipantDisconnected` | Per-participant reason | + +### Interaction between D and S paths + +- D1 flips `connection_state_` **before** sending the FFI disconnect, so the + echoed `kDisconnected` event is deduplicated → `onDisconnected` fires exactly once. +- Conversely, after S1 fires, a subsequent `disconnect()` returns `false` + immediately (state already `Disconnected`) — final resource cleanup then depends + on S2 arriving or on the `Room` destructor destroying members. +- `kEos` and `disconnect()` can race; `disconnect()` takes ownership of all state + under the lock so only one teardown path operates on it (comment at + `src/room.cpp:233`). + +## `DisconnectReason` values + +Defined in `include/livekit/room_event_types.h:80`, mirroring the server enum: +`Unknown`, `ClientInitiated`, `DuplicateIdentity`, `ServerShutdown`, +`ParticipantRemoved`, `RoomDeleted`, `StateMismatch`, `JoinFailure`, `Migration`, +`SignalClose`, `RoomClosed`, `UserUnavailable`, `UserRejected`, `SipTrunkFailure`, +`ConnectionTimeout`, `MediaFailure`. Full proto↔C++ mapping lives in +`src/room_proto_converter.cpp:118`. + +## Test coverage matrix + +Tests live under `src/tests/{unit,integration,stress}`; integration tests need a +live server (see `docs/testing.md`). + +| Path | Scenario | Test(s) | Coverage | +|------|----------|---------|----------| +| C1 | Successful connect | `integration/test_room.cpp` `ConnectToServer` | ✅ | +| C1 | Bad token / bad URL | `ConnectWithInvalidToken`, `ConnectWithInvalidUrl` | ✅ | +| C1 | Failed connect cleans up listener | `integration/test_room_listener_cleanup.cpp` (3 tests) | ✅ | +| C2 | Token-source connect (literal + custom + error paths) | `integration/test_room.cpp`, `unit/test_room.cpp` | ✅ | +| C3 | Connect while connected throws, no leak | `AlreadyConnectedConnectDoesNotReplaceOrLeakListener` | ✅ | +| C4 | Connect without initialize | `unit/test_room.cpp` `ConnectWithoutInitialize` | ✅ | +| D1 | Explicit disconnect: state, callback-once, reason | `integration/test_room.cpp` `UserDisconnect` | ✅ | +| D2 | Destructor-driven disconnect | `DestructorDisconnect`, `ParticipantHandlesExpireOnRoomDestruction` | ✅ | +| D3 | Double disconnect idempotent | asserted inside `UserDisconnect` | ✅ | +| D1/D2 | Repeated connect/disconnect cycles | `stress/test_room_stress.cpp` `RepeatedConnectDisconnect` | ✅ (stress) | +| D4 | `livekit::shutdown()` while a Room is still connected | — | ❌ none | +| D5 | SIGINT/SIGTERM during a session | — | ❌ none (by design: app responsibility, but undocumented) | +| D6 | Process exit without shutdown (warning path) | — | ❌ none | +| S1 | Server-initiated `kDisconnected` via `ServerLeave` | `integration/test_room_reconnect.cpp` `ServerLeaveDisconnects` | ✅ (SimulateScenario) | +| S1 | Duplicate identity → `DuplicateIdentity` | `integration/test_room_server_disconnect.cpp` `DuplicateIdentityDisconnectsFirstConnection` | ✅ (second connect, same token) | +| S1 | Kick → `ParticipantRemoved` | `integration/test_room_server_disconnect.cpp` `RemovedParticipantDisconnectsWithReason` | ✅ (`lk` CLI; needs `LIVEKIT_API_KEY`/`SECRET`) | +| S1 | Room deletion → `RoomDeleted` | `integration/test_room_server_disconnect.cpp` `DeletedRoomDisconnectsWithReason` | ✅ (`lk` CLI; needs `LIVEKIT_API_KEY`/`SECRET`) | +| S2 | `kEos` teardown (state released, `onRoomEos`, no double `onDisconnected`) | `integration/test_room_server_disconnect.cpp` `ServerDisconnectTearsDownViaEos` | ✅ | +| S1×D1 | Race: server disconnect vs. client disconnect fires `onDisconnected` once | `integration/test_room_server_disconnect.cpp` `ClientDisconnectDuringServerLeaveFiresDisconnectedOnce` | ✅ (best-effort timing) | +| S3 | Reconnecting/Reconnected callbacks | `integration/test_room_reconnect.cpp` `SignalReconnectResumesSession`, `FullReconnectReestablishesSession` | ✅ (SimulateScenario) | +| — | `simulateScenario` on a disconnected room throws | `unit/test_room.cpp` `SimulateScenarioOnDisconnectedRoomThrows` | ✅ | +| S4 | ConnectionStateChanged callback | — | ❌ none | +| S5 | FFI panic → SIGTERM | `unit/test_ffi_client.cpp` (SIGTERM handler + flag) | ✅ | +| S6 | Remote participant disconnects | `integration/test_room_server_disconnect.cpp` `PeerDisconnectFiresParticipantDisconnected` | ✅ (needs `LIVEKIT_TOKEN_B`) | +| — | Listener use-after-free during destroy | `unit/test_ffi_client.cpp` FakeRoom race/stress tests | ✅ | +| — | `connectionState()` thread safety | `unit/test_room_callbacks.cpp` | ✅ | + +### Coverage summary + +**Well covered:** every *client-initiated* path — connect success/failure/misuse, +explicit disconnect, destructor teardown, idempotency, listener cleanup, and the +FFI-panic path. + +**Not covered at all:** every *server-initiated* disconnect path. No test (unit or +integration) exercises `kDisconnected`, `kEos`, `kReconnecting`/`kReconnected`, or +the client-vs-server disconnect race. There is no mock/fake FFI event injector, so +these paths can currently only be reached against a live server plus a server-side +API call (delete room / remove participant). + +## Gaps and observations (code) + +1. **`kDisconnected` performs no teardown** (`src/room.cpp:1170`): after a + server-side disconnect, FFI handles, participants, and the listener remain + alive until `kEos` arrives. If `kEos` were not delivered, cleanup falls to the + `Room` destructor. Worth documenting the assumption that Rust always follows + `Disconnected` with `Eos`. +2. **`connection_state_` is not updated by `kConnectionStateChanged` or + `kReconnecting`** (TODO at `src/room.cpp:1158`): during a reconnect the public + `connectionState()` still reports `Connected`. +3. **No `Connecting` state**: `connect()` reuses `Reconnecting` while a first-time + connection is in flight. +4. **`onDisconnected` is not fired by the `kEos` path**: if an application only + listens for `onDisconnected` it will be told once (S1), but resources vanish + later (S2, `onRoomEos`) — the two-phase nature is not documented in the + delegate header. +5. **`livekit::shutdown()` doesn't coordinate with live `Room`s** — rooms are left + `Connected` with dead handles; subsequent calls fail gracefully but noisily. +6. **Duplicate RPC drain in `kEos`**: `old_local_participant->shutdown()` is called + twice (`src/room.cpp:1247` and `src/room.cpp:1256`). Harmless (idempotent) but + redundant. +7. **Dead file**: `src/room_event_converter.cpp` is not in the CMake build, + includes a nonexistent header, and contains a stub `toDisconnectReason` that + always returns `Unknown`. Should be deleted to avoid confusion with the real + converter in `src/room_proto_converter.cpp`. +8. **No signal handling guidance**: neither the SDK nor the README pattern shows a + SIGINT-safe shutdown (`signal → stop main loop → room.disconnect() → + livekit::shutdown()`); apps that Ctrl-C simply drop the connection and rely on + server timeout. + +## The Rust `SimulateScenario` hook + +The Rust core exposes a chaos/E2E testing primitive that is directly relevant to +the server-initiated gaps above. It exists at the FFI boundary — proto +`SimulateScenarioRequest { room_handle, scenario }` → async +`SimulateScenarioCallback` (`livekit-ffi/protocol/room.proto`), handled by +`on_simulate_scenario` in `livekit-ffi/src/server/requests.rs`. It is the same +request/async-callback shape the C++ `FfiClient` already uses for connect/disconnect. + +`SimulateScenarioKind` (FFI proto enum): + +| Value | Kind | Drives | Path exercised | +|-------|------|--------|----------------| +| 0 | `SIGNAL_RECONNECT` | Closes the signal channel locally; engine attempts a **resume** | S3 reconnecting→reconnected | +| 1 | `SPEAKER` | Simulated speaker activity | `onActiveSpeakersChanged` (not lifecycle) | +| 2 | `NODE_FAILURE` | Simulated media node death → reconnection | S3 | +| 3 | `SERVER_LEAVE` | Server sends a Leave | S1 `kDisconnected` (+`kEos`) — server-initiated disconnect | +| 4 | `MIGRATION` | Server migration | S3, `Migration` reason | +| 5 | `FORCE_TCP` | Force ICE over TCP | transport reconnect | +| 6 | `FORCE_TLS` | Force ICE over TLS | transport reconnect | +| 7 | `FULL_RECONNECT` | Server sends `Leave{Reconnect}` → new `RtcSession`, tracks republished | S3, full-reconnect + republish | +| 8 | `DISCONNECT_SIGNAL_ON_RESUME` | Drops signalling mid-resume → forces resume→full escalation | S3, escalation path | + +**Status in this SDK: wrapped.** Exposed as `Room::simulateScenario(SimulateScenario)` +(`include/livekit/room.h`, `src/room.cpp`) over `FfiClient::simulateScenarioAsync` +(`src/ffi_client.cpp`), with the C++ `SimulateScenario` enum in +`include/livekit/room_event_types.h` and proto mapping in +`src/room_proto_converter.cpp`. It throws if the room is not connected and blocks +on the FFI callback (so it must not be called from inside a delegate callback, +same as `disconnect()`). + +**Important:** `SimulateScenario` is an *in-band* hook forwarded over the live +signalling connection — it still requires a real server. It does **not** replace a +client-only fake event injector; it replaces the need for out-of-band server-admin +API calls (DeleteRoom / RemoveParticipant) to drive the *reconnect and leave* +scenarios. + +## Suggested next steps for coverage + +Two complementary mechanisms, at different layers: + +1. **Fake FFI event injector (unit, no server)** — the FakeRoom pattern in + `unit/test_ffi_client.cpp` is a starting point. Lets unit tests synthesize + `kDisconnected`, `kEos`, and reconnect events and assert callback-once + semantics per reason, teardown on `kEos`, and the S1×D1 race. This is the only + way to cover S2/S1×D1 deterministically and offline. `SimulateScenario` does + *not* help here (it needs a server). + +2. **`SimulateScenario` (integration, live server) — done.** `Room::simulateScenario` + now drives these paths; `integration/test_room_reconnect.cpp` covers + `SignalReconnect` and `FullReconnect` (S3 reconnecting→reconnected) and + `ServerLeave` (S1 server-initiated disconnect). Remaining scenarios worth adding + as they prove useful: `NodeFailure`, `Migration`, and `DisconnectSignalOnResume` + (resume→full escalation). + +Reason-specific disconnects that `SimulateScenario` does **not** cover are now +handled in `integration/test_room_server_disconnect.cpp`: `DuplicateIdentity` by +opening a second connection with the same token, and `ParticipantRemoved` / +`RoomDeleted` by shelling out to the `lk` CLI (skipped unless +`LIVEKIT_API_KEY`/`LIVEKIT_API_SECRET` are set; CI provides the dev-server +credentials). The same file covers Eos teardown (S2), peer disconnects (S6), +and the client-vs-server disconnect race (S1×D1). + +3. Add a test for `livekit::shutdown()` with a still-connected room (D4). +4. Document (and add an example for) signal-driven graceful shutdown (D5). diff --git a/include/livekit/room.h b/include/livekit/room.h index bf4bf68d..6ecff8f3 100644 --- a/include/livekit/room.h +++ b/include/livekit/room.h @@ -181,6 +181,27 @@ class LIVEKIT_API Room { /// room was already disconnected (no-op) or the graceful disconnect fails. bool disconnect(DisconnectReason reason = DisconnectReason::ClientInitiated); + /// Inject a reconnection / chaos scenario for testing. + /// + /// Drives the Rust core's `SimulateScenario` in-band over the live signalling + /// connection. This exists to exercise the server-initiated reconnect and + /// leave paths (which no other client API reaches) end-to-end without + /// server-admin calls. It is intended for tests and chaos tooling, not + /// production traffic. + /// + /// Depending on the scenario, expect subsequent delegate callbacks such as + /// @ref RoomDelegate::onReconnecting / @ref RoomDelegate::onReconnected (the + /// reconnect scenarios) or @ref RoomDelegate::onDisconnected (ServerLeave). + /// + /// @warning Safe to call from any thread, but **must not** be called from + /// inside a `RoomDelegate` callback — doing so will deadlock the event + /// listener, since this blocks on the FFI callback delivered on that thread. + /// + /// @param scenario The scenario to inject. + /// @returns true if the FFI accepted the request; false if it failed. + /// @throws std::runtime_error if the room is not currently connected. + bool simulateScenario(SimulateScenario scenario); + // Accessors /// Retrieve static metadata about the room. diff --git a/include/livekit/room_event_types.h b/include/livekit/room_event_types.h index f688bbec..54b8f2ef 100644 --- a/include/livekit/room_event_types.h +++ b/include/livekit/room_event_types.h @@ -96,6 +96,36 @@ enum class DisconnectReason { MediaFailure }; +/// Reconnection / chaos scenarios that can be injected at runtime for testing. +/// +/// These map 1:1 to the Rust core's `SimulateScenario` (proto +/// `SimulateScenarioKind`) and are driven in-band over the live signalling +/// connection via @ref Room::simulateScenario. They exist to exercise the +/// server-initiated reconnect and leave paths (which no client API otherwise +/// reaches) without needing server-admin calls. Intended for tests and chaos +/// tooling, not production traffic. +enum class SimulateScenario { + /// Close the signal channel locally; the engine attempts a resume. + SignalReconnect = 0, + /// Simulate local speaker activity (surfaces via onActiveSpeakersChanged). + Speaker, + /// Simulate a media node failure, triggering reconnection. + NodeFailure, + /// Ask the server to send a Leave, producing a server-initiated disconnect. + ServerLeave, + /// Simulate a server migration. + Migration, + /// Force ICE to use TCP, triggering a transport reconnect. + ForceTcp, + /// Force ICE to use TLS, triggering a transport reconnect. + ForceTls, + /// Ask the server to send Leave{Reconnect}, forcing a full reconnect + /// (new session; the SDK republishes existing local tracks). + FullReconnect, + /// Drop signalling mid-resume, forcing the resume->full-reconnect escalation. + DisconnectSignalOnResume +}; + /// Application-level user data carried in a data packet. struct UserPacketData { /// Raw payload bytes. diff --git a/src/ffi_client.cpp b/src/ffi_client.cpp index 89d6b0a8..d069ec82 100644 --- a/src/ffi_client.cpp +++ b/src/ffi_client.cpp @@ -71,6 +71,8 @@ std::optional ExtractAsyncId(const proto::FfiEvent& event) { return event.connect().async_id(); case E::kDisconnect: return event.disconnect().async_id(); + case E::kSimulateScenario: + return event.simulate_scenario().async_id(); case E::kDispose: return event.dispose().async_id(); case E::kPublishTrack: @@ -540,6 +542,42 @@ std::future FfiClient::disconnectAsync(uintptr_t room_handle, DisconnectRe return fut; } +std::future FfiClient::simulateScenarioAsync(uintptr_t room_handle, SimulateScenario scenario) { + const AsyncId async_id = generateAsyncId(); + + auto fut = registerAsync( + async_id, + [async_id](const proto::FfiEvent& event) { + return event.has_simulate_scenario() && event.simulate_scenario().async_id() == async_id; + }, + [](const proto::FfiEvent& event, std::promise& pr) { + const auto& cb = event.simulate_scenario(); + if (cb.has_error() && !cb.error().empty()) { + pr.set_exception(std::make_exception_ptr(std::runtime_error(cb.error()))); + return; + } + pr.set_value(); + }); + + proto::FfiRequest req; + auto* simulate = req.mutable_simulate_scenario(); + simulate->set_room_handle(room_handle); + simulate->set_request_async_id(async_id); + simulate->set_scenario(toProto(scenario)); + + try { + const proto::FfiResponse resp = sendRequest(req); + if (!resp.has_simulate_scenario()) { + logAndThrow("FfiResponse missing simulate_scenario"); + } + } catch (...) { + cancelPendingByAsyncId(async_id); + throw; + } + + return fut; +} + // Track APIs Implementation std::future> FfiClient::getTrackStatsAsync(uintptr_t track_handle) { // Generate client-side async_id first diff --git a/src/ffi_client.h b/src/ffi_client.h index 9dc840e4..3ba869b3 100644 --- a/src/ffi_client.h +++ b/src/ffi_client.h @@ -99,6 +99,9 @@ class LIVEKIT_INTERNAL_API FfiClient { std::future disconnectAsync(uintptr_t room_handle, DisconnectReason reason); + // Inject a reconnection / chaos scenario for testing (SimulateScenario). + std::future simulateScenarioAsync(uintptr_t room_handle, SimulateScenario scenario); + // Track APIs std::future> getTrackStatsAsync(uintptr_t track_handle); diff --git a/src/room.cpp b/src/room.cpp index 32dac5eb..35c7918d 100644 --- a/src/room.cpp +++ b/src/room.cpp @@ -292,6 +292,30 @@ bool Room::disconnect(DisconnectReason reason) { return ffi_ok; } +bool Room::simulateScenario(SimulateScenario scenario) { + TRACE_EVENT0("livekit", "Room::simulateScenario"); + + std::shared_ptr handle; + { + const std::scoped_lock g(lock_); + if (connection_state_ != ConnectionState::Connected) { + throw std::runtime_error("Room::simulateScenario called on a room that is not connected"); + } + handle = room_handle_; + } + if (!handle) { + throw std::runtime_error("Room::simulateScenario: missing room handle"); + } + + try { + FfiClient::instance().simulateScenarioAsync(handle->get(), scenario).get(); + return true; + } catch (const std::exception& e) { + LK_LOG_ERROR("Room::simulateScenario failed: {}", e.what()); + return false; + } +} + RoomInfoData Room::roomInfo() const { const std::scoped_lock g(lock_); return room_info_; diff --git a/src/room_proto_converter.cpp b/src/room_proto_converter.cpp index 010aa545..20a5e805 100644 --- a/src/room_proto_converter.cpp +++ b/src/room_proto_converter.cpp @@ -191,6 +191,31 @@ proto::DisconnectReason toProto(DisconnectReason in) { } } +proto::SimulateScenarioKind toProto(SimulateScenario in) { + switch (in) { + case SimulateScenario::SignalReconnect: + return proto::SIMULATE_SIGNAL_RECONNECT; + case SimulateScenario::Speaker: + return proto::SIMULATE_SPEAKER; + case SimulateScenario::NodeFailure: + return proto::SIMULATE_NODE_FAILURE; + case SimulateScenario::ServerLeave: + return proto::SIMULATE_SERVER_LEAVE; + case SimulateScenario::Migration: + return proto::SIMULATE_MIGRATION; + case SimulateScenario::ForceTcp: + return proto::SIMULATE_FORCE_TCP; + case SimulateScenario::ForceTls: + return proto::SIMULATE_FORCE_TLS; + case SimulateScenario::FullReconnect: + return proto::SIMULATE_FULL_RECONNECT; + case SimulateScenario::DisconnectSignalOnResume: + return proto::SIMULATE_DISCONNECT_SIGNAL_ON_RESUME; + default: + return proto::SIMULATE_SIGNAL_RECONNECT; + } +} + // --------- basic helper conversions --------- UserPacketData fromProto(const proto::UserPacket& in) { diff --git a/src/room_proto_converter.h b/src/room_proto_converter.h index b834d262..7f540912 100644 --- a/src/room_proto_converter.h +++ b/src/room_proto_converter.h @@ -39,6 +39,7 @@ LIVEKIT_INTERNAL_API ConnectionState toConnectionState(proto::ConnectionState in LIVEKIT_INTERNAL_API DataPacketKind toDataPacketKind(proto::DataPacketKind in); LIVEKIT_INTERNAL_API DisconnectReason toDisconnectReason(proto::DisconnectReason in); LIVEKIT_INTERNAL_API proto::DisconnectReason toProto(DisconnectReason in); +LIVEKIT_INTERNAL_API proto::SimulateScenarioKind toProto(SimulateScenario in); LIVEKIT_INTERNAL_API UserPacketData fromProto(const proto::UserPacket& in); LIVEKIT_INTERNAL_API SipDtmfData fromProto(const proto::SipDTMF& in); diff --git a/src/tests/integration/test_room_reconnect.cpp b/src/tests/integration/test_room_reconnect.cpp new file mode 100644 index 00000000..17ef09e4 --- /dev/null +++ b/src/tests/integration/test_room_reconnect.cpp @@ -0,0 +1,193 @@ +/* + * Copyright 2025 LiveKit + * + * 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. + */ + +// Exercises the server-initiated reconnect / leave paths end-to-end by +// injecting SimulateScenario over the live signalling connection. These are the +// paths (onReconnecting / onReconnected / server-driven onDisconnected) that no +// other client API can reach without server-admin calls. +// +// Requires LIVEKIT_URL and LIVEKIT_TOKEN_A (same as the other integration tests). + +#include +#include + +#include +#include +#include +#include +#include +#include + +using namespace std::chrono_literals; + +namespace livekit::test { + +namespace { + +// Records reconnect lifecycle callbacks and lets a test block until each fires. +class ReconnectTrackingDelegate : public RoomDelegate { +public: + void onReconnecting(Room&, const ReconnectingEvent&) override { + { + const std::scoped_lock lock(mutex_); + ++reconnecting_count_; + } + cv_.notify_all(); + } + + void onReconnected(Room&, const ReconnectedEvent&) override { + { + const std::scoped_lock lock(mutex_); + ++reconnected_count_; + } + cv_.notify_all(); + } + + void onDisconnected(Room&, const DisconnectedEvent& ev) override { + { + const std::scoped_lock lock(mutex_); + ++disconnected_count_; + last_reason_ = ev.reason; + } + cv_.notify_all(); + } + + bool waitForReconnecting(std::chrono::milliseconds timeout) { + std::unique_lock lock(mutex_); + return cv_.wait_for(lock, timeout, [this] { return reconnecting_count_ > 0; }); + } + + bool waitForReconnected(std::chrono::milliseconds timeout) { + std::unique_lock lock(mutex_); + return cv_.wait_for(lock, timeout, [this] { return reconnected_count_ > 0; }); + } + + bool waitForDisconnected(std::chrono::milliseconds timeout) { + std::unique_lock lock(mutex_); + return cv_.wait_for(lock, timeout, [this] { return disconnected_count_ > 0; }); + } + + int reconnectingCount() { + const std::scoped_lock lock(mutex_); + return reconnecting_count_; + } + int reconnectedCount() { + const std::scoped_lock lock(mutex_); + return reconnected_count_; + } + int disconnectedCount() { + const std::scoped_lock lock(mutex_); + return disconnected_count_; + } + DisconnectReason lastReason() { + const std::scoped_lock lock(mutex_); + return last_reason_; + } + +private: + std::mutex mutex_; + std::condition_variable cv_; + int reconnecting_count_ = 0; + int reconnected_count_ = 0; + int disconnected_count_ = 0; + DisconnectReason last_reason_ = DisconnectReason::Unknown; +}; + +} // namespace + +class RoomReconnectTest : public ::testing::Test { +protected: + void SetUp() override { + livekit::initialize(livekit::LogLevel::Info); + + const char* url_env = std::getenv("LIVEKIT_URL"); + const char* token_env = std::getenv("LIVEKIT_TOKEN_A"); + if (url_env && token_env) { + server_url_ = url_env; + token_ = token_env; + server_available_ = true; + } + } + + void TearDown() override { livekit::shutdown(); } + + bool server_available_ = false; + std::string server_url_; + std::string token_; +}; + +// SignalReconnect closes the signal channel locally; the engine resumes the +// session, which should surface as onReconnecting followed by onReconnected, +// with the room still Connected afterward. +TEST_F(RoomReconnectTest, SignalReconnectResumesSession) { + ASSERT_TRUE(server_available_) << "LIVEKIT_URL and LIVEKIT_TOKEN_A not set"; + + Room room; + ReconnectTrackingDelegate delegate; + room.setDelegate(&delegate); + + ASSERT_TRUE(room.connect(server_url_, token_, RoomOptions())) << "connect failed"; + ASSERT_EQ(room.connectionState(), ConnectionState::Connected); + + ASSERT_TRUE(room.simulateScenario(SimulateScenario::SignalReconnect)) + << "FFI should accept the SignalReconnect scenario"; + + EXPECT_TRUE(delegate.waitForReconnecting(15s)) << "onReconnecting should fire after SignalReconnect"; + EXPECT_TRUE(delegate.waitForReconnected(30s)) << "onReconnected should fire once the resume completes"; + EXPECT_EQ(room.connectionState(), ConnectionState::Connected) << "room should be Connected after a successful resume"; + + room.disconnect(); +} + +// FullReconnect forces a full reconnect (new session; local tracks republished). +// It should also surface as onReconnecting followed by onReconnected. +TEST_F(RoomReconnectTest, FullReconnectReestablishesSession) { + ASSERT_TRUE(server_available_) << "LIVEKIT_URL and LIVEKIT_TOKEN_A not set"; + + Room room; + ReconnectTrackingDelegate delegate; + room.setDelegate(&delegate); + + ASSERT_TRUE(room.connect(server_url_, token_, RoomOptions())) << "connect failed"; + ASSERT_EQ(room.connectionState(), ConnectionState::Connected); + + ASSERT_TRUE(room.simulateScenario(SimulateScenario::FullReconnect)) << "FFI should accept the FullReconnect scenario"; + + EXPECT_TRUE(delegate.waitForReconnecting(15s)) << "onReconnecting should fire after FullReconnect"; + EXPECT_TRUE(delegate.waitForReconnected(30s)) << "onReconnected should fire once the full reconnect completes"; + EXPECT_EQ(room.connectionState(), ConnectionState::Connected); + + room.disconnect(); +} + +// ServerLeave asks the server to send a (non-reconnect) Leave, which should +// tear the session down and surface as a single server-initiated onDisconnected. +TEST_F(RoomReconnectTest, ServerLeaveDisconnects) { + ASSERT_TRUE(server_available_) << "LIVEKIT_URL and LIVEKIT_TOKEN_A not set"; + + Room room; + ReconnectTrackingDelegate delegate; + room.setDelegate(&delegate); + + ASSERT_TRUE(room.connect(server_url_, token_, RoomOptions())) << "connect failed"; + ASSERT_EQ(room.connectionState(), ConnectionState::Connected); + + ASSERT_TRUE(room.simulateScenario(SimulateScenario::ServerLeave)) << "FFI should accept the ServerLeave scenario"; + + EXPECT_TRUE(delegate.waitForDisconnected(30s)) << "onDisconnected should fire after a server-initiated leave"; +} + +} // namespace livekit::test diff --git a/src/tests/integration/test_room_server_disconnect.cpp b/src/tests/integration/test_room_server_disconnect.cpp new file mode 100644 index 00000000..4b68fee2 --- /dev/null +++ b/src/tests/integration/test_room_server_disconnect.cpp @@ -0,0 +1,399 @@ +/* + * Copyright 2025 LiveKit + * + * 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. + */ + +// Exercises the server-initiated disconnect paths that SimulateScenario cannot +// reach: reason-specific disconnects (DuplicateIdentity, ParticipantRemoved, +// RoomDeleted), Eos-driven teardown, peer participant disconnects, and the +// client-vs-server disconnect race. +// +// Requires LIVEKIT_URL and LIVEKIT_TOKEN_A. The peer test additionally needs +// LIVEKIT_TOKEN_B. The admin-driven tests (remove participant / delete room) +// shell out to the `lk` CLI and are skipped unless LIVEKIT_API_KEY and +// LIVEKIT_API_SECRET are set (lk reads url/key/secret from the environment). + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace std::chrono_literals; + +namespace livekit::test { + +namespace { + +// --------------------------------------------------------------------------- +// JWT helpers: pull the identity ("sub") and room name ("room") out of a join +// token so the admin tests can target the right participant/room regardless of +// how the tokens were minted. +// --------------------------------------------------------------------------- + +std::string base64UrlDecode(const std::string& in) { + static const std::string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + std::string out; + int val = 0; + int bits = 0; + for (const char c : in) { + const auto pos = chars.find(c); + if (pos == std::string::npos) { + break; // padding or invalid char: stop + } + val = (val << 6) | static_cast(pos); + bits += 6; + if (bits >= 8) { + bits -= 8; + out.push_back(static_cast((val >> bits) & 0xFF)); + } + } + return out; +} + +// Extracts the string value of `"key":"value"` from a JSON blob. Crude, but +// join tokens are machine-minted so the shape is stable. +std::string extractJsonString(const std::string& json, const std::string& key) { + const std::string needle = "\"" + key + "\":\""; + const auto start = json.find(needle); + if (start == std::string::npos) { + return {}; + } + const auto value_start = start + needle.size(); + const auto end = json.find('"', value_start); + if (end == std::string::npos) { + return {}; + } + return json.substr(value_start, end - value_start); +} + +std::string jwtPayload(const std::string& token) { + const auto first_dot = token.find('.'); + if (first_dot == std::string::npos) { + return {}; + } + const auto second_dot = token.find('.', first_dot + 1); + if (second_dot == std::string::npos) { + return {}; + } + return base64UrlDecode(token.substr(first_dot + 1, second_dot - first_dot - 1)); +} + +// Values get embedded in a shell command; only accept identifier-ish strings. +bool isShellSafe(const std::string& s) { + if (s.empty()) { + return false; + } + for (const char c : s) { + const bool ok = (std::isalnum(static_cast(c)) != 0) || c == '-' || c == '_' || c == '.'; + if (!ok) { + return false; + } + } + return true; +} + +// --------------------------------------------------------------------------- +// Delegate that records the disconnect-related lifecycle callbacks. +// --------------------------------------------------------------------------- + +class ServerDisconnectTrackingDelegate : public RoomDelegate { +public: + void onDisconnected(Room&, const DisconnectedEvent& ev) override { + { + const std::scoped_lock lock(mutex_); + ++disconnected_count_; + last_reason_ = ev.reason; + } + cv_.notify_all(); + } + + void onRoomEos(Room&, const RoomEosEvent&) override { + { + const std::scoped_lock lock(mutex_); + ++eos_count_; + } + cv_.notify_all(); + } + + void onParticipantDisconnected(Room&, const ParticipantDisconnectedEvent& ev) override { + { + const std::scoped_lock lock(mutex_); + if (ev.participant != nullptr) { + disconnected_participants_.push_back(ev.participant->identity()); + } + last_participant_reason_ = ev.reason; + } + cv_.notify_all(); + } + + bool waitForDisconnected(std::chrono::milliseconds timeout) { + std::unique_lock lock(mutex_); + return cv_.wait_for(lock, timeout, [this] { return disconnected_count_ > 0; }); + } + + bool waitForEos(std::chrono::milliseconds timeout) { + std::unique_lock lock(mutex_); + return cv_.wait_for(lock, timeout, [this] { return eos_count_ > 0; }); + } + + bool waitForParticipantDisconnected(const std::string& identity, std::chrono::milliseconds timeout) { + std::unique_lock lock(mutex_); + return cv_.wait_for(lock, timeout, [this, &identity] { + for (const auto& id : disconnected_participants_) { + if (id == identity) { + return true; + } + } + return false; + }); + } + + int disconnectedCount() { + const std::scoped_lock lock(mutex_); + return disconnected_count_; + } + DisconnectReason lastReason() { + const std::scoped_lock lock(mutex_); + return last_reason_; + } + DisconnectReason lastParticipantReason() { + const std::scoped_lock lock(mutex_); + return last_participant_reason_; + } + +private: + std::mutex mutex_; + std::condition_variable cv_; + int disconnected_count_ = 0; + int eos_count_ = 0; + std::vector disconnected_participants_; + DisconnectReason last_reason_ = DisconnectReason::Unknown; + DisconnectReason last_participant_reason_ = DisconnectReason::Unknown; +}; + +} // namespace + +class RoomServerDisconnectTest : public ::testing::Test { +protected: + void SetUp() override { + livekit::initialize(livekit::LogLevel::Info); + + const char* url_env = std::getenv("LIVEKIT_URL"); + const char* token_a_env = std::getenv("LIVEKIT_TOKEN_A"); + const char* token_b_env = std::getenv("LIVEKIT_TOKEN_B"); + if (url_env && token_a_env) { + server_url_ = url_env; + token_a_ = token_a_env; + server_available_ = true; + } + if (token_b_env) { + token_b_ = token_b_env; + } + + const std::string payload = jwtPayload(token_a_); + identity_a_ = extractJsonString(payload, "sub"); + room_name_ = extractJsonString(payload, "room"); + } + + void TearDown() override { livekit::shutdown(); } + + // Admin actions go through the `lk` CLI, which reads LIVEKIT_URL, + // LIVEKIT_API_KEY, and LIVEKIT_API_SECRET from the environment. + bool adminAvailable() const { + return std::getenv("LIVEKIT_API_KEY") != nullptr && std::getenv("LIVEKIT_API_SECRET") != nullptr; + } + + static int runLk(const std::string& args) { + const std::string cmd = "lk " + args; + std::cout << "[admin] " << cmd << std::endl; + return std::system(cmd.c_str()); // NOLINT(concurrency-mt-unsafe) + } + + bool server_available_ = false; + std::string server_url_; + std::string token_a_; + std::string token_b_; + std::string identity_a_; + std::string room_name_; +}; + +// S1 (DuplicateIdentity): joining with an identity that is already connected +// makes the server disconnect the older connection with DUPLICATE_IDENTITY. +// Needs no admin API — just a second connection with the same token. +TEST_F(RoomServerDisconnectTest, DuplicateIdentityDisconnectsFirstConnection) { + ASSERT_TRUE(server_available_) << "LIVEKIT_URL and LIVEKIT_TOKEN_A not set"; + + Room first; + ServerDisconnectTrackingDelegate first_delegate; + first.setDelegate(&first_delegate); + ASSERT_TRUE(first.connect(server_url_, token_a_, RoomOptions())) << "first connect failed"; + ASSERT_EQ(first.connectionState(), ConnectionState::Connected); + + Room second; + ASSERT_TRUE(second.connect(server_url_, token_a_, RoomOptions())) << "second connect (same identity) failed"; + + EXPECT_TRUE(first_delegate.waitForDisconnected(30s)) + << "first connection should be disconnected when the same identity joins again"; + EXPECT_EQ(first_delegate.lastReason(), DisconnectReason::DuplicateIdentity); + EXPECT_EQ(first.connectionState(), ConnectionState::Disconnected); + EXPECT_EQ(first_delegate.disconnectedCount(), 1) << "onDisconnected must fire exactly once"; + + second.disconnect(); +} + +// S1 (ParticipantRemoved): a server-side RemoveParticipant call disconnects the +// client with PARTICIPANT_REMOVED. Driven via `lk room participants remove`. +TEST_F(RoomServerDisconnectTest, RemovedParticipantDisconnectsWithReason) { + ASSERT_TRUE(server_available_) << "LIVEKIT_URL and LIVEKIT_TOKEN_A not set"; + if (!adminAvailable()) { + GTEST_SKIP() << "LIVEKIT_API_KEY / LIVEKIT_API_SECRET not set; skipping admin-driven test"; + } + ASSERT_TRUE(isShellSafe(room_name_) && isShellSafe(identity_a_)) + << "could not extract a usable room ('" << room_name_ << "') / identity ('" << identity_a_ + << "') from LIVEKIT_TOKEN_A"; + + Room room; + ServerDisconnectTrackingDelegate delegate; + room.setDelegate(&delegate); + ASSERT_TRUE(room.connect(server_url_, token_a_, RoomOptions())) << "connect failed"; + ASSERT_EQ(room.connectionState(), ConnectionState::Connected); + + ASSERT_EQ(runLk("room participants remove --room " + room_name_ + " --identity " + identity_a_ + " --yes"), 0) + << "lk room participants remove failed"; + + EXPECT_TRUE(delegate.waitForDisconnected(30s)) << "onDisconnected should fire after the server removes us"; + EXPECT_EQ(delegate.lastReason(), DisconnectReason::ParticipantRemoved); + EXPECT_EQ(room.connectionState(), ConnectionState::Disconnected); + EXPECT_EQ(delegate.disconnectedCount(), 1); +} + +// S1 (RoomDeleted): deleting the room server-side disconnects every participant +// with ROOM_DELETED. Driven via `lk room delete`. +TEST_F(RoomServerDisconnectTest, DeletedRoomDisconnectsWithReason) { + ASSERT_TRUE(server_available_) << "LIVEKIT_URL and LIVEKIT_TOKEN_A not set"; + if (!adminAvailable()) { + GTEST_SKIP() << "LIVEKIT_API_KEY / LIVEKIT_API_SECRET not set; skipping admin-driven test"; + } + ASSERT_TRUE(isShellSafe(room_name_)) << "could not extract a usable room name from LIVEKIT_TOKEN_A"; + + Room room; + ServerDisconnectTrackingDelegate delegate; + room.setDelegate(&delegate); + ASSERT_TRUE(room.connect(server_url_, token_a_, RoomOptions())) << "connect failed"; + ASSERT_EQ(room.connectionState(), ConnectionState::Connected); + + ASSERT_EQ(runLk("room delete " + room_name_ + " --yes"), 0) << "lk room delete failed"; + + EXPECT_TRUE(delegate.waitForDisconnected(30s)) << "onDisconnected should fire after the room is deleted"; + EXPECT_EQ(delegate.lastReason(), DisconnectReason::RoomDeleted); + EXPECT_EQ(room.connectionState(), ConnectionState::Disconnected); + EXPECT_EQ(delegate.disconnectedCount(), 1); +} + +// S2 (Eos teardown): after a server-initiated disconnect, the Rust layer closes +// the room event stream. The kEos handler performs the actual state teardown +// (participants dropped, handles released) and fires onRoomEos. +TEST_F(RoomServerDisconnectTest, ServerDisconnectTearsDownViaEos) { + ASSERT_TRUE(server_available_) << "LIVEKIT_URL and LIVEKIT_TOKEN_A not set"; + + Room room; + ServerDisconnectTrackingDelegate delegate; + room.setDelegate(&delegate); + ASSERT_TRUE(room.connect(server_url_, token_a_, RoomOptions())) << "connect failed"; + ASSERT_FALSE(room.localParticipant().expired()); + + // ServerLeave produces a server-initiated disconnect without admin access. + ASSERT_TRUE(room.simulateScenario(SimulateScenario::ServerLeave)); + + ASSERT_TRUE(delegate.waitForDisconnected(30s)) << "onDisconnected should fire after ServerLeave"; + EXPECT_TRUE(delegate.waitForEos(30s)) << "onRoomEos should fire after a server-initiated disconnect"; + + EXPECT_EQ(room.connectionState(), ConnectionState::Disconnected); + EXPECT_TRUE(room.localParticipant().expired()) << "kEos teardown should release the local participant"; + EXPECT_EQ(delegate.disconnectedCount(), 1) << "the Eos path must not double-fire onDisconnected"; +} + +// S6 (peer disconnect): when another participant leaves, the remaining client +// gets onParticipantDisconnected with that identity, and the cached remote +// participant handle expires. +TEST_F(RoomServerDisconnectTest, PeerDisconnectFiresParticipantDisconnected) { + ASSERT_TRUE(server_available_) << "LIVEKIT_URL and LIVEKIT_TOKEN_A not set"; + if (token_b_.empty()) { + GTEST_SKIP() << "LIVEKIT_TOKEN_B not set; skipping two-participant test"; + } + + Room room; + ServerDisconnectTrackingDelegate delegate; + room.setDelegate(&delegate); + ASSERT_TRUE(room.connect(server_url_, token_a_, RoomOptions())) << "connect failed"; + + Room peer; + ASSERT_TRUE(peer.connect(server_url_, token_b_, RoomOptions())) << "peer connect failed"; + auto peer_local = peer.localParticipant().lock(); + ASSERT_NE(peer_local, nullptr); + const std::string peer_identity = peer_local->identity(); + peer_local.reset(); + + // Wait until the observer sees the peer before disconnecting it. + const auto deadline = std::chrono::steady_clock::now() + 10s; + while (room.remoteParticipant(peer_identity).expired() && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(100ms); + } + ASSERT_FALSE(room.remoteParticipant(peer_identity).expired()) << "peer never became visible"; + std::weak_ptr peer_handle = room.remoteParticipant(peer_identity); + + peer.disconnect(); + + EXPECT_TRUE(delegate.waitForParticipantDisconnected(peer_identity, 30s)) + << "onParticipantDisconnected should fire when the peer leaves"; + EXPECT_EQ(delegate.lastParticipantReason(), DisconnectReason::ClientInitiated); + EXPECT_TRUE(peer_handle.expired()) << "cached remote participant handle should expire after the peer leaves"; + EXPECT_EQ(room.connectionState(), ConnectionState::Connected) << "observer must stay connected"; + + room.disconnect(); +} + +// S1×D1 (race): a server-initiated leave racing a client disconnect() must +// resolve to exactly one onDisconnected, whichever side wins. +TEST_F(RoomServerDisconnectTest, ClientDisconnectDuringServerLeaveFiresDisconnectedOnce) { + ASSERT_TRUE(server_available_) << "LIVEKIT_URL and LIVEKIT_TOKEN_A not set"; + + Room room; + ServerDisconnectTrackingDelegate delegate; + room.setDelegate(&delegate); + ASSERT_TRUE(room.connect(server_url_, token_a_, RoomOptions())) << "connect failed"; + + // Kick off a server-side leave, then immediately race it with a client + // disconnect. Which side wins is timing-dependent; the invariants are not. + ASSERT_TRUE(room.simulateScenario(SimulateScenario::ServerLeave)); + room.disconnect(); + + EXPECT_TRUE(delegate.waitForDisconnected(30s)) << "one of the two paths must fire onDisconnected"; + EXPECT_EQ(room.connectionState(), ConnectionState::Disconnected); + + // Give the losing path time to (incorrectly) double-fire before asserting. + std::this_thread::sleep_for(3s); + EXPECT_EQ(delegate.disconnectedCount(), 1) << "the two teardown paths must dedupe to exactly one onDisconnected"; +} + +} // namespace livekit::test diff --git a/src/tests/unit/test_room.cpp b/src/tests/unit/test_room.cpp index f38c2cf6..132688f2 100644 --- a/src/tests/unit/test_room.cpp +++ b/src/tests/unit/test_room.cpp @@ -46,6 +46,14 @@ TEST_F(RoomTest, ConnectWithoutInitialize) { EXPECT_TRUE(room.remoteParticipants().empty()) << "Remote participants should be empty after failed connect"; } +TEST_F(RoomTest, SimulateScenarioOnDisconnectedRoomThrows) { + // simulateScenario is only valid on a connected room; a fresh Room is + // Disconnected, so it must throw before touching the FFI (no server needed). + Room room; + EXPECT_THROW(room.simulateScenario(SimulateScenario::SignalReconnect), std::runtime_error) + << "simulateScenario on a disconnected room should throw"; +} + TEST_F(RoomTest, LiteralTokenSourceEmptyCredentialsFails) { auto source = LiteralTokenSource::create("wss://localhost:7880", ""); const auto result = source->fetch().get();