Skip to content

Commit deea1b0

Browse files
afrindclaude
andcommitted
relay: make subscribers wait for an in-flight local forwarder setup
A subscriber that arrived while another subscriber's upstream subscribe was still in flight joined the local forwarder right away: it got a SUBSCRIBE_OK with no largest object, and it kept its subscription even when the upstream subscribe came back with an error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f67ed45 commit deea1b0

3 files changed

Lines changed: 246 additions & 16 deletions

File tree

src/MoqxRelay.cpp

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1188,15 +1188,19 @@ folly::coro::Task<void> MoqxRelay::addSubscriberAndPublishViaLocalForwarder(
11881188
co_await awaitPublishReply(localFwd, std::move(p->subscriber), std::move(p->reply));
11891189
}
11901190

1191+
LocalForwarderRegistry& MoqxRelay::localRegistry() {
1192+
if (!tlForwarders_.get()) {
1193+
tlForwarders_.reset(new LocalForwarderRegistry());
1194+
}
1195+
return *tlForwarders_;
1196+
}
1197+
11911198
// Slow-path local-forwarder bootstrap shared by the publish and subscribe LF paths.
11921199
// Callers handle the fast path and install the PendingForwarderCallback themselves,
11931200
// because the timing differs.
11941201
MoqxRelay::LocalForwarderBootstrap
11951202
MoqxRelay::acquireLocalForwarder(const FullTrackName& ftn, const InitialTrackState& initial) {
1196-
if (!tlForwarders_.get()) {
1197-
tlForwarders_.reset(new LocalForwarderRegistry());
1198-
}
1199-
auto* localReg = tlForwarders_.get();
1203+
auto* localReg = &localRegistry();
12001204
auto joined = localReg->join(ftn, [&] { return std::make_shared<MoQForwarder>(ftn); });
12011205
if (auto* claim = std::get_if<LocalForwarderRegistry::Claim>(&joined)) {
12021206
auto localFwd = claim->forwarder();
@@ -1954,22 +1958,45 @@ folly::coro::Task<Publisher::SubscribeResult> MoqxRelay::subscribeFromSubscriber
19541958
) {
19551959
const auto& ftn = subReq.fullTrackName;
19561960

1957-
// Join before the relay hop: serializes same-iothread races. No initial state yet —
1958-
// the upstream OK has not arrived, so an attacher can read a too-early largest here.
1959-
auto [localFwd, isNew, localReg] = acquireLocalForwarder(ftn, InitialTrackState{});
1961+
// Join before the relay hop: serializes same-iothread races.
1962+
auto* localReg = &localRegistry();
1963+
auto joined = localReg->join(ftn, [&] { return std::make_shared<MoQForwarder>(ftn); });
19601964

19611965
consumer =
19621966
wrapWithTrackStats(trackStats_, ftn, std::move(consumer), stats::TrackDirection::Egress);
19631967

1964-
if (!isNew) {
1965-
if (auto err = checkRangeNotInPast(*localFwd, subReq)) {
1968+
if (auto* pending = std::get_if<LocalForwarderRegistry::Pending>(&joined)) {
1969+
// Another subscriber owns setup. Wait for it.
1970+
co_await folly::coro::co_awaitTry(std::move(pending->ready));
1971+
// Re-resolve: the entry may have been displaced while we were waiting. A failed setup
1972+
// fails this subscriber too.
1973+
auto ready = localReg->getIfReady(ftn);
1974+
if (!ready) {
1975+
co_return folly::makeUnexpected(SubscribeError{
1976+
subReq.requestID,
1977+
SubscribeErrorCode::INTERNAL_ERROR,
1978+
"local forwarder setup failed"
1979+
});
1980+
}
1981+
joined = LocalForwarderRegistry::Ready{std::move(ready)};
1982+
}
1983+
1984+
if (auto* ready = std::get_if<LocalForwarderRegistry::Ready>(&joined)) {
1985+
auto& readyFwd = *ready->forwarder;
1986+
if (auto err = checkRangeNotInPast(readyFwd, subReq)) {
19661987
co_return folly::makeUnexpected(std::move(*err));
19671988
}
1968-
co_return attachSubscriber(*localFwd, std::move(session), subReq, std::move(consumer));
1989+
co_return attachSubscriber(readyFwd, std::move(session), subReq, std::move(consumer));
19691990
}
19701991

1971-
// isNew=true: this thread owns setup. Install PendingForwarderCallback first so
1972-
// forwardChanged/newGroupRequested/onEmpty events during setup are captured for replay.
1992+
// This thread owns setup. The claim stays open until the tail, so same-thread attachers
1993+
// wait on it also. The Claim destructor will clear the entry and wake subscribers
1994+
// waiting for it unless markReady() is called.
1995+
auto claim = std::move(std::get<LocalForwarderRegistry::Claim>(joined));
1996+
auto localFwd = claim.forwarder();
1997+
1998+
// Install PendingForwarderCallback first so forwardChanged/newGroupRequested/onEmpty
1999+
// events during setup are captured for replay.
19732000
auto pendingCb = std::make_shared<PendingForwarderCallback>(localReg, ftn);
19742001
localFwd->setCallback(pendingCb);
19752002

@@ -1981,7 +2008,6 @@ folly::coro::Task<Publisher::SubscribeResult> MoqxRelay::subscribeFromSubscriber
19812008
// when addChannelSubscriber runs on publisherExec, so forward flag is right from the start.
19822009
auto sub = localFwd->addSubscriber(session, subReq, std::move(consumer));
19832010
if (!sub) {
1984-
localReg->remove(ftn, localFwd.get());
19852011
co_return folly::makeUnexpected(makeAddSubscriberError(subReq.requestID));
19862012
}
19872013
bool forward = (localFwd->numForwardingSubscribers() > 0);
@@ -2026,18 +2052,21 @@ folly::coro::Task<Publisher::SubscribeResult> MoqxRelay::subscribeFromSubscriber
20262052
0,
20272053
attach.error->error().reasonPhrase
20282054
});
2029-
localReg->remove(ftn, localFwd.get());
20302055
co_return std::move(*attach.error);
20312056
}
20322057

2058+
// Only the first subscriber has an upstream OK. A subsequent one leaves this empty and
2059+
// marks the entry ready with no largest (a bug fixed in a subsequent commit)
2060+
InitialTrackState initial;
20332061
if (attach.upstreamOk) {
2034-
InitialTrackState initial{attach.upstreamOk->largest, attach.upstreamOk->extensions};
2035-
initial.applyTo(*localFwd);
2062+
initial = InitialTrackState{attach.upstreamOk->largest, attach.upstreamOk->extensions};
20362063
// Also on the subscriber, so a post-SUBSCRIBE_OK joining fetch resolves.
20372064
initial.applyTo(*sub);
20382065
}
2066+
claim.setInitialState(initial);
20392067
replayPendingFowarderEvents(localFwd.get(), attach.finalCallback, *pendingCb, forward);
20402068
localFwd->tryProcessNewGroupRequest(subReq.params);
2069+
claim.markReady();
20412070
co_return sub;
20422071
}
20432072

