Version
The affected code is unchanged on current master.
Platform
Originally observed on Linux x86_64; the bug is not OS-specific. The reproduction below also panics on Darwin 25.4.0 arm64.
Description
Calling poll_write on a tokio::fs::File after a previous poll_write returned an error can panic:
thread '...' panicked at .../tokio-1.49.0/src/fs/file.rs:745:51:
called `Option::unwrap()` on a `None` value
Repro:
use std::future::Future;
use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
use tokio::io::AsyncWriteExt;
use tokio::pin;
fn noop_waker() -> Waker {
fn no_op(_: *const ()) {}
fn clone(_: *const ()) -> RawWaker {
RawWaker::new(std::ptr::null(), &VTABLE)
}
static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, no_op, no_op, no_op);
unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) }
}
fn main() {
let path = std::env::temp_dir().join("tokio_file_poll_write_panic.txt");
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()
.unwrap();
// Keep a Handle alive so the blocking spawner is not deallocated after shutdown.
let handle = rt.handle().clone();
// Open the file while the blocking pool is still running.
let mut file = rt.block_on(async { tokio::fs::File::create(&path).await.unwrap() });
// Shut the runtime (and its blocking pool) down so spawn_mandatory_blocking returns None.
rt.shutdown_background();
// Enter the shut-down runtime's context so the spawn inside poll_write resolves to it.
let _guard = handle.enter();
let waker = noop_waker();
let mut cx = Context::from_waker(&waker);
// First write: returns an error.
{
let fut = file.write_all(b"first write\n");
pin!(fut);
match fut.as_mut().poll(&mut cx) {
Poll::Ready(Err(e)) => println!("first write_all returned the expected error: {e}"),
other => {
println!("UNEXPECTED: {other:?} — pool was not shut down?");
return;
}
}
}
// Second write on the same file: panics.
println!("polling a second write_all on the same File (expecting panic)...");
{
let fut = file.write_all(b"second write\n");
pin!(fut);
let _ = fut.as_mut().poll(&mut cx);
}
println!("NO PANIC — bug did not reproduce on this tokio version.");
}
(tokio = { version = "=1.49.0", features = ["rt-multi-thread", "fs", "io-util"] }. The first write is forced to fail deterministically — no thread-limit or timing tricks — by polling the File while its runtime's blocking pool is shut down.)
Root cause
In the State::Idle arm of poll_write (src/fs/file.rs, line numbers from 1.49.0):
State::Idle(ref mut buf_cell) => {
let mut buf = buf_cell.take().unwrap(); // (1) buffer moved out of the cell
// ...
let blocking_task_join_handle = spawn_mandatory_blocking(move || {
// ...
(Operation::Write(res), buf)
})
.ok_or_else(|| { // (2) returns None when spawn fails
io::Error::new(io::ErrorKind::Other, "background task failed")
})?; // (3) `?` early-returns the error
inner.state = State::Busy(blocking_task_join_handle); // (4) NOT reached on failure
return Poll::Ready(Ok(n));
}
When spawn_mandatory_blocking returns None: the buffer was already moved out at (1), ? at (3) returns before (4) runs, so the file is left as State::Idle(None). The next call into this arm runs buf_cell.take().unwrap() on None and panics. poll_write_vectored (line 827) has the identical pattern.
Version
The affected code is unchanged on current
master.Platform
Originally observed on Linux x86_64; the bug is not OS-specific. The reproduction below also panics on
Darwin 25.4.0 arm64.Description
Calling
poll_writeon atokio::fs::Fileafter a previouspoll_writereturned an error can panic:Repro:
(
tokio = { version = "=1.49.0", features = ["rt-multi-thread", "fs", "io-util"] }. The first write is forced to fail deterministically — no thread-limit or timing tricks — by polling theFilewhile its runtime's blocking pool is shut down.)Root cause
In the
State::Idlearm ofpoll_write(src/fs/file.rs, line numbers from 1.49.0):When
spawn_mandatory_blockingreturnsNone: the buffer was already moved out at (1),?at (3) returns before (4) runs, so the file is left asState::Idle(None). The next call into this arm runsbuf_cell.take().unwrap()onNoneand panics.poll_write_vectored(line 827) has the identical pattern.