Skip to content

tokio::fs::File::poll_write can panic after a previous write returned an error #8182

@mikekap

Description

@mikekap

Version

tokio v1.49.0

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions