Skip to content

Commit 24db748

Browse files
committed
feat: Add missing reply_to_stream_id field to TextStreamInfo and forward it from proto::DataStream.text_header().reply_to_stream_id()
1 parent 2b81279 commit 24db748

3 files changed

Lines changed: 220 additions & 1 deletion

File tree

include/livekit/data_stream.h

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,10 @@ struct BaseStreamInfo {
6161

6262
/// Metadata for a text stream.
6363
struct TextStreamInfo : BaseStreamInfo {
64-
/// IDs of any attached streams (for replies / threads).
64+
/// IDs of any attached streams (for attached files).
6565
std::vector<std::string> attachments;
66+
/// If this stream is a reply to another stream, this field holds its ID
67+
std::optional<std::string> reply_to_stream_id = std::nullopt;
6668
};
6769

6870
/// Metadata for a byte stream.

src/room_proto_converter.cpp

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,9 @@ TextStreamInfo makeTextInfo(const proto::DataStream::Header& header) {
615615
info.mime_type = header.mime_type();
616616
info.topic = header.topic();
617617
info.timestamp = header.timestamp();
618+
if (header.text_header().has_reply_to_stream_id()) {
619+
info.reply_to_stream_id = header.text_header().reply_to_stream_id();
620+
}
618621

619622
if (header.has_total_length()) {
620623
info.size = static_cast<std::size_t>(header.total_length());
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
/*
2+
* Copyright 2026 LiveKit
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
// End-to-end coverage for text/byte data streams (registerTextStreamHandler /
18+
// registerByteStreamHandler + TextStreamWriter / ByteStreamWriter). Requires a
19+
// local SFU; see test_data_track.cpp for setup instructions. Run with:
20+
// ./build-debug/bin/livekit_integration_tests --gtest_filter=*DataStream*
21+
22+
#include <livekit/data_stream.h>
23+
24+
#include <condition_variable>
25+
#include <cstdint>
26+
#include <mutex>
27+
#include <string>
28+
#include <thread>
29+
#include <vector>
30+
31+
#include "../common/test_common.h"
32+
33+
namespace livekit::test {
34+
35+
using namespace std::chrono_literals;
36+
37+
namespace {
38+
39+
constexpr auto kStreamWaitTimeout = 10s;
40+
41+
std::string makeTopic(const std::string& suffix) {
42+
return "test_topic_" + suffix + "_" + std::to_string(getTimestampUs());
43+
}
44+
45+
/// Waits for a single incoming text stream on `topic` and captures its info
46+
/// plus fully-read content. Registers itself as the topic's handler.
47+
class TextStreamCollector {
48+
public:
49+
void registerOn(Room& room, const std::string& topic) {
50+
// Handlers run on the Room event thread and must not block (per
51+
// registerTextStreamHandler's docs), since later chunk/close events for
52+
// this same reader are dispatched on that same thread. readAll() blocks
53+
// until close, so it has to happen on a separate thread.
54+
room.registerTextStreamHandler(
55+
topic, [this](std::shared_ptr<TextStreamReader> reader, const std::string& participant_identity) {
56+
std::thread([this, reader, participant_identity] {
57+
auto info = reader->info();
58+
auto text = reader->readAll();
59+
std::lock_guard<std::mutex> lock(mutex_);
60+
info_ = std::move(info);
61+
text_ = std::move(text);
62+
sender_identity_ = participant_identity;
63+
done_ = true;
64+
cv_.notify_all();
65+
}).detach();
66+
});
67+
}
68+
69+
bool wait(std::chrono::milliseconds timeout) {
70+
std::unique_lock<std::mutex> lock(mutex_);
71+
return cv_.wait_for(lock, timeout, [this] { return done_; });
72+
}
73+
74+
const TextStreamInfo& info() const { return info_; }
75+
const std::string& text() const { return text_; }
76+
const std::string& senderIdentity() const { return sender_identity_; }
77+
78+
private:
79+
std::mutex mutex_;
80+
std::condition_variable cv_;
81+
bool done_ = false;
82+
TextStreamInfo info_;
83+
std::string text_;
84+
std::string sender_identity_;
85+
};
86+
87+
/// Same idea as TextStreamCollector, for byte streams.
88+
class ByteStreamCollector {
89+
public:
90+
void registerOn(Room& room, const std::string& topic) {
91+
// See TextStreamCollector::registerOn: the blocking readNext() loop must
92+
// not run on the Room event thread.
93+
room.registerByteStreamHandler(
94+
topic, [this](std::shared_ptr<ByteStreamReader> reader, const std::string& participant_identity) {
95+
std::thread([this, reader, participant_identity] {
96+
auto info = reader->info();
97+
std::vector<std::uint8_t> content;
98+
std::vector<std::uint8_t> chunk;
99+
while (reader->readNext(chunk)) {
100+
content.insert(content.end(), chunk.begin(), chunk.end());
101+
}
102+
std::lock_guard<std::mutex> lock(mutex_);
103+
info_ = std::move(info);
104+
content_ = std::move(content);
105+
sender_identity_ = participant_identity;
106+
done_ = true;
107+
cv_.notify_all();
108+
}).detach();
109+
});
110+
}
111+
112+
bool wait(std::chrono::milliseconds timeout) {
113+
std::unique_lock<std::mutex> lock(mutex_);
114+
return cv_.wait_for(lock, timeout, [this] { return done_; });
115+
}
116+
117+
const ByteStreamInfo& info() const { return info_; }
118+
const std::vector<std::uint8_t>& content() const { return content_; }
119+
const std::string& senderIdentity() const { return sender_identity_; }
120+
121+
private:
122+
std::mutex mutex_;
123+
std::condition_variable cv_;
124+
bool done_ = false;
125+
ByteStreamInfo info_;
126+
std::vector<std::uint8_t> content_;
127+
std::string sender_identity_;
128+
};
129+
130+
} // namespace
131+
132+
class DataStreamE2ETest : public LiveKitTestBase {};
133+
134+
TEST_F(DataStreamE2ETest, TextStreamRoundTripEndToEnd) {
135+
const auto topic = makeTopic("text");
136+
137+
auto rooms = testRooms(2);
138+
auto& sender_room = rooms[0];
139+
auto& receiver_room = rooms[1];
140+
const auto sender_identity = lockLocalParticipant(*sender_room)->identity();
141+
142+
TextStreamCollector collector;
143+
collector.registerOn(*receiver_room, topic);
144+
145+
{
146+
TextStreamWriter writer(*lockLocalParticipant(*sender_room), topic);
147+
writer.write("hello, ");
148+
writer.write("world!");
149+
writer.close();
150+
}
151+
152+
ASSERT_TRUE(collector.wait(kStreamWaitTimeout)) << "Timed out waiting for text stream";
153+
EXPECT_EQ(collector.text(), "hello, world!");
154+
EXPECT_EQ(collector.senderIdentity(), sender_identity);
155+
EXPECT_EQ(collector.info().topic, topic);
156+
EXPECT_EQ(collector.info().mime_type, "text/plain");
157+
}
158+
159+
// Regression coverage for the `text_header.reply_to_stream_id` field: writing
160+
// a text stream with a reply-to id set should surface that id on the
161+
// receiving side's TextStreamInfo. If this fails while the C++-side
162+
// conversion (room_proto_converter.cpp: makeTextInfo) looks correct, the gap
163+
// is upstream in the Rust FFI layer not forwarding the field.
164+
TEST_F(DataStreamE2ETest, TextStreamReplyToStreamIdIsRoutedEndToEnd) {
165+
const auto topic = makeTopic("text_reply");
166+
const std::string reply_to_id = "original-stream-" + std::to_string(getTimestampUs());
167+
168+
auto rooms = testRooms(2);
169+
auto& sender_room = rooms[0];
170+
auto& receiver_room = rooms[1];
171+
172+
TextStreamCollector collector;
173+
collector.registerOn(*receiver_room, topic);
174+
175+
{
176+
TextStreamWriter writer(*lockLocalParticipant(*sender_room), topic, /*attributes=*/{}, /*stream_id=*/"",
177+
/*total_size=*/std::nullopt, reply_to_id);
178+
writer.write("reply payload");
179+
writer.close();
180+
}
181+
182+
ASSERT_TRUE(collector.wait(kStreamWaitTimeout)) << "Timed out waiting for text stream";
183+
EXPECT_EQ(collector.text(), "reply payload");
184+
ASSERT_TRUE(collector.info().reply_to_stream_id.has_value())
185+
<< "reply_to_stream_id was not routed through FFI from the Rust SDK";
186+
EXPECT_EQ(collector.info().reply_to_stream_id.value(), reply_to_id);
187+
}
188+
189+
TEST_F(DataStreamE2ETest, ByteStreamRoundTripEndToEnd) {
190+
const auto topic = makeTopic("bytes");
191+
const std::vector<std::uint8_t> payload{0x00, 0x01, 0x02, 0xFE, 0xFF, 'h', 'i'};
192+
193+
auto rooms = testRooms(2);
194+
auto& sender_room = rooms[0];
195+
auto& receiver_room = rooms[1];
196+
const auto sender_identity = lockLocalParticipant(*sender_room)->identity();
197+
198+
ByteStreamCollector collector;
199+
collector.registerOn(*receiver_room, topic);
200+
201+
{
202+
ByteStreamWriter writer(*lockLocalParticipant(*sender_room), /*name=*/"payload.bin", topic);
203+
writer.write(payload);
204+
writer.close();
205+
}
206+
207+
ASSERT_TRUE(collector.wait(kStreamWaitTimeout)) << "Timed out waiting for byte stream";
208+
EXPECT_EQ(collector.content(), payload);
209+
EXPECT_EQ(collector.senderIdentity(), sender_identity);
210+
EXPECT_EQ(collector.info().topic, topic);
211+
EXPECT_EQ(collector.info().name, "payload.bin");
212+
}
213+
214+
} // namespace livekit::test

0 commit comments

Comments
 (0)