@@ -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