Skip to content

Commit c8deb2b

Browse files
committed
fix(pubsub): avoid deadlock by ensuring lease refresh timer runs on cq thread
1 parent d799c3f commit c8deb2b

2 files changed

Lines changed: 93 additions & 11 deletions

File tree

google/cloud/pubsub/internal/subscription_lease_management.cc

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,9 +144,23 @@ void SubscriptionLeaseManagement::StartRefreshTimer(
144144
shutdown_manager_->StartOperation(__func__, "OnRefreshTimer", [&] {
145145
using TimerFuture = future<StatusOr<std::chrono::system_clock::time_point>>;
146146
if (refresh_timer_.valid()) refresh_timer_.cancel();
147-
refresh_timer_ =
148-
cq_.MakeDeadlineTimer(deadline).then([weak](TimerFuture tp) {
149-
if (auto self = weak.lock()) self->OnRefreshTimer(!tp.get());
147+
// The caller holds mu_ (the unnamed unique_lock parameter) for the full
148+
// duration of this function. If the timer future is already satisfied
149+
// when .then() attaches its continuation -- a deadline in the past
150+
// (RefreshMessageLeases can compute extensions as small as 1s, and
151+
// kAckDeadlineSlack then puts the deadline before now), or a CQ thread
152+
// winning the satisfaction race -- the continuation runs INLINE on this
153+
// thread, re-enters OnRefreshTimer -> RefreshMessageLeases, and
154+
// self-deadlocks re-acquiring the non-recursive mu_. Every other path
155+
// (AckMessage, OnRead, Shutdown) then blocks behind mu_ and the
156+
// subscription freezes until the process restarts. Dispatch the
157+
// continuation through the CompletionQueue so OnRefreshTimer always runs
158+
// on a CQ thread with no locks held.
159+
refresh_timer_ = cq_.MakeDeadlineTimer(deadline).then(
160+
[weak = std::move(weak), cq = cq_](TimerFuture tp) mutable {
161+
cq.RunAsync([weak = std::move(weak), cancelled = !tp.get()] {
162+
if (auto self = weak.lock()) self->OnRefreshTimer(cancelled);
163+
});
150164
});
151165
});
152166
}

google/cloud/pubsub/internal/subscription_lease_management_test.cc

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -109,18 +109,21 @@ TEST(SubscriptionLeaseManagementTest, NormalLifecycle) {
109109
// will verify that only the remaining messages have their lease extended.
110110
uut->AckMessage("ack-0-1");
111111
fake_cq->SimulateCompletion(true);
112+
// RunAsync, drain the deferred OnRefreshTimer
113+
fake_cq->SimulateCompletion(true);
112114
ASSERT_EQ(1U, fake_cq->size());
113115

114116
// Ack one more message and trigger the new timer.
115117
uut->NackMessage("ack-0-2");
116118
fake_cq->SimulateCompletion(true);
119+
// RunAsync, drain the deferred OnRefreshTimer
120+
fake_cq->SimulateCompletion(true);
117121
ASSERT_EQ(1U, fake_cq->size());
118122

119123
shutdown_manager->MarkAsShutdown(__func__, Status{});
120124
uut->Shutdown();
121125

122-
fake_cq->SimulateCompletion(false);
123-
ASSERT_EQ(0U, fake_cq->size());
126+
while (!fake_cq->empty()) fake_cq->SimulateCompletion(true);
124127
EXPECT_THAT(done.get(), IsOk());
125128
}
126129

@@ -154,8 +157,7 @@ TEST(SubscriptionLeaseManagementTest, ShutdownOnError) {
154157
Status(StatusCode::kPermissionDenied, "uh-oh"))});
155158
ASSERT_EQ(1U, fake_cq->size());
156159

157-
fake_cq->SimulateCompletion(false);
158-
ASSERT_EQ(0U, fake_cq->size());
160+
while (!fake_cq->empty()) fake_cq->SimulateCompletion(true);
159161
EXPECT_THAT(done.get(), StatusIs(StatusCode::kPermissionDenied));
160162
}
161163

@@ -219,12 +221,13 @@ TEST(SubscriptionLeaseManagementTest, UsesDeadlineExtension) {
219221

220222
// Ignore message and then fire the timer. This will extend the deadline.
221223
fake_cq->SimulateCompletion(true);
224+
// RunAsync, drain the deferred OnRefreshTimer
225+
fake_cq->SimulateCompletion(true);
222226
ASSERT_EQ(1U, fake_cq->size());
223227

224228
shutdown_manager->MarkAsShutdown(__func__, Status{});
225229
uut->Shutdown();
226-
fake_cq->SimulateCompletion(false);
227-
ASSERT_EQ(0U, fake_cq->size());
230+
while (!fake_cq->empty()) fake_cq->SimulateCompletion(true);
228231
EXPECT_THAT(done.get(), IsOk());
229232
}
230233

