Need help understanding tokio::time::timeout mechanics

⚓ rust    📅 2025-07-14    👤 surdeus    👁️ 3      

surdeus

Hello all! I'm new both to Rust and tokio, and I find myself at sea even reading the docs. The timeout function seems straightforward enough, but then you have this scary sentence:

Note that the timeout is checked before polling the future, so if the future does not yield during execution then it is possible for the future to complete and exceed the timeout without returning an error.

Does that mean the future could exceed the timeout by microseconds and not be caught in time to trigger a timeout, therefore technically timing out without an error, or does it mean a long-running blocking task could run forever without triggering timeout? I assumed the former, but now I'm hitting a sticking point in my code that suggests the latter.

What I'm trying to accomplish is to measure timeouts caused by synchronous std::net::tcp::TcpStream read/write operations within an async function, but to make sure I understood the tokio timeout mechanics I wrote the following test code:

// main.rs:
use tokio::time::{Duration, timeout};
use wrinkledytime::{concurrent_test_sleeper, sync_test_busy_driver, sync_test_sleeper_driver};

#[tokio::main(flavor = "current_thread")]
async fn main() {
    let res_async_sleep = match timeout(
        Duration::from_secs(5),
        concurrent_test_sleeper(Duration::from_secs(10)),
    )
    .await
    {
        Ok(_) => String::from("uh oh, async time is disobeying me!"),
        Err(e) => format!("async sleep timed out as expected with {}", e),
    };
    dbg!(res_async_sleep);

    let res_sync_sleep = match timeout(
        Duration::from_secs(5),
        sync_test_sleeper_driver(Duration::from_secs(10)),
    )
    .await
    {
        Ok(_) => String::from("uh oh, sync sleep time is disobeying me!"),
        Err(e) => format!("sync sleep timed out as expected with {}", e),
    };
    dbg!(res_sync_sleep);

    let res_sync_busy = match timeout(
        Duration::from_secs(5),
        sync_test_busy_driver(Duration::from_secs(10)),
    )
    .await
    {
        Ok(_) => String::from("uh oh, sync busy time is disobeying me!"),
        Err(e) => format!("sync busy timed out as expected with {}", e),
    };
    dbg!(res_sync_busy);
}

// lib.rs
use tokio::time::{Duration, sleep};

pub async fn concurrent_test_sleeper(duration: Duration) -> std::result::Result<(), &'static str> {
    sleep(duration).await;
    Ok(())
}

pub async fn sync_test_sleeper_driver(duration: Duration) -> std::result::Result<(), &'static str> {
    sync_test_sleeper(duration)
}

pub async fn sync_test_busy_driver(duration: Duration) -> std::result::Result<(), &'static str> {
    sync_test_busy(duration)
}

pub fn sync_test_sleeper(duration: Duration) -> std::result::Result<(), &'static str> {
    std::thread::sleep(duration);
    Ok(())
}

pub fn sync_test_busy(duration: Duration) -> std::result::Result<(), &'static str> {
    let clock = std::time::SystemTime::now();
    loop {
        if clock.elapsed().unwrap() >= duration {
            break;
        }
    }
    dbg!("busy for {:?} ms", clock.elapsed().unwrap());
    Ok(())
}

The concurrent_test_sleeper() future's timeout is detected successfully and uses the classic example with tokio::time::sleep(). However, neither of the sync test function timeouts were detected. If I understand correctly, the sync_test_sleeper() and sync_test_busy() functions are both essentially what I would be seeing if std::net::tcp::TcpStream operations ran long, essentially blocking the calling thread. As I feared, tokio timeout allows both those functions to run as long as they want, double the timeout duration. It doesn't detect that a timeout occurred after the fact either, returning an Ok result.

This insight raises the following questions for me:

  1. How can I structure/wrap/refactor my synchronous code such that tokio timeout will monitor it successfully? I've seen that tokio has its own TcpStream struct with AsyncRead and AsyncWrite trait implementations, but it looks like wrapping the std::net::tcp::TcpStream calls in tokio::task::spawn_blocking() might also help -- is there a preferred/better approach?
  2. Why is it that tokio timeout can't detect the timeout in the above sync_test* cases? From what I've read there's a lot of talk about polling futures, which I don't really understand, but it sounds like essentially tokio timeout can only check to see if a timeout has occurred if the measured code yields e.g. via await. If that's correct, isn't it possible that even well-behaved async code might yield infrequently enough that you can get arbitrary timeout overruns e.g. an async function is set to time out after 5 seconds but it doesn't yield for 6 seconds, so the earliest you can detect the timeout is a full second late?
  3. Why didn't tokio timeout detect that a timeout had occurred and return an error once the synchronous code was finally finished?
  4. How does tokio::time::sleep() operate differently to std::thread ::sleep() such that tokio timeout can detect its timeout?
  5. How does polling the future given to timeout function under the hood? I've looked at the source code, but I'm afraid it's over my head. The docs could really use diagrams, please and thanks!

thanks very much!

4 posts - 2 participants

Read full topic

🏷️ rust_feed