src/MoqxRelay.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,9 @@ class MoqxRelay : public moxygen::Publisher,
333333
folly::Executor* subscriberExec
334334
);
335335

336+
// This thread's registry, created on first use.
337+
LocalForwarderRegistry& localRegistry();
338+
336339
struct LocalForwarderBootstrap {
337340
std::shared_ptr<moxygen::MoQForwarder> localFwd;
338341
bool isNew{false};

test/MoqxRelaySubscribeTests.cpp

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,4 +204,202 @@ TEST_P(MoQRelayTest, FirstSubscriberViaUpstreamSubscribeReceivesData) {
204204
driveIfMultiThread();
205205
}
206206

207+
// Regression: a second subscriber that arrives while a first subscriber's upstream
208+
// SUBSCRIBE is still in flight must observe the upstream-seeded largest. In
209+
// LocalForwarderMT the second takes the acquireLocalForwarder isNew=false fast path
210+
// and, without a readiness gate, reads the not-yet-seeded localFwd largest (empty).
211+
// ST/MT wait on the registry promise and were already correct, so this passes in
212+
// every mode and regresses only LF.
213+
TEST_P(MoQRelayTest, SubsequentSubscriberWaitsForUpstreamLargestSeeding) {
214+
auto publisherSession = createMockSession();
215+
auto subSession1 = createMockSession();
216+
auto subSession2 = createMockSession();
217+
218+
doPublishNamespace(publisherSession, kTestNamespace);
219+
220+
const AbsoluteLocation kLargest{3, 0};
221+
SubscribeOk upstreamOk;
222+
upstreamOk.requestID = RequestID(1);
223+
upstreamOk.trackAlias = TrackAlias(1);
224+
upstreamOk.expires = std::chrono::milliseconds(0);
225+
upstreamOk.groupOrder = GroupOrder::OldestFirst;
226+
upstreamOk.largest = kLargest;
227+
228+
// Hold the upstream SUBSCRIBE in flight so the second subscriber races in while the
229+
// first subscriber's largest is still unseeded.
230+
folly::coro::Baton upstreamGate;
231+
std::atomic<bool> upstreamSubscribeCalled{false};
232+
std::shared_ptr<TrackConsumer> upstreamConsumer;
233+
EXPECT_CALL(*publisherSession, subscribe(_, _))
234+
.WillOnce(
235+
[&](const SubscribeRequest&, std::shared_ptr<TrackConsumer> consumer
236+
) -> folly::coro::Task<Publisher::SubscribeResult> {
237+
upstreamConsumer = std::move(consumer);
238+
upstreamSubscribeCalled.store(true);
239+
co_await upstreamGate;
240+
auto handle = std::make_shared<NiceMock<MockSubscriptionHandle>>(upstreamOk);
241+
co_return folly::Expected<std::shared_ptr<SubscriptionHandle>, SubscribeError>(handle);
242+
}
243+
);
244+
245+
// driveUntil caps SingleThread at one loopOnce; pump explicitly so the synchronous
246+
// cascades complete in every mode.
247+
auto pump = [&](auto pred) {
248+
for (int i = 0; i < 1000 && !pred(); ++i) {
249+
exec_->drive();
250+
}
251+
return pred();
252+
};
253+
auto launchSubscribe = [&](std::shared_ptr<MoQSession> session,
254+
std::shared_ptr<TrackConsumer> consumer,
255+
RequestID requestID,
256+
std::shared_ptr<std::optional<Publisher::SubscribeResult>> out) {
257+
withSessionContext(session, [&]() {
258+
SubscribeRequest sub;
259+
sub.fullTrackName = kTestTrackName;
260+
sub.requestID = requestID;
261+
sub.locType = LocationType::LargestObject;
262+
auto task = publisherInterface()->subscribe(std::move(sub), std::move(consumer));
263+
co_withExecutor(
264+
static_cast<folly::DrivableExecutor*>(exec_.get()),
265+
folly::coro::co_invoke([t = std::move(task), out]() mutable -> folly::coro::Task<void> {
266+
*out = co_await std::move(t);
267+
})
268+
).start();
269+
});
270+
};
271+
272+
// First subscriber: creates the shared localFwd in acquireLocalForwarder, then
273+
// suspends inside the gated upstream SUBSCRIBE.
274+
auto firstResult = std::make_shared<std::optional<Publisher::SubscribeResult>>();
275+
launchSubscribe(subSession1, createMockConsumer(), RequestID(0), firstResult);
276+
ASSERT_TRUE(pump([&] { return upstreamSubscribeCalled.load(); }))
277+
<< "relay should issue an upstream subscribe and suspend in it";
278+
279+
// Second subscriber, while the first's seeding is still pending. Drive it to its
280+
// steady state (LF without the gate attaches immediately; ST/MT/LF-with-gate wait)
281+
// before releasing the upstream OK, so a captured result reflects the race.
282+
auto secondResult = std::make_shared<std::optional<Publisher::SubscribeResult>>();
283+
launchSubscribe(subSession2, createMockConsumer(), RequestID(2), secondResult);
284+
for (int i = 0; i < 200; ++i) {
285+
exec_->drive();
286+
}
287+
288+
upstreamGate.post();
289+
ASSERT_TRUE(pump([&] { return firstResult->has_value() && secondResult->has_value(); }));
290+
291+
ASSERT_TRUE(firstResult->value().hasValue());
292+
EXPECT_EQ(firstResult->value().value()->subscribeOk().largest, kLargest);
293+
ASSERT_TRUE(secondResult->value().hasValue());
294+
EXPECT_EQ(secondResult->value().value()->subscribeOk().largest, kLargest)
295+
<< "subsequent subscriber must observe the upstream-seeded largest, not a "
296+
"pre-seeding value";
297+
298+
// Track the handles so cleanupMockSession tears down the subscriptions (else the
299+
// held consumers/sessions leak as unverified mocks at exit).
300+
getOrCreateMockState(subSession1)->subscribeHandles.push_back(firstResult->value().value());
301+
getOrCreateMockState(subSession2)->subscribeHandles.push_back(secondResult->value().value());
302+
303+
removeSession(publisherSession);
304+
removeSession(subSession1);
305+
removeSession(subSession2);
306+
driveIfMultiThread();
307+
}
308+
309+
// Regression: when the first subscriber's upstream SUBSCRIBE *fails*, a second
310+
// subscriber that raced in behind it must fail too. In LocalForwarderMT the second took
311+
// the isNew=false path and awaited the ready gate, which the first fulfills with
312+
// setValue() on every exit — including failure. The second then attached to a localFwd
313+
// that setup had already removed from the registry and that has no upstream, so it got a
314+
// SUBSCRIBE_OK for a track that can never deliver an object.
315+
TEST_P(MoQRelayTest, SubsequentSubscriberFailsWhenUpstreamSubscribeFails) {
316+
auto publisherSession = createMockSession();
317+
auto subSession1 = createMockSession();
318+
auto subSession2 = createMockSession();
319+
320+
doPublishNamespace(publisherSession, kTestNamespace);
321+
322+
// Hold the upstream SUBSCRIBE in flight so the second subscriber races in, then reject
323+
// it — the first subscriber's setup fails after the second is already waiting.
324+
folly::coro::Baton upstreamGate;
325+
std::atomic<bool> upstreamSubscribeCalled{false};
326+
EXPECT_CALL(*publisherSession, subscribe(_, _))
327+
.WillOnce(
328+
[&](const SubscribeRequest&,
329+
std::shared_ptr<TrackConsumer>) -> folly::coro::Task<Publisher::SubscribeResult> {
330+
upstreamSubscribeCalled.store(true);
331+
co_await upstreamGate;
332+
co_return folly::makeUnexpected(SubscribeError{
333+
RequestID(0),
334+
SubscribeErrorCode::INTERNAL_ERROR,
335+
"upstream rejected"
336+
});
337+
}
338+
);
339+
340+
auto pump = [&](auto pred) {
341+
for (int i = 0; i < 1000 && !pred(); ++i) {
342+
exec_->drive();
343+
}
344+
return pred();
345+
};
346+
auto launchSubscribe = [&](std::shared_ptr<MoQSession> session,
347+
std::shared_ptr<TrackConsumer> consumer,
348+
RequestID requestID,
349+
std::shared_ptr<std::optional<Publisher::SubscribeResult>> out) {
350+
withSessionContext(session, [&]() {
351+
SubscribeRequest sub;
352+
sub.fullTrackName = kTestTrackName;
353+
sub.requestID = requestID;
354+
sub.locType = LocationType::LargestObject;
355+
auto task = publisherInterface()->subscribe(std::move(sub), std::move(consumer));
356+
// ST/MT signal first-subscriber failure by throwing out of the SubsequentSubscriber
357+
// task; normalize to an error result so both shapes are comparable.
358+
co_withExecutor(
359+
static_cast<folly::DrivableExecutor*>(exec_.get()),
360+
folly::coro::co_invoke([t = std::move(task), out]() mutable -> folly::coro::Task<void> {
361+
try {
362+
*out = co_await std::move(t);
363+
} catch (const std::exception& e) {
364+
*out = folly::makeUnexpected(
365+
SubscribeError{RequestID(0), SubscribeErrorCode::INTERNAL_ERROR, e.what()}
366+
);
367+
}
368+
})
369+
).start();
370+
});
371+
};
372+
373+
auto firstResult = std::make_shared<std::optional<Publisher::SubscribeResult>>();
374+
launchSubscribe(subSession1, createMockConsumer(), RequestID(0), firstResult);
375+
ASSERT_TRUE(pump([&] { return upstreamSubscribeCalled.load(); }))
376+
<< "relay should issue an upstream subscribe and suspend in it";
377+
378+
// Second subscriber, parked on the ready gate while the first is still in flight.
379+
auto secondResult = std::make_shared<std::optional<Publisher::SubscribeResult>>();
380+
launchSubscribe(subSession2, createMockConsumer(), RequestID(2), secondResult);
381+
for (int i = 0; i < 200; ++i) {
382+
exec_->drive();
383+
}
384+
385+
upstreamGate.post();
386+
ASSERT_TRUE(pump([&] { return firstResult->has_value() && secondResult->has_value(); }));
387+
388+
EXPECT_FALSE(firstResult->value().hasValue()) << "upstream rejected the subscribe";
389+
EXPECT_FALSE(secondResult->value().hasValue())
390+
<< "a subscriber released by a FAILED setup must get an error, not a SUBSCRIBE_OK "
391+
"for a forwarder with no upstream";
392+
393+
if (firstResult->value().hasValue()) {
394+
getOrCreateMockState(subSession1)->subscribeHandles.push_back(firstResult->value().value());
395+
}
396+
if (secondResult->value().hasValue()) {
397+
getOrCreateMockState(subSession2)->subscribeHandles.push_back(secondResult->value().value());
398+
}
399+
400+
removeSession(publisherSession);
401+
removeSession(subSession1);
402+
removeSession(subSession2);
403+
driveIfMultiThread();
404+
}
207405
} // namespace openmoq::moqx::test

0 commit comments

Comments
 (0)