Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ Additional documentation for the SDK.
vcpkg, Docker, integration into your CMake project, troubleshooting.
- [Authentication](authentication.md) — generating tokens, token source types
(initial connect only), and in-session token refresh.
- [Reconnection](reconnection.md) — automatic resume/full reconnect behavior,
callback ordering and threading, object continuity, and application patterns.
- [Logging](logging.md) — compile-time vs runtime filtering, log levels,
custom sinks (file, JSON, ROS2 `RCLCPP_*` macros).
- [Tracing](tracing.md) — Chromium-format performance traces, viewing in
Expand Down
3 changes: 3 additions & 0 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ These are separate mechanisms:
the Rust core owns the session JWT. The SDK applies server-pushed token updates
internally (for reconnect). Your token endpoint is **not** called again unless
**you** invoke `Room::connect` again (for example after a full disconnect).
Automatic resume and full reconnect are both part of the same active room
session; neither invokes a token source. See [Reconnection](reconnection.md) for
the complete lifecycle and terminal-recovery guidance.

If you need the latest JWT after a server refresh, implement
`RoomDelegate::onTokenRefreshed` and store `TokenRefreshedEvent::token` in your
Expand Down
142 changes: 142 additions & 0 deletions docs/reconnection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# Reconnection

The C++ SDK follows the same reconnect contract as the other LiveKit client
SDKs. After an established room loses its signal or media transport, the
embedded Rust core automatically tries to recover the existing room. The
application observes that process through `RoomDelegate`; it must not start a
second retry loop or call `Room::connect()` while recovery is active.

## Lifecycle

A reconnect episode has one of two outcomes:

1. `Room::connectionState()` changes to `ConnectionState::Reconnecting`.
`onConnectionStateChanged` and then `onReconnecting` report that transition.
2. The Rust core first attempts a lightweight resume of the existing session.
3. If resume cannot succeed, the core escalates to a full reconnect, rebuilds
remote state, and republishes local tracks.
4. Success changes the state to `ConnectionState::Connected`.
`onConnectionStateChanged` and then `onReconnected` run after reconciliation
is complete.
5. Exhausted recovery changes the state to `ConnectionState::Disconnected`.
`onConnectionStateChanged` and then `onDisconnected` report the terminal
outcome exactly once.

Calling `Room::disconnect()` is also terminal and produces at most one
`onDisconnected` callback. A server-initiated terminal disconnect is followed
by `onRoomEos`, which completes local cleanup. Do not call `connect()` again
until `onRoomEos` has run; alternatively, call `disconnect()` from an
application thread to finish pending cleanup first.

`RoomOptions::join_retries` controls retries for the initial join. It does not
change the reconnect policy for an established room. Reconnect mode, reason,
attempt count, elapsed time, and retry policy are not currently exposed by the
C++ FFI.

## Callback threading

Server-driven room delegate callbacks run on the Rust-managed FFI callback
thread. The callback generated by an explicit `Room::disconnect()` runs on the
calling thread. In either case, a callback must not block, wait for a future,
reconnect the room, or do substantial work. Post a small event to the
application's executor, event loop, or worker queue instead:

```cpp
class ConnectionDelegate : public livekit::RoomDelegate {
public:
explicit ConnectionDelegate(Executor& executor) : executor_(executor) {}

void onReconnecting(livekit::Room&, const livekit::ReconnectingEvent&) override {
executor_.post([] { showConnectionBanner("Reconnecting"); });
}

void onReconnected(livekit::Room&, const livekit::ReconnectedEvent&) override {
executor_.post([] { hideConnectionBanner(); });
}

void onDisconnected(livekit::Room&, const livekit::DisconnectedEvent& event) override {
executor_.post([reason = event.reason] { handleTerminalDisconnect(reason); });
}

private:
Executor& executor_;
};
```

The exact executor type belongs to the application. It may be a UI event loop,
an `asio` executor, a service work queue, or another non-FFI thread.

