From 370e57f8477d3c51e1274d543e2323f6a2c0da6b Mon Sep 17 00:00:00 2001 From: Alan George Date: Wed, 20 May 2026 16:13:11 -0700 Subject: [PATCH 1/5] Expose room session stats --- include/livekit/room.h | 19 ++ include/livekit/session_stats_error.h | 49 +++++ include/livekit/stats.h | 15 ++ src/ffi_client.cpp | 67 ++++++ src/ffi_client.h | 4 + src/room.cpp | 15 ++ src/tests/integration/test_session_stats.cpp | 211 +++++++++++++++++++ src/tests/unit/test_ffi_client.cpp | 9 + 8 files changed, 389 insertions(+) create mode 100644 include/livekit/session_stats_error.h create mode 100644 src/tests/integration/test_session_stats.cpp diff --git a/include/livekit/room.h b/include/livekit/room.h index 33839158..47e0366b 100644 --- a/include/livekit/room.h +++ b/include/livekit/room.h @@ -17,13 +17,17 @@ #pragma once #include +#include #include #include #include "livekit/data_stream.h" #include "livekit/e2ee.h" #include "livekit/ffi_handle.h" +#include "livekit/result.h" #include "livekit/room_event_types.h" +#include "livekit/session_stats_error.h" +#include "livekit/stats.h" #include "livekit/subscription_thread_dispatcher.h" #include "livekit/visibility.h" @@ -187,6 +191,21 @@ class LIVEKIT_API Room { /// Returns the current connection state of the room. ConnectionState connectionState() const; + /// Retrieve aggregated WebRTC stats for this room session. + /// + /// Behavior: + /// - If the room is not currently connected (no live FFI handle), resolves + /// immediately with a `GetSessionStatsErrorCode::NOT_CONNECTED` failure. + /// - Otherwise dispatches an async `get_session_stats` request to the Rust + /// FFI; the future resolves once the corresponding callback arrives. + /// - The future never throws — failures are surfaced as a typed + /// `GetSessionStatsError`. Inspect `Result::ok()` / `Result::error().code` + /// to branch on outcome. + /// + /// @return Future resolving with publisher + subscriber stats on success, + /// or a typed error code + message on failure. + std::future> getSessionStats() const; + /* Register a handler for incoming text streams on a specific topic. * * When a remote participant opens a text stream with the given topic, diff --git a/include/livekit/session_stats_error.h b/include/livekit/session_stats_error.h new file mode 100644 index 00000000..6f6baede --- /dev/null +++ b/include/livekit/session_stats_error.h @@ -0,0 +1,49 @@ +/* + * Copyright 2026 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. + */ + +#pragma once + +#include +#include + +namespace livekit { + +/// Categorical reason code for a failed `Room::getSessionStats()` call. +enum class GetSessionStatsErrorCode : std::uint32_t { + /// Catch-all: the FFI returned an error message that does not map to a more + /// specific code. + UNKNOWN = 0, + /// The `Room` has no live FFI handle (never connected or already + /// disconnected). + NOT_CONNECTED = 1, + /// The FFI responded with an unexpected response shape (e.g. a missing + /// `get_session_stats` field on the synchronous response). + PROTOCOL_ERROR = 2, + /// The FFI threw an internal error while servicing the request (e.g. the + /// underlying Rust engine reported a failure). + INTERNAL = 3, +}; + +/// Typed error returned by `Room::getSessionStats()`. +/// +/// Surfaces the error reason as a `GetSessionStatsErrorCode` plus an +/// implementation-defined message for diagnostics/logging. +struct GetSessionStatsError { + GetSessionStatsErrorCode code{GetSessionStatsErrorCode::UNKNOWN}; + std::string message; +}; + +} // namespace livekit diff --git a/include/livekit/stats.h b/include/livekit/stats.h index 84674d8e..225b5878 100644 --- a/include/livekit/stats.h +++ b/include/livekit/stats.h @@ -501,6 +501,21 @@ struct RtcStats { RtcStatsVariant stats; }; +/// Aggregated WebRTC stats for a connected room session. +/// +/// Mirrors the FFI `GetSessionStatsCallback.Result` payload: stats are split +/// between the publisher peer connection (outbound media flowing from the +/// local participant to the SFU) and the subscriber peer connection (inbound +/// media flowing from the SFU back to the local participant). When the SDK is +/// operating in single-peer-connection mode the publisher list carries the +/// combined stats and the subscriber list is empty. +struct SessionStats { + /// Stats from the publisher peer connection (outbound media). + std::vector publisher_stats; + /// Stats from the subscriber peer connection (inbound media). + std::vector subscriber_stats; +}; + // ---------------------- // fromProto declarations // ---------------------- diff --git a/src/ffi_client.cpp b/src/ffi_client.cpp index 8105ffb7..8cc7d946 100644 --- a/src/ffi_client.cpp +++ b/src/ffi_client.cpp @@ -428,6 +428,73 @@ std::future> FfiClient::getTrackStatsAsync(uintptr_t track return fut; } +namespace { + +std::future> readySessionStatsFailure(GetSessionStatsErrorCode code, + std::string message) { + std::promise> pr; + pr.set_value(Result::failure(GetSessionStatsError{code, std::move(message)})); + return pr.get_future(); +} + +} // namespace + +std::future> FfiClient::getSessionStatsAsync(uintptr_t room_handle) { + const AsyncId async_id = generateAsyncId(); + + auto fut = registerAsync>( + async_id, + // match + [async_id](const proto::FfiEvent& event) { + return event.has_get_session_stats() && event.get_session_stats().async_id() == async_id; + }, + // handler + [](const proto::FfiEvent& event, std::promise>& pr) { + const auto& cb = event.get_session_stats(); + if (cb.has_error()) { + pr.set_value(Result::failure( + GetSessionStatsError{GetSessionStatsErrorCode::INTERNAL, cb.error()})); + return; + } + if (!cb.has_result()) { + pr.set_value(Result::failure(GetSessionStatsError{ + GetSessionStatsErrorCode::PROTOCOL_ERROR, "GetSessionStatsCallback missing result and error"})); + return; + } + + const auto& result = cb.result(); + SessionStats stats; + stats.publisher_stats.reserve(result.publisher_stats_size()); + for (const auto& ps : result.publisher_stats()) { + stats.publisher_stats.push_back(fromProto(ps)); + } + stats.subscriber_stats.reserve(result.subscriber_stats_size()); + for (const auto& ps : result.subscriber_stats()) { + stats.subscriber_stats.push_back(fromProto(ps)); + } + pr.set_value(Result::success(std::move(stats))); + }); + + proto::FfiRequest req; + auto* get_session_stats_req = req.mutable_get_session_stats(); + get_session_stats_req->set_room_handle(room_handle); + get_session_stats_req->set_request_async_id(async_id); + + try { + const proto::FfiResponse resp = sendRequest(req); + if (!resp.has_get_session_stats()) { + cancelPendingByAsyncId(async_id); + return readySessionStatsFailure(GetSessionStatsErrorCode::PROTOCOL_ERROR, + "FfiResponse missing get_session_stats"); + } + } catch (const std::exception& e) { + cancelPendingByAsyncId(async_id); + return readySessionStatsFailure(GetSessionStatsErrorCode::INTERNAL, e.what()); + } + + return fut; +} + // Participant APIs Implementation std::future FfiClient::publishTrackAsync(std::uint64_t local_participant_handle, std::uint64_t track_handle, diff --git a/src/ffi_client.h b/src/ffi_client.h index e66bb3d8..a865ad71 100644 --- a/src/ffi_client.h +++ b/src/ffi_client.h @@ -30,6 +30,7 @@ #include "data_track.pb.h" #include "livekit/data_track_error.h" #include "livekit/result.h" +#include "livekit/session_stats_error.h" #include "livekit/stats.h" #include "livekit/visibility.h" #include "lk_log.h" @@ -97,6 +98,9 @@ class LIVEKIT_INTERNAL_API FfiClient { // Track APIs std::future> getTrackStatsAsync(uintptr_t track_handle); + // Room APIs (stats) + std::future> getSessionStatsAsync(uintptr_t room_handle); + // Participant APIs std::future publishTrackAsync(std::uint64_t local_participant_handle, std::uint64_t track_handle, diff --git a/src/room.cpp b/src/room.cpp index ed258e62..8a2def4d 100644 --- a/src/room.cpp +++ b/src/room.cpp @@ -267,6 +267,21 @@ ConnectionState Room::connectionState() const { return connection_state_; } +std::future> Room::getSessionStats() const { + std::shared_ptr handle; + { + const std::scoped_lock g(lock_); + handle = room_handle_; + } + if (!handle) { + std::promise> pr; + pr.set_value(Result::failure( + GetSessionStatsError{GetSessionStatsErrorCode::NOT_CONNECTED, "Room is not connected"})); + return pr.get_future(); + } + return FfiClient::instance().getSessionStatsAsync(handle->get()); +} + E2EEManager* Room::e2eeManager() const { const std::scoped_lock g(lock_); return e2ee_manager_.get(); diff --git a/src/tests/integration/test_session_stats.cpp b/src/tests/integration/test_session_stats.cpp new file mode 100644 index 00000000..455a8a85 --- /dev/null +++ b/src/tests/integration/test_session_stats.cpp @@ -0,0 +1,211 @@ +/* + * Copyright 2026 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. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../common/audio_utils.h" +#include "../common/test_common.h" + +namespace livekit::test { + +using namespace std::chrono_literals; + +namespace { + +constexpr int kAudioSampleRate = kDefaultAudioSampleRate; +constexpr int kAudioChannels = kDefaultAudioChannels; + +/// Time to let media flow before sampling stats; below this the RTP counters +/// are typically empty and the printed output is uninteresting. +constexpr auto kStatsWarmup = 5s; + +const char* rtcStatsTypeName(const RtcStats& s) { + return std::visit( + [](const auto& v) -> const char* { + using T = std::decay_t; + if constexpr (std::is_same_v) { + return "Codec"; + } else if constexpr (std::is_same_v) { + return "InboundRtp"; + } else if constexpr (std::is_same_v) { + return "OutboundRtp"; + } else if constexpr (std::is_same_v) { + return "RemoteInboundRtp"; + } else if constexpr (std::is_same_v) { + return "RemoteOutboundRtp"; + } else if constexpr (std::is_same_v) { + return "MediaSource"; + } else if constexpr (std::is_same_v) { + return "MediaPlayout"; + } else if constexpr (std::is_same_v) { + return "PeerConnection"; + } else if constexpr (std::is_same_v) { + return "DataChannel"; + } else if constexpr (std::is_same_v) { + return "Transport"; + } else if constexpr (std::is_same_v) { + return "CandidatePair"; + } else if constexpr (std::is_same_v) { + return "LocalCandidate"; + } else if constexpr (std::is_same_v) { + return "RemoteCandidate"; + } else if constexpr (std::is_same_v) { + return "Certificate"; + } else if constexpr (std::is_same_v) { + return "Stream"; + } else { + return "Unknown"; + } + }, + s.stats); +} + +void dumpInterestingEntries(const std::vector& stats) { + for (const auto& stat : stats) { + std::visit( + [&](const auto& s) { + using T = std::decay_t; + if constexpr (std::is_same_v) { + std::cout << " [OutboundRtp] id=" << s.rtc.id << " kind=" << s.stream.kind + << " packets_sent=" << s.sent.packets_sent << " bytes_sent=" << s.sent.bytes_sent + << " target_bitrate=" << std::fixed << std::setprecision(2) << s.outbound.target_bitrate + << std::endl; + } else if constexpr (std::is_same_v) { + std::cout << " [InboundRtp] id=" << s.rtc.id << " kind=" << s.stream.kind + << " packets_received=" << s.received.packets_received + << " packets_lost=" << s.received.packets_lost << " jitter=" << std::fixed << std::setprecision(6) + << s.received.jitter << " bytes_received=" << s.inbound.bytes_received << std::endl; + } else if constexpr (std::is_same_v) { + std::cout << " [CandidatePair] id=" << s.rtc.id << " rtt=" << std::fixed << std::setprecision(4) + << s.candidate_pair.current_round_trip_time << "s" + << " in_bitrate=" << s.candidate_pair.available_incoming_bitrate + << " out_bitrate=" << s.candidate_pair.available_outgoing_bitrate + << " bytes_sent=" << s.candidate_pair.bytes_sent + << " bytes_received=" << s.candidate_pair.bytes_received << std::endl; + } else if constexpr (std::is_same_v) { + std::cout << " [Transport] id=" << s.rtc.id << " packets_sent=" << s.transport.packets_sent + << " packets_received=" << s.transport.packets_received + << " bytes_sent=" << s.transport.bytes_sent << " bytes_received=" << s.transport.bytes_received + << std::endl; + } else if constexpr (std::is_same_v) { + std::cout << " [PeerConnection] id=" << s.rtc.id + << " data_channels_opened=" << s.pc.data_channels_opened + << " data_channels_closed=" << s.pc.data_channels_closed << std::endl; + } + }, + stat.stats); + } +} + +void printSide(const std::string& side_label, const std::vector& stats) { + std::cout << " " << side_label << " entries=" << stats.size(); + std::map type_counts; + for (const auto& s : stats) { + type_counts[rtcStatsTypeName(s)]++; + } + if (!type_counts.empty()) { + std::cout << " types:"; + for (const auto& kv : type_counts) { + std::cout << " " << kv.first << "=" << kv.second; + } + } + std::cout << std::endl; + dumpInterestingEntries(stats); +} + +void printSessionStats(const std::string& room_label, const SessionStats& stats) { + std::cout << "[SessionStats] " << room_label << ":" << std::endl; + printSide("publisher", stats.publisher_stats); + printSide("subscriber", stats.subscriber_stats); +} + +} // namespace + +class SessionStatsIntegrationTest : public LiveKitTestBase {}; + +TEST_F(SessionStatsIntegrationTest, PublishAudioThenFetchSessionStats) { + skipIfNotConfigured(); + + RoomOptions options; + options.auto_subscribe = true; + options.single_peer_connection = false; + + auto receiver_room = std::make_unique(); + ASSERT_TRUE(receiver_room->connect(config_.url, config_.token_b, options)) << "Receiver failed to connect"; + + auto sender_room = std::make_unique(); + ASSERT_TRUE(sender_room->connect(config_.url, config_.token_a, options)) << "Sender failed to connect"; + + auto source = std::make_shared(kAudioSampleRate, kAudioChannels, 0); + auto track = LocalAudioTrack::createLocalAudioTrack("session-stats-audio", source); + TrackPublishOptions opts; + opts.source = TrackSource::SOURCE_MICROPHONE; + sender_room->localParticipant()->publishTrack(track, opts); + std::cerr << "[SessionStats] published audio track sid=" << track->sid() << std::endl; + + std::atomic running{true}; + std::thread audio_thread([&]() { runToneLoop(source, running, /*base_freq_hz=*/440.0, /*siren_mode=*/false); }); + + std::this_thread::sleep_for(kStatsWarmup); + + auto sender_fut = sender_room->getSessionStats(); + auto receiver_fut = receiver_room->getSessionStats(); + + auto sender_result = sender_fut.get(); + auto receiver_result = receiver_fut.get(); + + running.store(false, std::memory_order_relaxed); + if (audio_thread.joinable()) { + audio_thread.join(); + } + if (track->publication()) { + sender_room->localParticipant()->unpublishTrack(track->publication()->sid()); + } + + ASSERT_TRUE(sender_result.ok()) << "Sender getSessionStats failed: code=" + << static_cast(sender_result.error().code) + << " msg=" << sender_result.error().message; + ASSERT_TRUE(receiver_result.ok()) << "Receiver getSessionStats failed: code=" + << static_cast(receiver_result.error().code) + << " msg=" << receiver_result.error().message; + + printSessionStats("sender", sender_result.value()); + printSessionStats("receiver", receiver_result.value()); + + EXPECT_FALSE(sender_result.value().publisher_stats.empty()) << "Sender should have publisher stats"; + EXPECT_FALSE(receiver_result.value().subscriber_stats.empty()) << "Receiver should have subscriber stats"; +} + +TEST_F(SessionStatsIntegrationTest, NotConnectedReturnsNotConnected) { + Room room; + auto fut = room.getSessionStats(); + auto result = fut.get(); + EXPECT_FALSE(result.ok()); + EXPECT_EQ(result.error().code, GetSessionStatsErrorCode::NOT_CONNECTED); + std::cerr << "[SessionStats] disconnected message: " << result.error().message << std::endl; +} + +} // namespace livekit::test diff --git a/src/tests/unit/test_ffi_client.cpp b/src/tests/unit/test_ffi_client.cpp index f7516462..9acc1515 100644 --- a/src/tests/unit/test_ffi_client.cpp +++ b/src/tests/unit/test_ffi_client.cpp @@ -221,6 +221,15 @@ TEST_F(FfiClientTest, NotInitialized_GetTrackStatsAsyncThrows) { EXPECT_THROW(FfiClient::instance().getTrackStatsAsync(1), std::runtime_error); } +TEST_F(FfiClientTest, NotInitialized_GetSessionStatsAsyncFails) { + ASSERT_FALSE(FfiClient::instance().isInitialized()); + + auto fut_result = FfiClient::instance().getSessionStatsAsync(1); + auto result = fut_result.get(); + EXPECT_FALSE(result.ok()); + EXPECT_EQ(result.error().code, GetSessionStatsErrorCode::INTERNAL); +} + TEST_F(FfiClientTest, NotInitialized_PublishDataTrackAsyncFails) { ASSERT_FALSE(FfiClient::instance().isInitialized()); From 1fa57d97a164cc0929e089675882fab9d8f92225 Mon Sep 17 00:00:00 2001 From: Alan George Date: Thu, 21 May 2026 21:40:16 -0700 Subject: [PATCH 2/5] Better API alignment with Rust --- include/livekit/room.h | 2 +- include/livekit/session_stats_error.h | 4 ++-- src/room.cpp | 2 +- src/tests/integration/test_session_stats.cpp | 11 +++++------ 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/include/livekit/room.h b/include/livekit/room.h index 47e0366b..69d5490f 100644 --- a/include/livekit/room.h +++ b/include/livekit/room.h @@ -204,7 +204,7 @@ class LIVEKIT_API Room { /// /// @return Future resolving with publisher + subscriber stats on success, /// or a typed error code + message on failure. - std::future> getSessionStats() const; + std::future> getStats() const; /* Register a handler for incoming text streams on a specific topic. * diff --git a/include/livekit/session_stats_error.h b/include/livekit/session_stats_error.h index 6f6baede..1ca44ee5 100644 --- a/include/livekit/session_stats_error.h +++ b/include/livekit/session_stats_error.h @@ -21,7 +21,7 @@ namespace livekit { -/// Categorical reason code for a failed `Room::getSessionStats()` call. +/// Categorical reason code for a failed `Room::getStats()` call. enum class GetSessionStatsErrorCode : std::uint32_t { /// Catch-all: the FFI returned an error message that does not map to a more /// specific code. @@ -37,7 +37,7 @@ enum class GetSessionStatsErrorCode : std::uint32_t { INTERNAL = 3, }; -/// Typed error returned by `Room::getSessionStats()`. +/// Typed error returned by `Room::getStats()`. /// /// Surfaces the error reason as a `GetSessionStatsErrorCode` plus an /// implementation-defined message for diagnostics/logging. diff --git a/src/room.cpp b/src/room.cpp index 8a2def4d..7f7459aa 100644 --- a/src/room.cpp +++ b/src/room.cpp @@ -267,7 +267,7 @@ ConnectionState Room::connectionState() const { return connection_state_; } -std::future> Room::getSessionStats() const { +std::future> Room::getStats() const { std::shared_ptr handle; { const std::scoped_lock g(lock_); diff --git a/src/tests/integration/test_session_stats.cpp b/src/tests/integration/test_session_stats.cpp index 455a8a85..03c5ce90 100644 --- a/src/tests/integration/test_session_stats.cpp +++ b/src/tests/integration/test_session_stats.cpp @@ -171,8 +171,8 @@ TEST_F(SessionStatsIntegrationTest, PublishAudioThenFetchSessionStats) { std::this_thread::sleep_for(kStatsWarmup); - auto sender_fut = sender_room->getSessionStats(); - auto receiver_fut = receiver_room->getSessionStats(); + auto sender_fut = sender_room->getStats(); + auto receiver_fut = receiver_room->getStats(); auto sender_result = sender_fut.get(); auto receiver_result = receiver_fut.get(); @@ -185,10 +185,9 @@ TEST_F(SessionStatsIntegrationTest, PublishAudioThenFetchSessionStats) { sender_room->localParticipant()->unpublishTrack(track->publication()->sid()); } - ASSERT_TRUE(sender_result.ok()) << "Sender getSessionStats failed: code=" - << static_cast(sender_result.error().code) + ASSERT_TRUE(sender_result.ok()) << "Sender getStats failed: code=" << static_cast(sender_result.error().code) << " msg=" << sender_result.error().message; - ASSERT_TRUE(receiver_result.ok()) << "Receiver getSessionStats failed: code=" + ASSERT_TRUE(receiver_result.ok()) << "Receiver getStats failed: code=" << static_cast(receiver_result.error().code) << " msg=" << receiver_result.error().message; @@ -201,7 +200,7 @@ TEST_F(SessionStatsIntegrationTest, PublishAudioThenFetchSessionStats) { TEST_F(SessionStatsIntegrationTest, NotConnectedReturnsNotConnected) { Room room; - auto fut = room.getSessionStats(); + auto fut = room.getStats(); auto result = fut.get(); EXPECT_FALSE(result.ok()); EXPECT_EQ(result.error().code, GetSessionStatsErrorCode::NOT_CONNECTED); From 95f480def45f906e2df3bc97dbd7930b45d76546 Mon Sep 17 00:00:00 2001 From: Alan George Date: Tue, 26 May 2026 15:26:02 -0600 Subject: [PATCH 3/5] Cleanup AI verbosity --- include/livekit/room.h | 14 ++++---------- include/livekit/stats.h | 7 ------- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/include/livekit/room.h b/include/livekit/room.h index 69d5490f..b60cb953 100644 --- a/include/livekit/room.h +++ b/include/livekit/room.h @@ -194,16 +194,10 @@ class LIVEKIT_API Room { /// Retrieve aggregated WebRTC stats for this room session. /// /// Behavior: - /// - If the room is not currently connected (no live FFI handle), resolves - /// immediately with a `GetSessionStatsErrorCode::NOT_CONNECTED` failure. - /// - Otherwise dispatches an async `get_session_stats` request to the Rust - /// FFI; the future resolves once the corresponding callback arrives. - /// - The future never throws — failures are surfaced as a typed - /// `GetSessionStatsError`. Inspect `Result::ok()` / `Result::error().code` - /// to branch on outcome. - /// - /// @return Future resolving with publisher + subscriber stats on success, - /// or a typed error code + message on failure. + /// - If the room is not currently connected, returns a failed result immediately. + /// - Otherwise dispatches an async request to the server to get the stats. + /// @note Check result.ok() before accessing the stats. + /// @return Future result of the room session stats. std::future> getStats() const; /* Register a handler for incoming text streams on a specific topic. diff --git a/include/livekit/stats.h b/include/livekit/stats.h index 225b5878..4b1580fd 100644 --- a/include/livekit/stats.h +++ b/include/livekit/stats.h @@ -502,13 +502,6 @@ struct RtcStats { }; /// Aggregated WebRTC stats for a connected room session. -/// -/// Mirrors the FFI `GetSessionStatsCallback.Result` payload: stats are split -/// between the publisher peer connection (outbound media flowing from the -/// local participant to the SFU) and the subscriber peer connection (inbound -/// media flowing from the SFU back to the local participant). When the SDK is -/// operating in single-peer-connection mode the publisher list carries the -/// combined stats and the subscriber list is empty. struct SessionStats { /// Stats from the publisher peer connection (outbound media). std::vector publisher_stats; From 339597a60623dd5b58bf2a919b4864c4a76a08a0 Mon Sep 17 00:00:00 2001 From: Alan George Date: Tue, 26 May 2026 15:59:50 -0600 Subject: [PATCH 4/5] Cleanup result type --- include/livekit/room.h | 10 ++-- include/livekit/session_stats_error.h | 49 -------------------- src/ffi_client.cpp | 26 +++++------ src/ffi_client.h | 6 +-- src/room.cpp | 7 ++- src/tests/integration/test_session_stats.cpp | 13 ++---- src/tests/unit/test_ffi_client.cpp | 2 +- 7 files changed, 30 insertions(+), 83 deletions(-) delete mode 100644 include/livekit/session_stats_error.h diff --git a/include/livekit/room.h b/include/livekit/room.h index b60cb953..781ae6ce 100644 --- a/include/livekit/room.h +++ b/include/livekit/room.h @@ -26,7 +26,6 @@ #include "livekit/ffi_handle.h" #include "livekit/result.h" #include "livekit/room_event_types.h" -#include "livekit/session_stats_error.h" #include "livekit/stats.h" #include "livekit/subscription_thread_dispatcher.h" #include "livekit/visibility.h" @@ -196,9 +195,14 @@ class LIVEKIT_API Room { /// Behavior: /// - If the room is not currently connected, returns a failed result immediately. /// - Otherwise dispatches an async request to the server to get the stats. - /// @note Check result.ok() before accessing the stats. + /// + /// @note Check `result.ok()` before accessing the stats. The error variant + /// is a free-form diagnostic string; treat it as opaque (suitable for logs/ + /// metrics, not for programmatic branching). The Rust FFI does not yet + /// surface a typed error code for this operation; see `cb.error()` plumbing + /// in `FfiClient::getSessionStatsAsync` for the source. /// @return Future result of the room session stats. - std::future> getStats() const; + std::future> getStats() const; /* Register a handler for incoming text streams on a specific topic. * diff --git a/include/livekit/session_stats_error.h b/include/livekit/session_stats_error.h deleted file mode 100644 index 1ca44ee5..00000000 --- a/include/livekit/session_stats_error.h +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2026 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. - */ - -#pragma once - -#include -#include - -namespace livekit { - -/// Categorical reason code for a failed `Room::getStats()` call. -enum class GetSessionStatsErrorCode : std::uint32_t { - /// Catch-all: the FFI returned an error message that does not map to a more - /// specific code. - UNKNOWN = 0, - /// The `Room` has no live FFI handle (never connected or already - /// disconnected). - NOT_CONNECTED = 1, - /// The FFI responded with an unexpected response shape (e.g. a missing - /// `get_session_stats` field on the synchronous response). - PROTOCOL_ERROR = 2, - /// The FFI threw an internal error while servicing the request (e.g. the - /// underlying Rust engine reported a failure). - INTERNAL = 3, -}; - -/// Typed error returned by `Room::getStats()`. -/// -/// Surfaces the error reason as a `GetSessionStatsErrorCode` plus an -/// implementation-defined message for diagnostics/logging. -struct GetSessionStatsError { - GetSessionStatsErrorCode code{GetSessionStatsErrorCode::UNKNOWN}; - std::string message; -}; - -} // namespace livekit diff --git a/src/ffi_client.cpp b/src/ffi_client.cpp index 8cc7d946..915158ab 100644 --- a/src/ffi_client.cpp +++ b/src/ffi_client.cpp @@ -430,35 +430,32 @@ std::future> FfiClient::getTrackStatsAsync(uintptr_t track namespace { -std::future> readySessionStatsFailure(GetSessionStatsErrorCode code, - std::string message) { - std::promise> pr; - pr.set_value(Result::failure(GetSessionStatsError{code, std::move(message)})); +std::future> readySessionStatsFailure(std::string message) { + std::promise> pr; + pr.set_value(Result::failure(std::move(message))); return pr.get_future(); } } // namespace -std::future> FfiClient::getSessionStatsAsync(uintptr_t room_handle) { +std::future> FfiClient::getSessionStatsAsync(uintptr_t room_handle) { const AsyncId async_id = generateAsyncId(); - auto fut = registerAsync>( + auto fut = registerAsync>( async_id, // match [async_id](const proto::FfiEvent& event) { return event.has_get_session_stats() && event.get_session_stats().async_id() == async_id; }, // handler - [](const proto::FfiEvent& event, std::promise>& pr) { + [](const proto::FfiEvent& event, std::promise>& pr) { const auto& cb = event.get_session_stats(); if (cb.has_error()) { - pr.set_value(Result::failure( - GetSessionStatsError{GetSessionStatsErrorCode::INTERNAL, cb.error()})); + pr.set_value(Result::failure(cb.error())); return; } if (!cb.has_result()) { - pr.set_value(Result::failure(GetSessionStatsError{ - GetSessionStatsErrorCode::PROTOCOL_ERROR, "GetSessionStatsCallback missing result and error"})); + pr.set_value(Result::failure("GetSessionStatsCallback missing result and error")); return; } @@ -472,7 +469,7 @@ std::future> FfiClient::getSessionSta for (const auto& ps : result.subscriber_stats()) { stats.subscriber_stats.push_back(fromProto(ps)); } - pr.set_value(Result::success(std::move(stats))); + pr.set_value(Result::success(std::move(stats))); }); proto::FfiRequest req; @@ -484,12 +481,11 @@ std::future> FfiClient::getSessionSta const proto::FfiResponse resp = sendRequest(req); if (!resp.has_get_session_stats()) { cancelPendingByAsyncId(async_id); - return readySessionStatsFailure(GetSessionStatsErrorCode::PROTOCOL_ERROR, - "FfiResponse missing get_session_stats"); + return readySessionStatsFailure("FfiResponse missing get_session_stats"); } } catch (const std::exception& e) { cancelPendingByAsyncId(async_id); - return readySessionStatsFailure(GetSessionStatsErrorCode::INTERNAL, e.what()); + return readySessionStatsFailure(e.what()); } return fut; diff --git a/src/ffi_client.h b/src/ffi_client.h index a865ad71..99a8ebcf 100644 --- a/src/ffi_client.h +++ b/src/ffi_client.h @@ -30,7 +30,6 @@ #include "data_track.pb.h" #include "livekit/data_track_error.h" #include "livekit/result.h" -#include "livekit/session_stats_error.h" #include "livekit/stats.h" #include "livekit/visibility.h" #include "lk_log.h" @@ -98,8 +97,9 @@ class LIVEKIT_INTERNAL_API FfiClient { // Track APIs std::future> getTrackStatsAsync(uintptr_t track_handle); - // Room APIs (stats) - std::future> getSessionStatsAsync(uintptr_t room_handle); + // Room APIs (stats). On failure, the error variant carries an + // implementation-defined diagnostic string (suitable for logs/metrics). + std::future> getSessionStatsAsync(uintptr_t room_handle); // Participant APIs std::future publishTrackAsync(std::uint64_t local_participant_handle, diff --git a/src/room.cpp b/src/room.cpp index 7f7459aa..f622559a 100644 --- a/src/room.cpp +++ b/src/room.cpp @@ -267,16 +267,15 @@ ConnectionState Room::connectionState() const { return connection_state_; } -std::future> Room::getStats() const { +std::future> Room::getStats() const { std::shared_ptr handle; { const std::scoped_lock g(lock_); handle = room_handle_; } if (!handle) { - std::promise> pr; - pr.set_value(Result::failure( - GetSessionStatsError{GetSessionStatsErrorCode::NOT_CONNECTED, "Room is not connected"})); + std::promise> pr; + pr.set_value(Result::failure("Room is not connected")); return pr.get_future(); } return FfiClient::instance().getSessionStatsAsync(handle->get()); diff --git a/src/tests/integration/test_session_stats.cpp b/src/tests/integration/test_session_stats.cpp index 03c5ce90..4e89d5fc 100644 --- a/src/tests/integration/test_session_stats.cpp +++ b/src/tests/integration/test_session_stats.cpp @@ -185,11 +185,8 @@ TEST_F(SessionStatsIntegrationTest, PublishAudioThenFetchSessionStats) { sender_room->localParticipant()->unpublishTrack(track->publication()->sid()); } - ASSERT_TRUE(sender_result.ok()) << "Sender getStats failed: code=" << static_cast(sender_result.error().code) - << " msg=" << sender_result.error().message; - ASSERT_TRUE(receiver_result.ok()) << "Receiver getStats failed: code=" - << static_cast(receiver_result.error().code) - << " msg=" << receiver_result.error().message; + ASSERT_TRUE(sender_result.ok()) << "Sender getStats failed: " << sender_result.error(); + ASSERT_TRUE(receiver_result.ok()) << "Receiver getStats failed: " << receiver_result.error(); printSessionStats("sender", sender_result.value()); printSessionStats("receiver", receiver_result.value()); @@ -198,13 +195,13 @@ TEST_F(SessionStatsIntegrationTest, PublishAudioThenFetchSessionStats) { EXPECT_FALSE(receiver_result.value().subscriber_stats.empty()) << "Receiver should have subscriber stats"; } -TEST_F(SessionStatsIntegrationTest, NotConnectedReturnsNotConnected) { +TEST_F(SessionStatsIntegrationTest, NotConnectedReturnsError) { Room room; auto fut = room.getStats(); auto result = fut.get(); EXPECT_FALSE(result.ok()); - EXPECT_EQ(result.error().code, GetSessionStatsErrorCode::NOT_CONNECTED); - std::cerr << "[SessionStats] disconnected message: " << result.error().message << std::endl; + EXPECT_FALSE(result.error().empty()); + std::cerr << "[SessionStats] disconnected message: " << result.error() << std::endl; } } // namespace livekit::test diff --git a/src/tests/unit/test_ffi_client.cpp b/src/tests/unit/test_ffi_client.cpp index 9acc1515..783b80ca 100644 --- a/src/tests/unit/test_ffi_client.cpp +++ b/src/tests/unit/test_ffi_client.cpp @@ -227,7 +227,7 @@ TEST_F(FfiClientTest, NotInitialized_GetSessionStatsAsyncFails) { auto fut_result = FfiClient::instance().getSessionStatsAsync(1); auto result = fut_result.get(); EXPECT_FALSE(result.ok()); - EXPECT_EQ(result.error().code, GetSessionStatsErrorCode::INTERNAL); + EXPECT_FALSE(result.error().empty()); } TEST_F(FfiClientTest, NotInitialized_PublishDataTrackAsyncFails) { From 3b861c320acf37c3bd531b9cfc88709ac3d5c3b7 Mon Sep 17 00:00:00 2001 From: Alan George Date: Thu, 28 May 2026 17:01:01 -0600 Subject: [PATCH 5/5] Use exception instead --- include/livekit/room.h | 20 ++++++------- src/ffi_client.cpp | 30 +++++++------------- src/ffi_client.h | 4 +-- src/room.cpp | 6 ++-- src/tests/integration/test_session_stats.cpp | 25 +++++++--------- src/tests/unit/test_ffi_client.cpp | 7 ++--- 6 files changed, 34 insertions(+), 58 deletions(-) diff --git a/include/livekit/room.h b/include/livekit/room.h index 781ae6ce..5d8384f4 100644 --- a/include/livekit/room.h +++ b/include/livekit/room.h @@ -24,7 +24,6 @@ #include "livekit/data_stream.h" #include "livekit/e2ee.h" #include "livekit/ffi_handle.h" -#include "livekit/result.h" #include "livekit/room_event_types.h" #include "livekit/stats.h" #include "livekit/subscription_thread_dispatcher.h" @@ -192,17 +191,16 @@ class LIVEKIT_API Room { /// Retrieve aggregated WebRTC stats for this room session. /// - /// Behavior: - /// - If the room is not currently connected, returns a failed result immediately. - /// - Otherwise dispatches an async request to the server to get the stats. + /// Dispatches an async request to the server and returns a future that + /// resolves with the stats. /// - /// @note Check `result.ok()` before accessing the stats. The error variant - /// is a free-form diagnostic string; treat it as opaque (suitable for logs/ - /// metrics, not for programmatic branching). The Rust FFI does not yet - /// surface a typed error code for this operation; see `cb.error()` plumbing - /// in `FfiClient::getSessionStatsAsync` for the source. - /// @return Future result of the room session stats. - std::future> getStats() const; + /// @return Future of the room session stats. + /// + /// @throws std::runtime_error Synchronously, if the room is not currently + /// connected, or if the FFI request fails to + /// dispatch. + /// @throws std::runtime_error On `future.get()`, if the async request fails. + std::future getStats() const; /* Register a handler for incoming text streams on a specific topic. * diff --git a/src/ffi_client.cpp b/src/ffi_client.cpp index 915158ab..de2ae065 100644 --- a/src/ffi_client.cpp +++ b/src/ffi_client.cpp @@ -428,34 +428,25 @@ std::future> FfiClient::getTrackStatsAsync(uintptr_t track return fut; } -namespace { - -std::future> readySessionStatsFailure(std::string message) { - std::promise> pr; - pr.set_value(Result::failure(std::move(message))); - return pr.get_future(); -} - -} // namespace - -std::future> FfiClient::getSessionStatsAsync(uintptr_t room_handle) { +std::future FfiClient::getSessionStatsAsync(uintptr_t room_handle) { const AsyncId async_id = generateAsyncId(); - auto fut = registerAsync>( + auto fut = registerAsync( async_id, // match [async_id](const proto::FfiEvent& event) { return event.has_get_session_stats() && event.get_session_stats().async_id() == async_id; }, // handler - [](const proto::FfiEvent& event, std::promise>& pr) { + [](const proto::FfiEvent& event, std::promise& pr) { const auto& cb = event.get_session_stats(); if (cb.has_error()) { - pr.set_value(Result::failure(cb.error())); + pr.set_exception(std::make_exception_ptr(std::runtime_error(cb.error()))); return; } if (!cb.has_result()) { - pr.set_value(Result::failure("GetSessionStatsCallback missing result and error")); + pr.set_exception( + std::make_exception_ptr(std::runtime_error("GetSessionStatsCallback missing result and error"))); return; } @@ -469,7 +460,7 @@ std::future> FfiClient::getSessionStatsAsync(u for (const auto& ps : result.subscriber_stats()) { stats.subscriber_stats.push_back(fromProto(ps)); } - pr.set_value(Result::success(std::move(stats))); + pr.set_value(std::move(stats)); }); proto::FfiRequest req; @@ -480,12 +471,11 @@ std::future> FfiClient::getSessionStatsAsync(u try { const proto::FfiResponse resp = sendRequest(req); if (!resp.has_get_session_stats()) { - cancelPendingByAsyncId(async_id); - return readySessionStatsFailure("FfiResponse missing get_session_stats"); + logAndThrow("FfiResponse missing get_session_stats"); } - } catch (const std::exception& e) { + } catch (...) { cancelPendingByAsyncId(async_id); - return readySessionStatsFailure(e.what()); + throw; } return fut; diff --git a/src/ffi_client.h b/src/ffi_client.h index 99a8ebcf..1cfdd361 100644 --- a/src/ffi_client.h +++ b/src/ffi_client.h @@ -97,9 +97,7 @@ class LIVEKIT_INTERNAL_API FfiClient { // Track APIs std::future> getTrackStatsAsync(uintptr_t track_handle); - // Room APIs (stats). On failure, the error variant carries an - // implementation-defined diagnostic string (suitable for logs/metrics). - std::future> getSessionStatsAsync(uintptr_t room_handle); + std::future getSessionStatsAsync(uintptr_t room_handle); // Participant APIs std::future publishTrackAsync(std::uint64_t local_participant_handle, diff --git a/src/room.cpp b/src/room.cpp index f622559a..99b050a6 100644 --- a/src/room.cpp +++ b/src/room.cpp @@ -267,16 +267,14 @@ ConnectionState Room::connectionState() const { return connection_state_; } -std::future> Room::getStats() const { +std::future Room::getStats() const { std::shared_ptr handle; { const std::scoped_lock g(lock_); handle = room_handle_; } if (!handle) { - std::promise> pr; - pr.set_value(Result::failure("Room is not connected")); - return pr.get_future(); + throw std::runtime_error("Room::getStats called on a disconnected room"); } return FfiClient::instance().getSessionStatsAsync(handle->get()); } diff --git a/src/tests/integration/test_session_stats.cpp b/src/tests/integration/test_session_stats.cpp index 4e89d5fc..4bd88c56 100644 --- a/src/tests/integration/test_session_stats.cpp +++ b/src/tests/integration/test_session_stats.cpp @@ -174,8 +174,10 @@ TEST_F(SessionStatsIntegrationTest, PublishAudioThenFetchSessionStats) { auto sender_fut = sender_room->getStats(); auto receiver_fut = receiver_room->getStats(); - auto sender_result = sender_fut.get(); - auto receiver_result = receiver_fut.get(); + SessionStats sender_stats; + SessionStats receiver_stats; + ASSERT_NO_THROW(sender_stats = sender_fut.get()) << "Sender getStats threw"; + ASSERT_NO_THROW(receiver_stats = receiver_fut.get()) << "Receiver getStats threw"; running.store(false, std::memory_order_relaxed); if (audio_thread.joinable()) { @@ -185,23 +187,16 @@ TEST_F(SessionStatsIntegrationTest, PublishAudioThenFetchSessionStats) { sender_room->localParticipant()->unpublishTrack(track->publication()->sid()); } - ASSERT_TRUE(sender_result.ok()) << "Sender getStats failed: " << sender_result.error(); - ASSERT_TRUE(receiver_result.ok()) << "Receiver getStats failed: " << receiver_result.error(); + printSessionStats("sender", sender_stats); + printSessionStats("receiver", receiver_stats); - printSessionStats("sender", sender_result.value()); - printSessionStats("receiver", receiver_result.value()); - - EXPECT_FALSE(sender_result.value().publisher_stats.empty()) << "Sender should have publisher stats"; - EXPECT_FALSE(receiver_result.value().subscriber_stats.empty()) << "Receiver should have subscriber stats"; + EXPECT_FALSE(sender_stats.publisher_stats.empty()) << "Sender should have publisher stats"; + EXPECT_FALSE(receiver_stats.subscriber_stats.empty()) << "Receiver should have subscriber stats"; } -TEST_F(SessionStatsIntegrationTest, NotConnectedReturnsError) { +TEST_F(SessionStatsIntegrationTest, NotConnectedThrows) { Room room; - auto fut = room.getStats(); - auto result = fut.get(); - EXPECT_FALSE(result.ok()); - EXPECT_FALSE(result.error().empty()); - std::cerr << "[SessionStats] disconnected message: " << result.error() << std::endl; + EXPECT_THROW(room.getStats(), std::runtime_error); } } // namespace livekit::test diff --git a/src/tests/unit/test_ffi_client.cpp b/src/tests/unit/test_ffi_client.cpp index 783b80ca..79f74263 100644 --- a/src/tests/unit/test_ffi_client.cpp +++ b/src/tests/unit/test_ffi_client.cpp @@ -221,13 +221,10 @@ TEST_F(FfiClientTest, NotInitialized_GetTrackStatsAsyncThrows) { EXPECT_THROW(FfiClient::instance().getTrackStatsAsync(1), std::runtime_error); } -TEST_F(FfiClientTest, NotInitialized_GetSessionStatsAsyncFails) { +TEST_F(FfiClientTest, NotInitialized_GetSessionStatsAsyncThrows) { ASSERT_FALSE(FfiClient::instance().isInitialized()); - auto fut_result = FfiClient::instance().getSessionStatsAsync(1); - auto result = fut_result.get(); - EXPECT_FALSE(result.ok()); - EXPECT_FALSE(result.error().empty()); + EXPECT_THROW(FfiClient::instance().getSessionStatsAsync(1), std::runtime_error); } TEST_F(FfiClientTest, NotInitialized_PublishDataTrackAsyncFails) {