Skip to content

Commit 7b52bce

Browse files
Wrap SimulateScenario to test server-initiated reconnect/leave paths
Adds Room::simulateScenario(SimulateScenario) over a new FfiClient::simulateScenarioAsync, a public SimulateScenario enum, and the proto mapping — mirroring the disconnect() async pattern. This surfaces the Rust core's chaos/reconnect hook so tests can drive the previously zero-coverage server-initiated paths in-band, without server-admin calls. Tests: - unit: simulateScenario on a disconnected room throws (no server needed) - integration: SignalReconnect and FullReconnect drive onReconnecting/ onReconnected; ServerLeave drives a server-initiated onDisconnected Updates docs/connection-lifecycle.md coverage matrix accordingly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8b70463 commit 7b52bce

10 files changed

Lines changed: 360 additions & 15 deletions

File tree

docs/connection-lifecycle.md

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,12 @@ live server (see `docs/testing.md`).
9898
| D4 | `livekit::shutdown()` while a Room is still connected || ❌ none |
9999
| D5 | SIGINT/SIGTERM during a session || ❌ none (by design: app responsibility, but undocumented) |
100100
| D6 | Process exit without shutdown (warning path) || ❌ none |
101-
| S1 | Server-initiated `kDisconnected` (kick, room deletion, duplicate identity) || ❌ none — no test kicks a participant or deletes a room |
101+
| S1 | Server-initiated `kDisconnected` via `ServerLeave` | `integration/test_room_reconnect.cpp` `ServerLeaveDisconnects` | ✅ (SimulateScenario) |
102+
| S1 | Server-initiated `kDisconnected` (kick, room deletion, duplicate identity) || ❌ none — needs server-admin API or fake injector |
102103
| S2 | `kEos` teardown || ❌ none |
103104
| S1×D1 | Race: server disconnect vs. client disconnect || ❌ none |
104-
| S3 | Reconnecting/Reconnected callbacks || ❌ none |
105+
| S3 | Reconnecting/Reconnected callbacks | `integration/test_room_reconnect.cpp` `SignalReconnectResumesSession`, `FullReconnectReestablishesSession` | ✅ (SimulateScenario) |
106+
|| `simulateScenario` on a disconnected room throws | `unit/test_room.cpp` `SimulateScenarioOnDisconnectedRoomThrows` ||
105107
| S4 | ConnectionStateChanged callback || ❌ none |
106108
| S5 | FFI panic → SIGTERM | `unit/test_ffi_client.cpp` (SIGTERM handler + flag) ||
107109
| S6 | Remote participant disconnects || ❌ none (only reachable with a second participant) |
@@ -173,10 +175,13 @@ request/async-callback shape the C++ `FfiClient` already uses for connect/discon
173175
| 7 | `FULL_RECONNECT` | Server sends `Leave{Reconnect}` → new `RtcSession`, tracks republished | S3, full-reconnect + republish |
174176
| 8 | `DISCONNECT_SIGNAL_ON_RESUME` | Drops signalling mid-resume → forces resume→full escalation | S3, escalation path |
175177

176-
**Status in this SDK: not wrapped.** There is no reference to `simulate` anywhere
177-
in `src/`, and `FfiClient` has no `simulateScenarioAsync`. The Rust core supports
178-
it (and the rust-sdks own `reconnection_test.rs`, `data_channel_test.rs`, etc. use
179-
it), but the C++ layer has never surfaced it.
178+
**Status in this SDK: wrapped.** Exposed as `Room::simulateScenario(SimulateScenario)`
179+
(`include/livekit/room.h`, `src/room.cpp`) over `FfiClient::simulateScenarioAsync`
180+
(`src/ffi_client.cpp`), with the C++ `SimulateScenario` enum in
181+
`include/livekit/room_event_types.h` and proto mapping in
182+
`src/room_proto_converter.cpp`. It throws if the room is not connected and blocks
183+
on the FFI callback (so it must not be called from inside a delegate callback,
184+
same as `disconnect()`).
180185