## Object and registration continuity

A successful resume preserves the current participant, publication, and track
objects.

A full reconnect republishes local audio and video tracks. Existing `Track` and
`LocalTrackPublication` objects remain valid and are updated in place before
`onReconnected`. Their SIDs can change, so code should read the current SID from
the object after reconnect instead of retaining a SID string indefinitely.
`LocalParticipant::trackPublications()` is rekeyed to the current SID, and the
current SID can be used with `unpublishTrack`.

`onLocalTrackRepublished` runs once for each successfully republished local
audio or video track. The event contains the existing publication and track,
the previous SID, and the local participant. Use it to rekey external state
before `onReconnected` reports that reconciliation is complete.

Publication and local-track properties are updated on the FFI callback thread
and must not be read concurrently from another thread during that update.
Reading them inside `onLocalTrackRepublished` is safe. To consume the refreshed
values elsewhere, copy the values into an application event and transfer that
event through a mutex, thread-safe queue, or executor so the receiving thread
is synchronized with the callback.

Audio, video, and data callback registrations on `Room` remain installed during
recovery. Reader threads can stop and restart as track events rebuild the
subscription. Register handlers once for the room lifetime; register them again
only after tearing down the room and creating a new connection.

One FFI limitation remains for data tracks: `LocalDataTrack::info().sid` and
remote data-track SID snapshots can become stale after a full reconnect.
Handle-based operations such as `tryPush`, subscribe, and unpublish continue to
use the current Rust object. Refreshing those snapshots requires a future
Rust/FFI event or query.

## Operations while reconnecting

Treat `Reconnecting` as a temporary loss of transport:

- Pause nonessential media or control operations, or tolerate a transient
operation failure and retry after `onReconnected`.
- Buffer application data only when its ordering, memory bound, and expiry
behavior are explicit. The SDK does not provide an unbounded application
queue.
- Keep application state associated with participant, track, and publication
objects. Do not discard them merely because reconnect started.
- Do not fetch new credentials or call `connect()` during an active episode.
The Rust core uses the latest server-refreshed session token.

After terminal `onDisconnected`, wait for `onRoomEos` (or explicitly finish
cleanup with `disconnect()`), obtain fresh credentials if needed, and perform
the next `connect()` from an application thread.

## Application patterns

Interactive applications commonly disable controls that require the transport,
show a reconnect indicator, and restore controls from `onReconnected`. A
terminal disconnect returns the UI to its join flow.

Headless services commonly publish lifecycle events into their main work queue
and mark the service degraded while reconnecting. A watchdog may impose an
application-specific availability bound and replace the room after a terminal
disconnect. Its timeout is an operational policy, not the Rust reconnect
deadline.

Publisher/subscriber processes should keep sources, tracks, publications, and
callback registrations alive through recovery. After `onReconnected`, they can
read current publication SIDs and resume any application-level production that
was paused.

A ROS bridge is one example of the headless-service pattern: delegate callbacks
enqueue state changes for a ROS executor, while a watchdog decides when the
larger node should be restarted. The same contract applies to conferencing,
agents, streaming, robotics, and embedded applications.
8 changes: 8 additions & 0 deletions include/livekit/local_participant.h
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ struct ParticipantTrackPermission;
class FfiClient;
class Track;
class LocalTrackPublication;
namespace proto {
class TrackPublicationInfo;
}

