Skip to content

Commit c91f37b

Browse files
committed
Merge fix/egress-blocking-send: move egress player send off the QUIC loop
Introduces a bounded PlayerQueue (stale-media drop per draft s11) and a dedicated writer thread so a stalled RTMP player can no longer wedge the egress QUIC network thread. on_frame/on_player_play only enqueue; the writer does the blocking send outside player_mutex. Teardown stops the QUIC producer, shuts the player fd to unblock a stalled send, closes the queue, joins the writer, then destroys sessions. Reconnect closes and flushes the prior player. Stall test pins SO_RCVBUF so drops are deterministic across host TCP tuning.
2 parents 14d07d5 + fe2b124 commit c91f37b

8 files changed

Lines changed: 373 additions & 11 deletions

File tree

gateway/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ add_library(roqr-gateway STATIC
33
src/rtmp_commands.cpp
44
src/ingest.cpp
55
src/egress.cpp
6+
src/player_queue.cpp
67
)
78

89
target_include_directories(roqr-gateway PUBLIC

gateway/include/roqr/gateway/egress.hpp

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ class EgressGateway {
2727
void stop();
2828
bool wait_playing(std::chrono::milliseconds timeout);
2929

30+
// Number of media messages dropped under player backpressure (draft s11).
31+
uint64_t frames_dropped() const;
32+
3033
private:
3134
struct Impl;
3235
std::unique_ptr<Impl> impl_;
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
#pragma once
2+
3+
#include <condition_variable>
4+
#include <cstdint>
5+
#include <deque>
6+
#include <mutex>
7+
#include <optional>
8+
9+
#include "roqr/rtmp/message.hpp"
10+
11+
namespace roqr::gateway {
12+
13+
// Thread-safe bounded queue between the RoQR receive thread (producer) and
14+
// the egress writer thread (consumer). When full, the oldest Coded entry is
15+
// evicted so decoder config (metadata + sequence headers, Kind::Init) is
16+
// never dropped while stale coded media is (draft s11). close() unblocks a
17+
// waiting consumer so the writer thread can exit.
18+
class PlayerQueue {
19+
public:
20+
enum class Kind { Init, Coded }; // Init = metadata / sequence header
21+
22+
explicit PlayerQueue(size_t max_messages) : max_(max_messages) {}
23+
24+
// Enqueue. On overflow, drop the oldest Coded entry to make room; if the
25+
// queue holds only Init entries (degenerate) drop the incoming Coded
26+
// message instead of growing. Returns false if the message was dropped
27+
// (either the incoming one or the evicted victim's slot is the caller's
28+
// signal that a drop happened). Never blocks.
29+
bool push(roqr::rtmp::RtmpMessage msg, Kind kind);
30+
31+
// Block until a message is available or the queue is closed. Returns
32+
// nullopt only when the queue is closed AND drained.
33+
std::optional<roqr::rtmp::RtmpMessage> pop();
34+
35+
void close();
36+
size_t size() const;
37+
uint64_t dropped() const;
38+
39+
// Drop all queued entries without counting them toward dropped() — used
40+
// when installing a new player so stale prior-era frames don't linger
41+
// ahead of the new player's init frames; this is a deliberate flush, not
42+
// the loss dropped() tracks.
43+
void clear();
44+
45+
private:
46+
struct Entry {
47+
roqr::rtmp::RtmpMessage msg;
48+
Kind kind;
49+
};
50+
mutable std::mutex mutex_;
51+
std::condition_variable cv_;
52+
std::deque<Entry> queue_;
53+
size_t max_;
54+
uint64_t dropped_ = 0;
55+
bool closed_ = false;
56+
};
57+
58+
} // namespace roqr::gateway

gateway/src/egress.cpp

Lines changed: 74 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55
#include <map>
66
#include <mutex>
77
#include <string>
8+
#include <thread>
89
#include <vector>
910

1011
#include "roqr/gateway/bridge.hpp"
1112
#include "roqr/gateway/gap.hpp"
13+
#include "roqr/gateway/player_queue.hpp"
1214
#include "roqr/gateway/rtmp_commands.hpp"
1315
#include "roqr/quic/client.hpp"
1416
#include "roqr/rtmp/classify.hpp"
@@ -39,10 +41,34 @@ struct EgressGateway::Impl {
3941

4042
roqr::gateway::GapTracker gaps; // draft s8, defined in gap.hpp
4143

44+
// Bounded queue + dedicated writer thread (draft s11): on_frame (QUIC
45+
// network thread) and on_player_play (session thread) only enqueue;
46+
// the writer thread does the blocking player->send, so a stalled RTMP
47+
// player never wedges the QUIC loop. Must be declared before `client`
48+
// (see below) so they're destroyed after it re: construction order,
49+
// but they're joined/closed explicitly in stop() well before that.
50+
static constexpr size_t kMaxQueuedMessages = 512;
51+
PlayerQueue queue{kMaxQueuedMessages};
52+
std::thread writer;
53+
bool writer_started = false;
54+
4255
// Declared last: ~Client joins the network thread before the members
4356
// its handlers touch are destroyed.
4457
roqr::quic::Client client;
4558

59+
void writer_loop() {
60+
for (;;) {
61+
auto msg = queue.pop();
62+
if (!msg) return; // closed and drained
63+
roqr::rtmp::ServerSession* p = nullptr;
64+
{
65+
std::lock_guard lock(player_mutex);
66+
if (player_ready) p = player;
67+
}
68+
if (p != nullptr) p->send(*msg);
69+
}
70+
}
71+
4672
static bool is_init(const roqr::rtmp::RtmpMessage& msg) {
4773
if (msg.type == roqr::rtmp::kTypeDataAmf0 || msg.type == 15 /* AMF3 data */)
4874
return true;
@@ -67,23 +93,32 @@ struct EgressGateway::Impl {
6793
if (msg.type == roqr::rtmp::kTypeCommandAmf0) return; // relay replies
6894
if (msg.type == 9 && !accept_video(msg)) return;
6995

70-
std::lock_guard lock(player_mutex);
71-
if (is_init(msg)) {
72-
if (init_cache.find(msg.type) == init_cache.end()) {
73-
init_types.push_back(msg.type);
96+
const bool init = is_init(msg);
97+
{
98+
std::lock_guard lock(player_mutex);
99+
if (init) {
100+
if (init_cache.find(msg.type) == init_cache.end()) {
101+
init_types.push_back(msg.type);
102+
}
103+
init_cache[msg.type] = msg;
74104
}
75-
init_cache[msg.type] = msg;
105+
if (!player_ready) return; // pre-play: cache only, don't enqueue
76106
}
77-
if (player != nullptr && player_ready) player->send(msg);
107+
queue.push(std::move(msg),
108+
init ? PlayerQueue::Kind::Init : PlayerQueue::Kind::Coded);
78109
}
79110

80111
// Called on the session thread when the player issues play (after the
81-
// RTMP handshake). Primes it with the cached init frames, then opens
82-
// live delivery.
112+
// RTMP handshake). Enqueues the cached init frames (so the writer
113+
// thread delivers them, not the session thread) and sets player_ready
114+
// under the lock before releasing, so on_frame cannot interleave a live
115+
// coded frame ahead of the init frames.
83116
void on_player_play() {
84117
std::lock_guard lock(player_mutex);
85118
if (player == nullptr) return;
86-
for (uint8_t type : init_types) player->send(init_cache.at(type));
119+
for (uint8_t type : init_types) {
120+
queue.push(init_cache.at(type), PlayerQueue::Kind::Init);
121+
}
87122
player_ready = true;
88123
}
89124

@@ -113,12 +148,24 @@ EgressGateway::~EgressGateway() { stop(); }
113148
bool EgressGateway::start(const EgressOptions& options) {
114149
impl_->options = options;
115150
Impl* impl = impl_.get();
151+
if (!impl->writer_started) {
152+
impl->writer = std::thread([impl] { impl->writer_loop(); });
153+
impl->writer_started = true;
154+
}
116155
impl->begin_play(); // connect + play before accepting the player
117156
return impl->listener.start(
118157
options.rtmp_port,
119158
[impl](roqr::rtmp::ServerSession& s) {
120159
{
121160
std::lock_guard lock(impl->player_mutex);
161+
// A previous player may still be alive (e.g. a stalled one
162+
// that never disconnected): shut its fd down so any writer
163+
// blocked in its send() unblocks, then drop its queued
164+
// frames so the new player isn't fed stale, prior-era media
165+
// ahead of its own init frames.
166+
if (impl->player != nullptr && impl->player != &s)
167+
impl->player->close();
168+
impl->queue.clear();
122169
impl->player = &s;
123170
impl->player_ready = false;
124171
}
@@ -139,9 +186,26 @@ bool EgressGateway::start(const EgressOptions& options) {
139186
}
140187

141188
void EgressGateway::stop() {
142-
impl_->listener.stop();
189+
// 1. Stop the QUIC network thread so on_frame stops enqueuing.
143190
impl_->client.close();
144191
impl_->client.wait_closed(std::chrono::seconds(2));
192+
// 2. Unblock a writer that may be stuck in a blocking send to a stalled
193+
// player: shut down the player's fd so the send returns.
194+
{
195+
std::lock_guard lock(impl_->player_mutex);
196+
if (impl_->player != nullptr) impl_->player->close();
197+
}
198+
// 3. Close the queue and join the writer (it drains, sends fail fast on
199+
// the shut-down fd, then exits).
200+
impl_->queue.close();
201+
if (impl_->writer.joinable()) impl_->writer.join();
202+
impl_->writer_started = false;
203+
// 4. Now no thread touches the player: safe to destroy the sessions.
204+
impl_->listener.stop();
205+
}
206+
207+
uint64_t EgressGateway::frames_dropped() const {
208+
return impl_->queue.dropped();
145209
}
146210

147211
bool EgressGateway::wait_playing(std::chrono::milliseconds timeout) {

gateway/src/player_queue.cpp

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
#include "roqr/gateway/player_queue.hpp"
2+
3+
#include <algorithm>
4+
5+
namespace roqr::gateway {
6+
7+
bool PlayerQueue::push(roqr::rtmp::RtmpMessage msg, Kind kind) {
8+
std::lock_guard lock(mutex_);
9+
if (closed_) return false;
10+
bool dropped_one = false;
11+
if (queue_.size() >= max_) {
12+
auto victim = std::find_if(queue_.begin(), queue_.end(),
13+
[](const Entry& e) {
14+
return e.kind == Kind::Coded;
15+
});
16+
if (victim != queue_.end()) {
17+
queue_.erase(victim);
18+
++dropped_;
19+
dropped_one = true;
20+
} else {
21+
// Only Init entries and still full: drop the incoming rather
22+
// than exceed the bound.
23+
++dropped_;
24+
return false;
25+
}
26+
}
27+
queue_.push_back(Entry{std::move(msg), kind});
28+
cv_.notify_one();
29+
return !dropped_one;
30+
}
31+
32+
std::optional<roqr::rtmp::RtmpMessage> PlayerQueue::pop() {
33+
std::unique_lock lock(mutex_);
34+
cv_.wait(lock, [&] { return !queue_.empty() || closed_; });
35+
if (queue_.empty()) return std::nullopt; // closed and drained
36+
roqr::rtmp::RtmpMessage m = std::move(queue_.front().msg);
37+
queue_.pop_front();
38+
return m;
39+
}
40+
41+
void PlayerQueue::close() {
42+
{
43+
std::lock_guard lock(mutex_);
44+
closed_ = true;
45+
}
46+
cv_.notify_all();
47+
}
48+
49+
size_t PlayerQueue::size() const {
50+
std::lock_guard lock(mutex_);
51+
return queue_.size();
52+
}
53+
54+
uint64_t PlayerQueue::dropped() const {
55+
std::lock_guard lock(mutex_);
56+
return dropped_;
57+
}
58+
59+
void PlayerQueue::clear() {
60+
std::lock_guard lock(mutex_);
61+
queue_.clear();
62+
}
63+
64+
} // namespace roqr::gateway

tests/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ if(ROQR_BUILD_EXAMPLES)
9393
gateway/rtmp_commands_test.cpp
9494
gateway/media_router_test.cpp
9595
gateway/gap_recovery_test.cpp
96+
gateway/player_queue_test.cpp
9697
)
9798
target_link_libraries(roqr-gateway-tests PRIVATE roqr-gateway roqr-relayd-lib Catch2::Catch2WithMain)
9899
catch_discover_tests(roqr-gateway-tests PROPERTIES TIMEOUT 60)
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
#include <catch2/catch_test_macros.hpp>
2+
3+
#include <thread>
4+
5+
#include "roqr/gateway/player_queue.hpp"
6+
7+
using namespace roqr::gateway;
8+
using roqr::rtmp::RtmpMessage;
9+
10+
namespace {
11+
RtmpMessage vid(uint32_t ts) {
12+
RtmpMessage m;
13+
m.type = 9;
14+
m.timestamp = ts;
15+
m.payload = {0x27, 0x01};
16+
return m;
17+
}
18+
RtmpMessage seq_header() {
19+
RtmpMessage m;
20+
m.type = 9;
21+
m.payload = {0x17, 0x00};
22+
return m;
23+
}
24+
} // namespace
25+
26+
TEST_CASE("queue is FIFO under the bound") {
27+
PlayerQueue q(8);
28+
CHECK(q.push(vid(1), PlayerQueue::Kind::Coded));
29+
CHECK(q.push(vid(2), PlayerQueue::Kind::Coded));
30+
CHECK(q.size() == 2);
31+
CHECK(q.pop()->timestamp == 1);
32+
CHECK(q.pop()->timestamp == 2);
33+
}
34+
35+
TEST_CASE("overflow drops the oldest coded frame, stays bounded") {
36+
PlayerQueue q(4);
37+
for (uint32_t i = 0; i < 4; ++i) {
38+
REQUIRE(q.push(vid(i), PlayerQueue::Kind::Coded));
39+
}
40+
// Now full. Pushing more evicts the oldest coded frame each time.
41+
q.push(vid(100), PlayerQueue::Kind::Coded);
42+
q.push(vid(101), PlayerQueue::Kind::Coded);
43+
CHECK(q.size() == 4); // bounded
44+
CHECK(q.dropped() == 2); // two evictions
45+
// The two oldest (ts 0,1) were evicted; front is now ts 2.
46+
CHECK(q.pop()->timestamp == 2);
47+
}
48+
49+
TEST_CASE("sequence headers are never evicted") {
50+
PlayerQueue q(3);
51+
REQUIRE(q.push(seq_header(), PlayerQueue::Kind::Init));
52+
REQUIRE(q.push(vid(1), PlayerQueue::Kind::Coded));
53+
REQUIRE(q.push(vid(2), PlayerQueue::Kind::Coded));
54+
// Full. Two more coded frames evict the coded ones, keeping the Init.
55+
q.push(vid(3), PlayerQueue::Kind::Coded);
56+
q.push(vid(4), PlayerQueue::Kind::Coded);
57+
CHECK(q.size() == 3);
58+
// The Init (seq header) is still at the front.
59+
auto first = q.pop();
60+
REQUIRE(first.has_value());
61+
CHECK(first->payload == std::vector<uint8_t>{0x17, 0x00});
62+
}
63+
64+
TEST_CASE("close unblocks a waiting consumer and drains remaining") {
65+
PlayerQueue q(8);
66+
q.push(vid(1), PlayerQueue::Kind::Coded);
67+
q.close();
68+
CHECK(q.pop()->timestamp == 1); // drains the queued item
69+
CHECK_FALSE(q.pop().has_value()); // then reports closed+empty
70+
71+
PlayerQueue q2(8);
72+
std::thread consumer([&] {
73+
auto m = q2.pop(); // blocks until close()
74+
CHECK_FALSE(m.has_value());
75+
});
76+
q2.close();
77+
consumer.join();
78+
}
79+
80+
TEST_CASE("clear drops all entries without counting them as dropped") {
81+
PlayerQueue q(8);
82+
REQUIRE(q.push(seq_header(), PlayerQueue::Kind::Init));
83+
REQUIRE(q.push(vid(1), PlayerQueue::Kind::Coded));
84+
REQUIRE(q.push(vid(2), PlayerQueue::Kind::Coded));
85+
CHECK(q.size() == 3);
86+
CHECK(q.dropped() == 0);
87+
88+
q.clear();
89+
90+
CHECK(q.size() == 0);
91+
CHECK(q.dropped() == 0); // clear() is not a drop-tracked eviction
92+
93+
// Queue is still usable after clear().
94+
REQUIRE(q.push(vid(3), PlayerQueue::Kind::Coded));
95+
CHECK(q.pop()->timestamp == 3);
96+
}

0 commit comments

Comments
 (0)