181186
**Important:** `SimulateScenario` is an *in-band* hook forwarded over the live
182187
signalling connection — it still requires a real server. It does **not** replace a
@@ -195,15 +200,12 @@ Two complementary mechanisms, at different layers:
195200
way to cover S2/S1×D1 deterministically and offline. `SimulateScenario` does
196201
*not* help here (it needs a server).
197202

198-
2. **Wrap `SimulateScenario` (integration, live server)** — add a thin
199-
`FfiClient::simulateScenarioAsync(room_handle, kind)` plus a `Room` hook (or a
200-
test-only accessor), mirroring `disconnectAsync` exactly; the proto is already
201-
generated from the submodule. This is the clean way to drive the currently
202-
**zero-coverage S3 reconnect paths** (`SIGNAL_RECONNECT`, `NODE_FAILURE`,
203-
`FULL_RECONNECT`, `DISCONNECT_SIGNAL_ON_RESUME`) and a server-initiated
204-
disconnect (`SERVER_LEAVE` → S1/S2) end-to-end, without server-admin calls.
205-
It is the primary unlock for `onReconnecting`/`onReconnected` coverage, which no
206-
C++ test currently touches.
203+
2. **`SimulateScenario` (integration, live server) — done.** `Room::simulateScenario`
204+
now drives these paths; `integration/test_room_reconnect.cpp` covers
205+
`SignalReconnect` and `FullReconnect` (S3 reconnecting→reconnected) and
206+
`ServerLeave` (S1 server-initiated disconnect). Remaining scenarios worth adding
207+
as they prove useful: `NodeFailure`, `Migration`, and `DisconnectSignalOnResume`
208+
(resume→full escalation).
207209

208210
Reason-specific disconnects that `SimulateScenario` does **not** cover
209211
(`ParticipantRemoved`, `RoomDeleted`, `DuplicateIdentity`) still need either the

include/livekit/room.h

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,27 @@ class LIVEKIT_API Room {
181181
/// room was already disconnected (no-op) or the graceful disconnect fails.
182182
bool disconnect(DisconnectReason reason = DisconnectReason::ClientInitiated);
183183

184+
/// Inject a reconnection / chaos scenario for testing.
185+
///
186+
/// Drives the Rust core's `SimulateScenario` in-band over the live signalling
187+
/// connection. This exists to exercise the server-initiated reconnect and
188+
/// leave paths (which no other client API reaches) end-to-end without
189+
/// server-admin calls. It is intended for tests and chaos tooling, not
190+
/// production traffic.
191+
///
192+
/// Depending on the scenario, expect subsequent delegate callbacks such as
193+
/// @ref RoomDelegate::onReconnecting / @ref RoomDelegate::onReconnected (the
194+
/// reconnect scenarios) or @ref RoomDelegate::onDisconnected (ServerLeave).
195+
///
196+
/// @warning Safe to call from any thread, but **must not** be called from
197+
/// inside a `RoomDelegate` callback — doing so will deadlock the event
198+
/// listener, since this blocks on the FFI callback delivered on that thread.
199+
///
200+
/// @param scenario The scenario to inject.
201+
/// @returns true if the FFI accepted the request; false if it failed.
202+
/// @throws std::runtime_error if the room is not currently connected.
203+
bool simulateScenario(SimulateScenario scenario);
204+
184205
// Accessors
185206

186207
/// Retrieve static metadata about the room.

include/livekit/room_event_types.h

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,36 @@ enum class DisconnectReason {
9696
MediaFailure
9797
};
9898

99+
/// Reconnection / chaos scenarios that can be injected at runtime for testing.
100+
///
101+
/// These map 1:1 to the Rust core's `SimulateScenario` (proto
102+
/// `SimulateScenarioKind`) and are driven in-band over the live signalling
103+
/// connection via @ref Room::simulateScenario. They exist to exercise the
104+
/// server-initiated reconnect and leave paths (which no client API otherwise
105+
/// reaches) without needing server-admin calls. Intended for tests and chaos
106+
/// tooling, not production traffic.
107+
enum class SimulateScenario {
108+
/// Close the signal channel locally; the engine attempts a resume.
109+
SignalReconnect = 0,
110+
/// Simulate local speaker activity (surfaces via onActiveSpeakersChanged).
111+
Speaker,
112+
/// Simulate a media node failure, triggering reconnection.
113+
NodeFailure,
114+
/// Ask the server to send a Leave, producing a server-initiated disconnect.
115+
ServerLeave,
116+
/// Simulate a server migration.
117+
Migration,
118+
/// Force ICE to use TCP, triggering a transport reconnect.
119+
ForceTcp,
120+
/// Force ICE to use TLS, triggering a transport reconnect.
121+
ForceTls,
122+
/// Ask the server to send Leave{Reconnect}, forcing a full reconnect
123+
/// (new session; the SDK republishes existing local tracks).
124+
FullReconnect,
125+
/// Drop signalling mid-resume, forcing the resume->full-reconnect escalation.
126+
DisconnectSignalOnResume
127+
};
128+
99129
/// Application-level user data carried in a data packet.
100130
struct UserPacketData {
101131
/// Raw payload bytes.

src/ffi_client.cpp

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ std::optional<FfiClient::AsyncId> ExtractAsyncId(const proto::FfiEvent& event) {
7171
return event.connect().async_id();
7272
case E::kDisconnect:
7373
return event.disconnect().async_id();
74+
case E::kSimulateScenario:
75+
return event.simulate_scenario().async_id();
7476
case E::kDispose:
7577
return event.dispose().async_id();
7678
case E::kPublishTrack:
@@ -540,6 +542,42 @@ std::future<void> FfiClient::disconnectAsync(uintptr_t room_handle, DisconnectRe
540542
return fut;
541543
}
542544

545+
std::future<void> FfiClient::simulateScenarioAsync(uintptr_t room_handle, SimulateScenario scenario) {
546+
const AsyncId async_id = generateAsyncId();
547+
548+
auto fut = registerAsync<void>(
549+
async_id,
550+
[async_id](const proto::FfiEvent& event) {
551+
return event.has_simulate_scenario() && event.simulate_scenario().async_id() == async_id;
552+
},
553+
[](const proto::FfiEvent& event, std::promise<void>& pr) {
554+
const auto& cb = event.simulate_scenario();
555+
if (cb.has_error() && !cb.error().empty()) {
556+
pr.set_exception(std::make_exception_ptr(std::runtime_error(cb.error())));
557+
return;
558+
}
559+
pr.set_value();
560+
});
561+
562+
proto::FfiRequest req;
563+
auto* simulate = req.mutable_simulate_scenario();
564+
simulate->set_room_handle(room_handle);
565+
simulate->set_request_async_id(async_id);
566+
simulate->set_scenario(toProto(scenario));
567+
568+
try {
569+
const proto::FfiResponse resp = sendRequest(req);
570+
if (!resp.has_simulate_scenario()) {
571+
logAndThrow("FfiResponse missing simulate_scenario");
572+
}
573+
} catch (...) {
574+
cancelPendingByAsyncId(async_id);
575+
throw;
576+
}
577+
578+
return fut;
579+
}
580+
543581
// Track APIs Implementation
544582
std::future<std::vector<RtcStats>> FfiClient::getTrackStatsAsync(uintptr_t track_handle) {
545583
// Generate client-side async_id first

src/ffi_client.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ class LIVEKIT_INTERNAL_API FfiClient {
9999

100100
std::future<void> disconnectAsync(uintptr_t room_handle, DisconnectReason reason);
101101

102+
// Inject a reconnection / chaos scenario for testing (SimulateScenario).
103+
std::future<void> simulateScenarioAsync(uintptr_t room_handle, SimulateScenario scenario);
104+
102105
// Track APIs
103106
std::future<std::vector<RtcStats>> getTrackStatsAsync(uintptr_t track_handle);
104107

src/room.cpp

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,30 @@ bool Room::disconnect(DisconnectReason reason) {
292292
return ffi_ok;
293293
}
294294

295+
bool Room::simulateScenario(SimulateScenario scenario) {
296+
TRACE_EVENT0("livekit", "Room::simulateScenario");
297+
298+
std::shared_ptr<FfiHandle> handle;
299+
{
300+
const std::scoped_lock<std::mutex> g(lock_);
301+
if (connection_state_ != ConnectionState::Connected) {
302+
throw std::runtime_error("Room::simulateScenario called on a room that is not connected");
303+
}
304+
handle = room_handle_;
305+
}
306+
if (!handle) {
307+
throw std::runtime_error("Room::simulateScenario: missing room handle");
308+
}
309+
310+
try {
311+
FfiClient::instance().simulateScenarioAsync(handle->get(), scenario).get();
312+
return true;
313+
} catch (const std::exception& e) {
314+
LK_LOG_ERROR("Room::simulateScenario failed: {}", e.what());
315+
return false;
316+
}
317+
}
318+
295319
RoomInfoData Room::roomInfo() const {
296320
const std::scoped_lock<std::mutex> g(lock_);
297321
return room_info_;

src/room_proto_converter.cpp

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,31 @@ proto::DisconnectReason toProto(DisconnectReason in) {
191191
}
192192
}
193193

194+
proto::SimulateScenarioKind toProto(SimulateScenario in) {
195+
switch (in) {
196+
case SimulateScenario::SignalReconnect:
197+
return proto::SIMULATE_SIGNAL_RECONNECT;
198+
case SimulateScenario::Speaker:
199+
return proto::SIMULATE_SPEAKER;
200+
case SimulateScenario::NodeFailure:
201+
return proto::SIMULATE_NODE_FAILURE;
202+
case SimulateScenario::ServerLeave:
203+
return proto::SIMULATE_SERVER_LEAVE;
204+
case SimulateScenario::Migration:
205+
return proto::SIMULATE_MIGRATION;
206+
case SimulateScenario::ForceTcp:
207+
return proto::SIMULATE_FORCE_TCP;
208+
case SimulateScenario::ForceTls:
209+
return proto::SIMULATE_FORCE_TLS;
210+
case SimulateScenario::FullReconnect:
211+
return proto::SIMULATE_FULL_RECONNECT;
212+
case SimulateScenario::DisconnectSignalOnResume:
213+
return proto::SIMULATE_DISCONNECT_SIGNAL_ON_RESUME;
214+
default:
215+
return proto::SIMULATE_SIGNAL_RECONNECT;
216+
}
217+
}
218+
194219
// --------- basic helper conversions ---------
195220

196221
UserPacketData fromProto(const proto::UserPacket& in) {

src/room_proto_converter.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ LIVEKIT_INTERNAL_API ConnectionState toConnectionState(proto::ConnectionState in
3939
LIVEKIT_INTERNAL_API DataPacketKind toDataPacketKind(proto::DataPacketKind in);
4040
LIVEKIT_INTERNAL_API DisconnectReason toDisconnectReason(proto::DisconnectReason in);
4141
LIVEKIT_INTERNAL_API proto::DisconnectReason toProto(DisconnectReason in);
42+
LIVEKIT_INTERNAL_API proto::SimulateScenarioKind toProto(SimulateScenario in);
4243

4344
LIVEKIT_INTERNAL_API UserPacketData fromProto(const proto::UserPacket& in);
4445
LIVEKIT_INTERNAL_API SipDtmfData fromProto(const proto::SipDTMF& in);

0 commit comments

Comments
 (0)