Skip to content

Commit 87268e8

Browse files
committed
Add subscriber wait timeout
1 parent fa9a481 commit 87268e8

9 files changed

Lines changed: 203 additions & 8 deletions

File tree

CMakeLists.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ if(OPENMOQ_BUILD_TESTS)
7878
target_link_libraries(openmoq-publisher-packaging-tests PRIVATE openmoq_publisher_lib)
7979
add_test(NAME openmoq-publisher-packaging-tests COMMAND openmoq-publisher-packaging-tests)
8080

81+
add_executable(openmoq-publisher-cli-tests
82+
tests/cli_options_test.cpp
83+
)
84+
target_link_libraries(openmoq-publisher-cli-tests PRIVATE openmoq_publisher_lib)
85+
add_test(NAME openmoq-publisher-cli-tests COMMAND openmoq-publisher-cli-tests)
86+
8187
add_executable(openmoq-publisher-transport-tests
8288
tests/moqt_session_test.cpp
8389
)

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ OPENMOQ_PICOQUIC_TRACE=1 ./build/openmoq-publisher \
187187
--endpoint moqt://interop-relay.cloudflare.mediaoverquic.com:443/moq \
188188
--namespace interop \
189189
--forward 0 \
190+
--timeout 10 \
190191
--paced \
191192
--insecure
192193
```
@@ -197,6 +198,7 @@ Current status as of March 13, 2026:
197198
- `CLIENT_SETUP` succeeds and the client prints the negotiated connection ID to stdout after setup
198199
- `PUBLISH_NAMESPACE` is accepted with `PUBLISH_NAMESPACE_OK`
199200
- with `--forward 0`, the current client waits for inbound `SUBSCRIBE`; relays may consume `SUBSCRIBE_NAMESPACE` themselves and only forward `SUBSCRIBE` to the publisher
201+
- `--timeout <seconds>` controls how long the publisher waits for inbound `SUBSCRIBE` requests before failing the publish attempt
200202
- the Cloudflare endpoints accepted setup and namespace announce in testing, but did not issue subscriptions, so the publish attempt timed out waiting for control-stream data
201203
- with `--forward 1`, `moq-relay.red5.net:8443` now progresses through `PUBLISH_OK` for the catalog and media tracks, after which the client begins sending object streams
202204
- `fb.mvfst.net:9448` now accepts the draft-14 publish flow end-to-end after switching `PUBLISH`, `PUBLISH_OK`, and `PUBLISH_ERROR` control messages to `u16` outer lengths; the current draft-16 flow is still rejected with MOQT application error `3` (`PROTOCOL_VIOLATION`) immediately after setup
@@ -246,6 +248,7 @@ Transport-oriented CLI flags are also present now:
246248
--endpoint localhost:4433 \
247249
--namespace media \
248250
--forward 0 \
251+
--timeout 3 \
249252
--paced \
250253
--insecure
251254
```
@@ -264,6 +267,7 @@ Current status:
264267
- the local picoquic loopback handshake works, including object publication over QUIC streams
265268
- `--namespace` lets you choose the advertised track namespace during transport tests
266269
- `--forward 0|1` selects whether the publisher waits for `SUBSCRIBE` (`0`) or immediately sends `PUBLISH` requests and forwards objects after namespace announce (`1`)
270+
- `--timeout <seconds>` sets the subscriber wait timeout used when the publisher is waiting for `SUBSCRIBE`
267271
- ALPN is selected from the requested draft unless `--alpn` explicitly overrides it
268272
- `--paced` delays media-object sends to match fragment media timestamps instead of sending the whole file as fast as possible; it only has an effect once object transmission begins
269273
- after setup completes, the CLI prints `connection_id=<hex>` to stdout

include/openmoq/publisher/cli_options.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#pragma once
22

33
#include <filesystem>
4+
#include <chrono>
45
#include <optional>
56
#include <string>
67

