Skip to content

Fix deadlock between ReschedulableTask::destroy() and concurrent task - #24

Merged
yrivardmulrooney merged 1 commit into
coveooss:mainfrom
the-mikedavis:destroy-deadlock
Aug 5, 2026
Merged

Fix deadlock between ReschedulableTask::destroy() and concurrent task#24
yrivardmulrooney merged 1 commit into
coveooss:mainfrom
the-mikedavis:destroy-deadlock

Conversation

@the-mikedavis

Copy link
Copy Markdown
Contributor

destroy() held InternalState::mutex while blocking on AsyncTask::destroy(), which waits for the asio strand to become free. The strand running the callback starts by acquiring the same mutex. So if destroy() coincides with a callback, it becomes deadlocked. This might happen if a reconnection or confirm timeout task fires at the same time that something calls stop().

destroy() now releases the mutex before calling AsyncTask::destroy(), keeping a local reference to the AsyncTask so a concurrent destroy() call can't have it torn down from under the first caller. AsyncTask::destroy() is idempotent, so both callers redoing it is OK. They converge on waiting for the same current_invocation afterward, matching what holding the mutex throughout used to guarantee.

This change adds a test case to a new suite reschedulable_task.cpp which thrashes - it keeps the task firing from one thread and repeatedly destroys and recreates it from another. This reliably reproduces the deadlock before the fix after a few iterations.

Comment thread src/reschedulable_task.cpp Outdated
m_state->async_task->destroy();
m_state->pending_invocations = -1;
std::shared_ptr<AsyncTask> async_task = m_state->async_task;
m_state->pending_invocations = -1;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't tried to break it, but I feel like assigning this before calling async_task->destroy() could result in the task re-incrementing the counter to 0 when trying to run, which would probably break the contract and lead to some form of a corrupted state. This would be a very rare case (probably the same category of case that would previously have deadlocked the code?). The important part of this method is that we need to have a strong guarantee that the function that was passed into the ReschedulableTask can never be invoked again after destroy() returns.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm I think you're right. If schedule_now/schedule_after/cancel are called concurrently with destroy this might be an issue. Let me think about the right way to fix this...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I built a small deterministic reproducer and I could see pending_invocations rise -1 -> 0 -> 1 but I could never trigger the function running after destroy. It seems to be safe by an ordering accident of the strand + future. I think the right fix for this is to set and check a destroyed bool instead of the -1 value. What do you think?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems reasonable, recycling the pending invocation count was a bit of a hack, this is more explicit.

@the-mikedavis
the-mikedavis marked this pull request as draft August 3, 2026 17:23
destroy() held InternalState::mutex while blocking on
AsyncTask::destroy(), which waits for the asio strand to become free.
The strand running the callback starts by acquiring the same mutex. So
if destroy() coincides with a callback, it becomes deadlocked. This
might happen if a reconnection or confirm timeout task fires at the same
time that something calls stop().

destroy() now releases the mutex before calling AsyncTask::destroy(),
keeping a local reference to the AsyncTask so a concurrent destroy()
call can't have it torn down from under the first caller.
AsyncTask::destroy() is idempotent, so both callers redoing it is OK.
They converge on waiting for the same current_invocation afterward,
matching what holding the mutex throughout used to guarantee.

We also set a destroyed bool on the task which takes the place of
setting and checking that pending_invocations is -1. This avoids races
where pending_invocations is incremented which ruin the indicator that
the task is destroyed.

This change adds a test case to a new suite reschedulable_task.cpp which
thrashes - it keeps the task firing from one thread and repeatedly
destroys and recreates it from another. This reliably reproduces the
deadlock before the fix after a few iterations.
@the-mikedavis
the-mikedavis marked this pull request as ready for review August 4, 2026 16:34
@yrivardmulrooney
yrivardmulrooney merged commit 122e0c3 into coveooss:main Aug 5, 2026
6 checks passed
@the-mikedavis
the-mikedavis deleted the destroy-deadlock branch August 6, 2026 02:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants