@@ -271,6 +271,134 @@ TEST(HedgedObjectReadSourceTest,
271271 primary_closed->get_future ().get ();
272272}
273273
274+ TEST (HedgedObjectReadSourceTest, HedgeOpenFailureReleasesSlot) {
275+ // Verify that if a hedge attempt fails during stream opening (factory()
276+ // error), the hedge concurrency slot is released via RAII (SlotGuard) and is
277+ // not leaked.
278+ auto unblock_primary = std::make_shared<std::promise<void >>();
279+ auto calls = std::make_shared<std::atomic<int >>(0 );
280+ auto factory = [unblock_primary,
281+ calls]() -> StatusOr<std::unique_ptr<ObjectReadSource>> {
282+ int call_count = ++*calls;
283+ if (call_count == 1 ) {
284+ // Primary attempt: stalls until unblocked.
285+ auto mock = std::make_unique<MockObjectReadSource>();
286+ EXPECT_CALL (*mock, Read)
287+ .WillOnce ([unblock_primary](char * buf, std::size_t ) {
288+ unblock_primary->get_future ().get ();
289+ std::string const payload = " primary" ;
290+ std::copy (payload.begin (), payload.end (), buf);
291+ return MakeReadResult (payload);
292+ });
293+ return std::unique_ptr<ObjectReadSource>(std::move (mock));
294+ }
295+ // Hedge attempt: fails to open.
296+ return Status (StatusCode::kUnavailable , " open failed" );
297+ };
298+
299+ auto hedge_pool = std::make_shared<HedgingThreadPool>(
300+ /* max_threads=*/ 2 , /* rate_limit=*/ 0.0 , /* capacity=*/ 0.0 ,
301+ /* max_concurrent=*/ 1 );
302+
303+ HedgedObjectReadSource source (MakeUnlimitedReadPool (), hedge_pool, factory,
304+ std::chrono::milliseconds (1 ),
305+ /* max_hedges=*/ 1 , kUnlimitedBuffer );
306+
307+ std::vector<char > buffer (100 );
308+ std::thread unblocker ([unblock_primary] {
309+ std::this_thread::sleep_for (std::chrono::milliseconds (30 ));
310+ unblock_primary->set_value ();
311+ });
312+
313+ auto result = source.Read (buffer.data (), buffer.size ());
314+ unblocker.join ();
315+
316+ ASSERT_THAT (result, IsOk ());
317+ EXPECT_THAT (std::string (buffer.data (), result->bytes_received ),
318+ Eq (" primary" ));
319+ EXPECT_THAT (calls->load (), Eq (2 ));
320+
321+ // If the slot leaked on open failure, TryAcquireHedgeToken would fail because
322+ // max_concurrent is 1.
323+ EXPECT_TRUE (hedge_pool->TryAcquireHedgeToken ());
324+ hedge_pool->ReleaseHedgeSlot ();
325+ }
326+
327+ TEST (HedgedObjectReadSourceTest, ZeroDelayBacksOffOnHedgeTokenExhaustion) {
328+ // Verify that when delay_ == 0ms and TryAcquireHedgeToken() returns false,
329+ // the hedging loop backs off instead of busy-spinning, allowing the primary
330+ // read to complete normally.
331+ auto unblock_primary = std::make_shared<std::promise<void >>();
332+ auto calls = std::make_shared<std::atomic<int >>(0 );
333+ auto factory = [unblock_primary,
334+ calls]() -> StatusOr<std::unique_ptr<ObjectReadSource>> {
335+ ++*calls;
336+ auto mock = std::make_unique<MockObjectReadSource>();
337+ EXPECT_CALL (*mock, Read)
338+ .WillOnce ([unblock_primary](char * buf, std::size_t ) {
339+ unblock_primary->get_future ().get ();
340+ std::string const payload = " primary_data" ;
341+ std::copy (payload.begin (), payload.end (), buf);
342+ return MakeReadResult (payload);
343+ });
344+ return std::unique_ptr<ObjectReadSource>(std::move (mock));
345+ };
346+
347+ auto hedge_pool = std::make_shared<HedgingThreadPool>(
348+ /* max_threads=*/ 1 , /* rate_limit=*/ 0.0 , /* capacity=*/ 0.0 ,
349+ /* max_concurrent=*/ 1 );
350+ // Exhaust all hedge slots so TryAcquireHedgeToken fails.
351+ ASSERT_TRUE (hedge_pool->TryAcquireHedgeToken ());
352+
353+ HedgedObjectReadSource source (MakeUnlimitedReadPool (), hedge_pool, factory,
354+ std::chrono::milliseconds (0 ),
355+ /* max_hedges=*/ 2 , kUnlimitedBuffer );
356+
357+ std::vector<char > buffer (100 );
358+ std::thread unblocker ([unblock_primary] {
359+ std::this_thread::sleep_for (std::chrono::milliseconds (30 ));
360+ unblock_primary->set_value ();
361+ });
362+
363+ auto result = source.Read (buffer.data (), buffer.size ());
364+ unblocker.join ();
365+
366+ ASSERT_THAT (result, IsOk ());
367+ EXPECT_THAT (std::string (buffer.data (), result->bytes_received ),
368+ Eq (" primary_data" ));
369+ EXPECT_THAT (calls->load (), Eq (1 ));
370+
371+ hedge_pool->ReleaseHedgeSlot ();
372+ }
373+
374+ TEST (HedgedObjectReadSourceTest, NonPositiveMaxHedgesDoesNotHedge) {
375+ // Verify that negative or zero max_hedges values defensively result in 0
376+ // hedge attempts, running only the primary attempt.
377+ auto calls = std::make_shared<std::atomic<int >>(0 );
378+ auto factory = [calls]() -> StatusOr<std::unique_ptr<ObjectReadSource>> {
379+ ++*calls;
380+ auto mock = std::make_unique<MockObjectReadSource>();
381+ EXPECT_CALL (*mock, Read).WillOnce ([](char * buf, std::size_t ) {
382+ std::string const payload = " primary_only" ;
383+ std::copy (payload.begin (), payload.end (), buf);
384+ return MakeReadResult (payload);
385+ });
386+ return std::unique_ptr<ObjectReadSource>(std::move (mock));
387+ };
388+
389+ HedgedObjectReadSource source (MakeUnlimitedReadPool (),
390+ MakeUnlimitedHedgePool (), factory,
391+ std::chrono::milliseconds (0 ),
392+ /* max_hedges=*/ -1 , kUnlimitedBuffer );
393+
394+ std::vector<char > buffer (100 );
395+ auto result = source.Read (buffer.data (), buffer.size ());
396+ ASSERT_THAT (result, IsOk ());
397+ EXPECT_THAT (std::string (buffer.data (), result->bytes_received ),
398+ Eq (" primary_only" ));
399+ EXPECT_THAT (calls->load (), Eq (1 ));
400+ }
401+
274402TEST (HedgedObjectReadSourceTest, PrimaryOpenErrorPropagates) {
275403 auto factory = []() -> StatusOr<std::unique_ptr<ObjectReadSource>> {
276404 return Status (StatusCode::kPermissionDenied , " uh-oh" );
0 commit comments