@@ -20,6 +21,7 @@ struct CliOptions {
2021
bool forward = false;
2122
bool paced = false;
2223
bool dump_plan = false;
24+
std::chrono::seconds subscriber_timeout = std::chrono::seconds(3);
2325
};
2426

2527
CliOptions parse_cli_options(int argc, char** argv);

include/openmoq/publisher/transport/moqt_session.h

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
#include "openmoq/publisher/transport/publisher_transport.h"
55

66
#include <optional>
7+
#include <chrono>
78
#include <span>
89
#include <string>
910
#include <string_view>
@@ -16,7 +17,8 @@ class MoqtSession {
1617
explicit MoqtSession(PublisherTransport& transport,
1718
std::string track_namespace = "media",
1819
bool auto_forward = false,
19-
bool paced = false);
20+
bool paced = false,
21+
std::chrono::seconds subscriber_timeout = std::chrono::seconds(3));
2022

2123
TransportStatus connect(const EndpointConfig& endpoint, const TlsConfig& tls);
2224
TransportStatus publish(const openmoq::publisher::PublishPlan& plan);
@@ -31,6 +33,7 @@ class MoqtSession {
3133
std::string track_namespace_;
3234
bool auto_forward_ = false;
3335
bool paced_ = false;
36+
std::chrono::seconds subscriber_timeout_ = std::chrono::seconds(3);
3437
std::optional<EndpointConfig> endpoint_;
3538
std::uint64_t control_stream_id_ = 0;
3639
std::uint64_t peer_max_request_id_ = 0;

src/cli_options.cpp

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,14 @@ bool parse_forward_flag(std::string_view value) {
5959
throw std::runtime_error("unsupported --forward value: expected 0 or 1");
6060
}
6161

62+
std::chrono::seconds parse_timeout(std::string_view value) {
63+
const int timeout = std::stoi(std::string(value));
64+
if (timeout < 0) {
65+
throw std::runtime_error("subscriber timeout must be zero or greater");
66+
}
67+
return std::chrono::seconds(timeout);
68+
}
69+
6270
} // namespace
6371

