A weird test hang
I recently hit a Rust test that hung forever:
- no panic
- no error
- no timeout
- just… silence
The confusing part: the code path only used tokio::sync::RwLock::read(). No writers, no contention. Two reads succeeded. The third read().await never returned.
That sounds impossible if you're thinking in "classic lock" terms.
But the root cause wasn't contention. It was executor semantics.
The minimal reproducer
I published a tiny repro repo that demonstrates the behavior:
-
cargo run→ hangs (expected) -
cargo run -- --fixed→ completes
The repro intentionally forces Tokio's cooperative scheduler to yield, then attempts another uncontended read lock.
The mental model mismatch
A common assumption is:
If a lock is uncontended, acquiring it can't block.
That's true for synchronous locks, but async is different. In async Rust, .await means:
- Poll the future
- If it can't make progress right now, return
Poll::Pending - The executor will poll it again later
Crucially:
Pendingdoes not always mean "the resource isn't ready".
Sometimes it means "yield for fairness".
Tokio's cooperative scheduling (the key)
Tokio uses cooperative scheduling. Each task has a limited budget to prevent a single task from starving others.
When that budget is exhausted, Tokio can intentionally return Poll::Pending to force a yield — even if the underlying resource is available.
Under a normal Tokio runtime, that's fine:
- the task gets re-polled
- the budget is reset
- the operation completes on the next poll
This is usually invisible.
Where it goes wrong: futures::executor::block_on
In my case, I discovered that part of the test setup was driving Tokio async code using:
futures::executor::block_on(async { ... })
This is a common pattern in tests/mocks when you have a synchronous callback but need to call async code.
The problem is: futures::executor::block_on is not the Tokio runtime.
What it does is roughly:
- Poll the future on the current OS thread
- If it returns
Pending, park the thread and wait for a wake-up
So when Tokio returns Pending for cooperative scheduling reasons, block_on treats it as "I should sleep until someone wakes me".
But in this scenario, there isn't a normal "external wake-up" to wait for. Tokio expected the runtime to re-poll the task.
Result:
- the OS thread parks
- the future never gets re-polled
- the test hangs forever
And the lock looks "deadlocked" even though it's uncontended.
Why it happened on the third read
Because the cooperative budget is cumulative.
The first awaits didn't exhaust the budget. The third happened to be the first place where Tokio decided "you must yield now", returning Pending.
With small code changes, the hang could move earlier/later or disappear entirely — which is part of what makes this class of bug hard to debug.
A practical fix
The simplest rule:
If async code uses Tokio primitives, it must be driven by Tokio.
If you need to run async code from a synchronous context (tests/mocks/callbacks), one safe pattern is to drive it with Tokio's runtime handle on a separate OS thread:
fn tokio_block_on<F: std::future::Future + Send>(fut: F) -> F::Output
where
F::Output: Send,
{
let handle = tokio::runtime::Handle::current();
std::thread::scope(|s| s.spawn(|| handle.block_on(fut)).join().unwrap())
}
In the repro repo, cargo run -- --fixed uses this approach and completes successfully.
Takeaways
-
Poll::Pendingdoesn't always mean "waiting for a resource" - Tokio relies on its runtime to re-poll tasks; mixing executors can break that assumption
- Avoid
futures::executor::block_onfor Tokio tasks - If you must cross sync/async boundaries, drive futures using Tokio itself (or redesign the boundary)
This bug is rare, but it sits in a very common pattern: sync test/mocking glue calling async code. When it happens, it's deeply confusing — and completely silent.
Top comments (0)