/// @brief Data passed to a registered RPC method handler.
struct RpcInvocationData {
Expand Down Expand Up @@ -241,10 +244,15 @@ class LIVEKIT_API LocalParticipant : public Participant {
friend class Room;

private:
bool handleTrackRepublished(const std::string& previous_sid, uintptr_t publication_handle,
const proto::TrackPublicationInfo& info,
std::shared_ptr<LocalTrackPublication>& publication, std::shared_ptr<Track>& track);

/// Publication SID → local track (@ref unpublishTrack clears the track’s
/// cached publication). @c mutable so @ref trackPublications() const can
/// prune expired @c weak_ptr entries.
mutable TrackMap published_tracks_by_sid_;
mutable std::mutex publication_mutex_;

std::unordered_map<std::string, RpcHandler> rpc_handlers_;

Expand Down
10 changes: 9 additions & 1 deletion include/livekit/local_track_publication.h
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,22 @@ namespace livekit {

namespace proto {
class OwnedTrackPublication;
}
class TrackPublicationInfo;
} // namespace proto

class LocalParticipant;

/// @brief A track published by a local participant.
class LIVEKIT_API LocalTrackPublication : public TrackPublication {
public:
/// Note, this LocalTrackPublication is constructed internally only;
/// safe to accept proto::OwnedTrackPublication.
explicit LocalTrackPublication(const proto::OwnedTrackPublication& owned);

private:
friend class LocalParticipant;

void updateFromProto(uintptr_t publication_handle, const proto::TrackPublicationInfo& info);
};

} // namespace livekit
4 changes: 4 additions & 0 deletions include/livekit/room.h
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
#include <future>
#include <memory>
#include <mutex>
#include <optional>

#include "livekit/data_stream.h"
#include "livekit/e2ee.h"
Expand Down Expand Up @@ -334,6 +335,8 @@ class LIVEKIT_API Room {

mutable std::mutex lock_;
ConnectionState connection_state_ = ConnectionState::Disconnected;
std::optional<ConnectionState> last_notified_connection_state_;
bool disconnect_callback_claimed_ = true;
RoomDelegate* delegate_ = nullptr; // Not owned
RoomInfoData room_info_;
std::shared_ptr<FfiHandle> room_handle_;
Expand All @@ -355,6 +358,7 @@ class LIVEKIT_API Room {
int listener_id_{0};

void onEvent(const proto::FfiEvent& event);
void transitionConnectionState(ConnectionState state, bool notify_delegate);

// Shared shutdown path for explicit disconnect, server disconnect, EOS, and destruction.
bool shutdown(bool disconnect_ffi, DisconnectReason reason, bool notify_delegate);
Expand Down
6 changes: 6 additions & 0 deletions include/livekit/room_delegate.h
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ class LIVEKIT_API RoomDelegate {
/// Called when a local track is unpublished.
virtual void onLocalTrackUnpublished(Room&, const LocalTrackUnpublishedEvent&) {}

/// @brief Called when a local track is republished during a full reconnect.
///
/// The publication and track have already been updated and rekeyed under the
/// current SID. This callback runs before @ref onReconnected.
virtual void onLocalTrackRepublished(Room&, const LocalTrackRepublishedEvent&) {}

/// Called when a local track gains its first subscriber.
virtual void onLocalTrackSubscribed(Room&, const LocalTrackSubscribedEvent&) {}

Expand Down
20 changes: 20 additions & 0 deletions include/livekit/room_event_types.h
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ namespace livekit {
// Forward declarations to avoid pulling in heavy headers.
class Track;
class Participant;
class LocalParticipant;
class RemoteParticipant;
class RemoteDataTrack;
class LocalTrackPublication;
Expand Down Expand Up @@ -395,6 +396,25 @@ struct LocalTrackUnpublishedEvent {
std::shared_ptr<LocalTrackPublication> publication;
};

/// @brief Fired when the SDK republishes a local track during a full reconnect.
///
/// The existing publication and track objects are updated in place before this
/// event is delivered. Use @ref previous_sid to reconcile application state
/// keyed by the former server-assigned SID.
struct LocalTrackRepublishedEvent {
/// Existing publication updated with its current SID and metadata.
std::shared_ptr<LocalTrackPublication> publication;

/// Existing local track associated with the publication.
std::shared_ptr<Track> track;

/// Local participant that owns the publication (owned by Room).
LocalParticipant* participant = nullptr;

/// Server-assigned publication SID used before the full reconnect.
std::string previous_sid;
};

/// Fired when a local track gets its first subscriber.
struct LocalTrackSubscribedEvent {
/// Subscribed local track.
Expand Down
10 changes: 10 additions & 0 deletions include/livekit/track.h
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
namespace livekit {

class LocalTrackPublication;
class LocalParticipant;

/// @brief Media kind for an audio or video track.
enum class TrackKind {
Expand Down Expand Up @@ -74,6 +75,11 @@ struct ParticipantTrackPermission {
};

/// @brief Base class for local and remote media tracks.
///
/// @warning Local-track properties are not safe to read concurrently with a
/// full reconnect update. Read the refreshed SID from
/// RoomDelegate::onLocalTrackRepublished or after synchronizing application
/// state from RoomDelegate::onReconnected.
class LIVEKIT_API Track {
public:
virtual ~Track() = default;
Expand Down Expand Up @@ -121,6 +127,10 @@ class LIVEKIT_API Track {
std::optional<std::string> mime_type);

private:
friend class LocalParticipant;

void setSid(const std::string& sid) { sid_ = sid; }

FfiHandle handle_; // Owned

std::string sid_;
Expand Down
10 changes: 8 additions & 2 deletions include/livekit/track_publication.h
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,14 @@ class RemoteTrack;

/// @brief Base class for a track that has been published to a room.
///
/// Wraps the immutable publication info plus an FFI handle, and
/// holds a weak reference to the associated Track (if any).
/// Wraps publication info plus an FFI handle and holds a reference to the
/// associated Track, if any. A local publication's SID and metadata can change
/// in place during a full reconnect.
///
/// @warning Publication properties are not safe to read concurrently with a
/// reconnect update. Read the refreshed properties from
/// RoomDelegate::onLocalTrackRepublished or after synchronizing application
/// state from RoomDelegate::onReconnected.
class LIVEKIT_API TrackPublication {
public:
virtual ~TrackPublication() = default;
Expand Down
2 changes: 1 addition & 1 deletion scripts/generate-docs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ cp "$repo_root/docs/doxygen/Doxyfile" "$doxyfile_tmp"
{
echo ""
echo "# Local override generated by scripts/generate-docs.sh"
echo "INPUT = include/livekit ${mainpage_rel} docs/README.md docs/building.md docs/authentication.md docs/logging.md docs/tracing.md docs/testing.md docs/tools.md"
echo "INPUT = include/livekit ${mainpage_rel} docs/README.md docs/building.md docs/authentication.md docs/reconnection.md docs/logging.md docs/tracing.md docs/testing.md docs/tools.md"
echo "USE_MDFILE_AS_MAINPAGE = ${mainpage_rel}"
} >>"$doxyfile_tmp"

Expand Down
38 changes: 38 additions & 0 deletions src/ffi_client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ std::optional<FfiClient::AsyncId> 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:
Expand Down Expand Up @@ -540,6 +542,42 @@ std::future<void> FfiClient::disconnectAsync(uintptr_t room_handle, DisconnectRe
return fut;
}

std::future<void> FfiClient::simulateScenarioAsync(uintptr_t room_handle, proto::SimulateScenarioKind scenario) {
const AsyncId async_id = generateAsyncId();

auto fut = registerAsync<void>(
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<void>& pr) {
const auto& callback = event.simulate_scenario();
if (callback.has_error() && !callback.error().empty()) {
pr.set_exception(std::make_exception_ptr(std::runtime_error(callback.error())));
return;
}
pr.set_value();
});

proto::FfiRequest req;
auto* simulate = req.mutable_simulate_scenario();
simulate->set_room_handle(room_handle);
simulate->set_scenario(scenario);
simulate->set_request_async_id(async_id);

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<std::vector<RtcStats>> FfiClient::getTrackStatsAsync(uintptr_t track_handle) {
// Generate client-side async_id first
Expand Down
Loading
Loading