6472
CliOptions parse_cli_options(int argc, char** argv) {
@@ -99,6 +107,8 @@ CliOptions parse_cli_options(int argc, char** argv) {
99107
options.track_namespace = std::string(require_value("--namespace"));
100108
} else if (argument == "--forward") {
101109
options.forward = parse_forward_flag(require_value("--forward"));
110+
} else if (argument == "--timeout") {
111+
options.subscriber_timeout = parse_timeout(require_value("--timeout"));
102112
} else if (argument == "--paced") {
103113
options.paced = true;
104114
} else if (argument == "--emit-dir") {
@@ -128,7 +138,8 @@ CliOptions parse_cli_options(int argc, char** argv) {
128138

129139
std::string build_usage(const char* argv0) {
130140
return std::string("Usage: ") + argv0 +
131-
" --input <mp4> [--draft 14|16] [--namespace <value>] [--forward 0|1] [--paced] [--dump-plan] [--emit-dir <dir>]"
141+
" --input <mp4> [--draft 14|16] [--namespace <value>] [--forward 0|1] [--timeout <seconds>]"
142+
" [--paced] [--dump-plan] [--emit-dir <dir>]"
132143
" [--endpoint host:port|moqt://host:port/path] [--alpn value]"
133144
" [--cert file] [--key file] [--ca file] [--insecure]";
134145
}

src/main.cpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ int main(int argc, char** argv) {
3434
endpoint.alpn = default_alpn(options.draft_version);
3535
}
3636
PicoquicClient transport;
37-
MoqtSession session(transport, options.track_namespace, options.forward, options.paced);
37+
MoqtSession session(
38+
transport, options.track_namespace, options.forward, options.paced, options.subscriber_timeout);
3839

3940
TransportStatus status = session.connect(endpoint, options.tls);
4041
if (!status.ok) {

src/transport/moqt_session.cpp

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,7 @@ TransportStatus serve_subscriptions(PublisherTransport& transport,
448448
openmoq::publisher::DraftVersion draft,
449449
std::string_view track_namespace,
450450
bool paced,
451+
std::chrono::milliseconds subscriber_timeout,
451452
std::vector<std::uint8_t>& pending_control_bytes,
452453
bool send_namespace_done = true) {
453454
std::vector<std::uint8_t> buffer = std::move(pending_control_bytes);
@@ -621,7 +622,7 @@ TransportStatus serve_subscriptions(PublisherTransport& transport,
621622
}
622623

623624
std::vector<std::uint8_t> chunk;
624-
const TransportStatus read_status = transport.read_stream(control_stream_id, chunk, fin, std::chrono::seconds(3));
625+
const TransportStatus read_status = transport.read_stream(control_stream_id, chunk, fin, subscriber_timeout);
625626
if (!read_status.ok) {
626627
if (served_any_subscription &&
627628
(read_status.message == "timed out waiting for stream data" ||
@@ -656,6 +657,7 @@ TransportStatus forward_published_tracks(PublisherTransport& transport,
656657
std::uint64_t peer_max_request_id,
657658
std::string_view track_namespace,
658659
bool paced,
660+
std::chrono::milliseconds subscriber_timeout,
659661
std::vector<std::uint8_t>& pending_control_bytes) {
660662
std::map<std::string, std::uint64_t> request_id_by_track;
661663
std::map<std::string, PublishedTrack> tracks_by_name;
@@ -779,6 +781,7 @@ TransportStatus forward_published_tracks(PublisherTransport& transport,
779781
plan.draft.version,
780782
track_namespace,
781783
paced,
784+
subscriber_timeout,
782785
pending_control_bytes,
783786
false);
784787
if (!status.ok) {
@@ -797,8 +800,16 @@ TransportStatus forward_published_tracks(PublisherTransport& transport,
797800

798801
} // namespace
799802

800-
MoqtSession::MoqtSession(PublisherTransport& transport, std::string track_namespace, bool auto_forward, bool paced)
801-
: transport_(transport), track_namespace_(std::move(track_namespace)), auto_forward_(auto_forward), paced_(paced) {}
803+
MoqtSession::MoqtSession(PublisherTransport& transport,
804+
std::string track_namespace,
805+
bool auto_forward,
806+
bool paced,
807+
std::chrono::seconds subscriber_timeout)
808+
: transport_(transport),
809+
track_namespace_(std::move(track_namespace)),
810+
auto_forward_(auto_forward),
811+
paced_(paced),
812+
subscriber_timeout_(subscriber_timeout) {}
802813

803814
TransportStatus MoqtSession::connect(const EndpointConfig& endpoint, const TlsConfig& tls) {
804815
endpoint_ = endpoint;
@@ -856,7 +867,15 @@ TransportStatus MoqtSession::publish(const openmoq::publisher::PublishPlan& plan
856867

857868
if (auto_forward_) {
858869
return forward_published_tracks(
859-
transport_, control_stream_id_, plan, tracks, peer_max_request_id_, track_namespace_, paced_, pending_control_bytes_);
870+
transport_,
871+
control_stream_id_,
872+
plan,
873+
tracks,
874+
peer_max_request_id_,
875+
track_namespace_,
876+
paced_,
877+
subscriber_timeout_,
878+
pending_control_bytes_);
860879
}
861880

862881
std::cerr << "[moqt-session] awaiting SUBSCRIBE for tracks:";
@@ -872,6 +891,7 @@ TransportStatus MoqtSession::publish(const openmoq::publisher::PublishPlan& plan
872891
plan.draft.version,
873892
track_namespace_,
874893
paced_,
894+
subscriber_timeout_,
875895
pending_control_bytes_);
876896
}
877897

tests/cli_options_test.cpp

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
#include "openmoq/publisher/cli_options.h"
2+
3+
#include <chrono>
4+
#include <iostream>
5+
#include <stdexcept>
6+
#include <string>
7+
#include <vector>
8+
9+
namespace {
10+
11+
using openmoq::publisher::CliOptions;
12+
using openmoq::publisher::parse_cli_options;
13+
14+
bool expect(bool condition, const std::string& message) {
15+
if (!condition) {
16+
std::cerr << "FAIL: " << message << '\n';
17+
return false;
18+
}
19+
return true;
20+
}
21+
22+
CliOptions parse(std::vector<std::string> args) {
23+
std::vector<char*> argv;
24+
argv.reserve(args.size());
25+
for (auto& arg : args) {
26+
argv.push_back(arg.data());
27+
}
28+
return parse_cli_options(static_cast<int>(argv.size()), argv.data());
29+
}
30+
31+
} // namespace
32+
33+
int main() {
34+
bool ok = true;
35+
36+
{
37+
const CliOptions options = parse({"openmoq-publisher", "--input", "sample.mp4"});
38+
ok &= expect(options.subscriber_timeout == std::chrono::seconds(3),
39+
"expected default subscriber timeout to remain 3 seconds");
40+
}
41+
42+
{
43+
const CliOptions options =
44+
parse({"openmoq-publisher", "--input", "sample.mp4", "--timeout", "9", "--forward", "0"});
45+
ok &= expect(options.subscriber_timeout == std::chrono::seconds(9),
46+
"expected --timeout to override subscriber timeout");
47+
}
48+
49+
{
50+
bool threw = false;
51+
try {
52+
static_cast<void>(parse({"openmoq-publisher", "--input", "sample.mp4", "--timeout", "-1"}));
53+
} catch (const std::runtime_error& error) {
54+
threw = std::string(error.what()) == "subscriber timeout must be zero or greater";
55+
}
56+
ok &= expect(threw, "expected negative --timeout to be rejected");
57+
}
58+
59+
return ok ? 0 : 1;
60+
}

tests/moqt_session_test.cpp

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
#include <chrono>
77
#include <cstdint>
8+
#include <functional>
89
#include <iomanip>
910
#include <iostream>
1011
#include <map>
@@ -88,7 +89,11 @@ struct MockTransport final : PublisherTransport {
8889
std::vector<std::uint8_t>& bytes,
8990
bool& fin,
9091
std::chrono::milliseconds timeout) override {
91-
static_cast<void>(timeout);
92+
read_timeouts.push_back(timeout);
93+
++read_count;
94+
if (on_read) {
95+
on_read(*this, stream_id);
96+
}
9297
const auto it = reads.find(stream_id);
9398
if (it == reads.end()) {
9499
return TransportStatus::failure("no queued read for stream");
@@ -124,8 +129,11 @@ struct MockTransport final : PublisherTransport {
124129
std::uint64_t next_bidi_ = 0;
125130
std::uint64_t next_uni_ = 2;
126131
std::uint64_t last_close_code = 0;
132+
std::size_t read_count = 0;
127133
std::vector<WriteEvent> writes;
134+
std::vector<std::chrono::milliseconds> read_timeouts;
128135
std::map<std::uint64_t, std::vector<std::vector<std::uint8_t>>> reads;
136+
std::function<void(const MockTransport&, std::uint64_t)> on_read;
129137
};
130138

131139
void append_be16(std::vector<std::uint8_t>& out, std::uint16_t value) {
@@ -554,6 +562,64 @@ int main() {
554562
ok &= expect(transport.writes[8].bytes == std::vector<std::uint8_t>({0x09, 0x00, 0x09, 0x01, 0x07, 0x69, 0x6e,
555563
0x74, 0x65, 0x72, 0x6f, 0x70}),
556564
"expected draft-14 PUBLISH_NAMESPACE_DONE to contain the configured track namespace");
565+
ok &= expect(!transport.read_timeouts.empty() && transport.read_timeouts.back() == std::chrono::seconds(3),
566+
"expected default subscriber wait timeout to be 3 seconds");
567+
}
568+
569+
{
570+
MockTransport transport;
571+
transport.reads[0].push_back(encode_server_setup_message({
572+
.draft = DraftVersion::kDraft14,
573+
.max_request_id = 8,
574+
}));
575+
transport.reads[0].push_back(encode_publish_namespace_ok_message(DraftVersion::kDraft14, 0));
576+
transport.reads[0].push_back(encode_subscribe_message(2, kTestTrackNamespace, "catalog", 0));
577+
transport.reads[0].push_back(encode_subscribe_message(4, kTestTrackNamespace, "vide_1", 0));
578+
579+
bool saw_media_before_media_subscribe = false;
580+
transport.on_read = [&](const MockTransport& current, std::uint64_t stream_id) {
581+
if (stream_id != 0 || current.read_count != 4) {
582+
return;
583+
}
584+
585+
for (const auto& write : current.writes) {
586+
if (write.stream_id != 6) {
587+
continue;
588+
}
589+
std::uint64_t stream_type = 0;
590+
std::uint64_t track_alias = 0;
591+
std::uint64_t group_id = 0;
592+
std::uint64_t subgroup_id = 0;
593+
std::uint64_t publisher_priority = 0;
594+
std::uint64_t object_id_delta = 0;
595+
std::uint64_t payload_length = 0;
596+
std::vector<std::uint8_t> payload;
597+
if (decode_object_stream_fields(write.bytes,
598+
stream_type,
599+
track_alias,
600+
group_id,
601+
subgroup_id,
602+
publisher_priority,
603+
object_id_delta,
604+
payload_length,
605+
payload) &&
606+
track_alias == 1) {
607+
saw_media_before_media_subscribe = true;
608+
}
609+
}
610+
};
611+
612+
MoqtSession session(transport, std::string(kTestTrackNamespace), false);
613+
614+
auto status = session.connect(endpoint, tls);
615+
ok &= expect(status.ok, "expected delayed-subscriber session connect to succeed");
616+
617+
const PublishPlan materialized =
618+
materialize_publish_plan(make_span_backed_plan(DraftVersion::kDraft14), source_bytes);
619+
status = session.publish(materialized);
620+
ok &= expect(status.ok, "expected publish to succeed with delayed media subscriber");
621+
ok &= expect(!saw_media_before_media_subscribe,
622+
"expected forward=0 to avoid sending media before the media subscriber arrives");
557623
}
558624

559625
{
@@ -830,5 +896,27 @@ int main() {
830896
ok &= expect(status.ok, "expected close to succeed");
831897
ok &= expect(close_transport.last_close_code == 7, "expected close code to propagate");
832898

899+
{
900+
MockTransport timeout_transport;
901+
timeout_transport.reads[0].push_back(encode_server_setup_message({
902+
.draft = DraftVersion::kDraft14,
903+
.max_request_id = 8,
904+
}));
905+
queue_subscribe_requests(timeout_transport,
906+
DraftVersion::kDraft14,
907+
kTestTrackNamespace,
908+
{{2, "catalog"}, {4, "vide_1"}});
909+
MoqtSession timeout_session(
910+
timeout_transport, std::string(kTestTrackNamespace), false, false, std::chrono::seconds(11));
911+
status = timeout_session.connect(endpoint, tls);
912+
ok &= expect(status.ok, "expected custom-timeout session connect to succeed");
913+
status = timeout_session.publish(
914+
materialize_publish_plan(make_span_backed_plan(DraftVersion::kDraft14), source_bytes));
915+
ok &= expect(status.ok, "expected publish to succeed with custom subscriber timeout");
916+
ok &= expect(!timeout_transport.read_timeouts.empty() &&
917+
timeout_transport.read_timeouts.back() == std::chrono::seconds(11),
918+
"expected custom subscriber timeout to reach transport reads");
919+
}
920+
833921
return ok ? 0 : 1;
834922
}

0 commit comments

Comments
 (0)