@@ -270,13 +273,78 @@ TEST(SubscriptionLeaseManagementTest, ExpiredMessage) {
270273
// will verify that only the remaining messages have their lease extended.
271274
uut->AckMessage("ack-0-1");
272275
fake_cq->SimulateCompletion(true);
276+
// RunAsync, drain the deferred OnRefreshTimer
277+
fake_cq->SimulateCompletion(true);
273278
ASSERT_EQ(1U, fake_cq->size());
274279

275280
shutdown_manager->MarkAsShutdown(__func__, Status{});
276281
uut->Shutdown();
282+
while (!fake_cq->empty()) fake_cq->SimulateCompletion(true);
283+
EXPECT_THAT(done.get(), IsOk());
284+
}
285+
286+
/// @test Regression test for the self-deadlock in StartRefreshTimer: the lease
287+
/// refresh-timer continuation must be dispatched via `RunAsync` rather than run
288+
/// inline in the timer callback. Running it inline re-entered the already-held
289+
/// `mu_` and self-deadlocked the subscription. Here we verify that firing the
290+
/// timer only *schedules* the refresh (a RunAsync task); the lease extension
291+
/// runs on a subsequent completion, never synchronously in the timer callback.
292+
TEST(SubscriptionLeaseManagementTest,
293+
RefreshTimerContinuationDispatchedViaRunAsync) {
294+
auto mock = std::make_shared<pubsub_testing::MockSubscriptionBatchSource>();
295+
std::shared_ptr<BatchCallback> batch_callback;
296+
EXPECT_CALL(*mock, Start).WillOnce([&](std::shared_ptr<BatchCallback> cb) {
297+
batch_callback = std::move(cb);
298+
});
277299

278-
fake_cq->SimulateCompletion(false);
279-
ASSERT_EQ(0U, fake_cq->size());
300+
auto mock_batch_callback =
301+
std::make_shared<pubsub_testing::MockBatchCallback>();
302+
EXPECT_CALL(*mock_batch_callback, callback).Times(1);
303+
304+
int extend_calls = 0;
305+
EXPECT_CALL(*mock, ExtendLeases)
306+
.WillRepeatedly(
307+
[&](std::vector<std::string> const&, std::chrono::seconds) {
308+
++extend_calls;
309+
return make_ready_future(Status{});
310+
});
311+
EXPECT_CALL(*mock, BulkNack)
312+
.WillRepeatedly([](std::vector<std::string> const&) {
313+
return make_ready_future(Status{});
314+
});
315+
EXPECT_CALL(*mock, Shutdown).Times(1);
316+
317+
auto fake_cq = std::make_shared<FakeCompletionQueueImpl>();
318+
CompletionQueue cq(fake_cq);
319+
auto shutdown_manager = std::make_shared<SessionShutdownManager>();
320+
auto uut = SubscriptionLeaseManagement::Create(cq, shutdown_manager, mock,
321+
std::chrono::seconds(345),
322+
std::chrono::seconds(600));
323+
324+
auto done = shutdown_manager->Start({});
325+
uut->Start(mock_batch_callback);
326+
batch_callback->callback(
327+
BatchCallback::StreamingPullResponse{GenerateMessages("0-", 1)});
328+
ASSERT_EQ(1U, fake_cq->size()); // the refresh timer is pending
329+
330+
// Fire the timer. With the fix, the timer callback only *schedules*
331+
// OnRefreshTimer via RunAsync; it must NOT extend leases inline (doing so
332+
// re-enters the held mutex and deadlocks).
333+
fake_cq->SimulateCompletion(true);
334+
EXPECT_EQ(0, extend_calls)
335+
<< "lease refresh ran inline in the timer callback (deadlock path)";
336+
EXPECT_EQ(1U, fake_cq->size()); // a RunAsync task is now pending
337+
338+
// Draining the RunAsync task runs OnRefreshTimer -> ExtendLeases.
339+
fake_cq->SimulateCompletion(true);
340+
EXPECT_EQ(1, extend_calls);
341+
342+
// The fix defers OnRefreshTimer to RunAsync, which the fake CQ only executes
343+
// on SimulateCompletion(true) (false drops the task). Drain with true so the
344+
// deferred refresh operation finishes and the session shutdown completes.
345+
shutdown_manager->MarkAsShutdown(__func__, Status{});
346+
uut->Shutdown();
347+
while (!fake_cq->empty()) fake_cq->SimulateCompletion(true);
280348
EXPECT_THAT(done.get(), IsOk());
281349
}
282350

0 commit comments

Comments
 (0)