Implemented io-uring Op<Statx> and applied to read_uring and fs::try_exists (#8080)

* implement Op<Statx> and use it to implement fs::try_exists

* fix statx on unavailable platforms

* complete using only io-uring operations for read_uring

* Implemented io-uring::Op<Statx> and apply it accordingly to read_uring, try_exists

* Added test for io uring statx operations. Checks for cancellations, shutdown, stating multiple files, ELOOP, ENAMETOOLONG, EACCES

* Removed musl as supported platform for io_uring statx operations, as statx is supported on 1.25+ musl, and MSRV that uses 1.25 on all *-linux-musl platforms is 1.93

* Removed pending checks for cancel_op_future since io_uring not available on Linux <5.1, removed stat permission denied test case since it doesn't work on Linux 4.19

* Removed redundant cfg attributes on functions, added STATX_BTIME flag in statx operation, use assert pending in cancel ops

* Uncommented stat_permission_denied test and make sure it doesn't run on platforms that don't support io_uring, removed unnecessary comments, and added TODO on symlink_metadata for when Metadata::from_statx is stabilized

* Statx fd leak drop test added and added STATX_BTIME to file_metadata

* Remove unnecessary utils function, refactored code in statx and statx fd leak test, and removed cfg_io_uring gates (localized the feature gate to the pertinent part of the code that uses it in read_uring)

---------

Co-authored-by: Vrtgs <[email protected]>
This commit is contained in:
Mahdi Ali-Raihan
2026-06-13 13:30:47 -04:00
committed by GitHub
co-authored by Vrtgs
parent da044f27d7
commit 7892f6020d
10 changed files with 766 additions and 10 deletions
+9
View File
@@ -106,6 +106,15 @@ impl From<MockFile> for OwnedFd {
}
}
#[cfg(all(test, unix))]
impl From<OwnedFd> for MockFile {
#[inline]
fn from(file: OwnedFd) -> MockFile {
use std::os::fd::IntoRawFd;
unsafe { MockFile::from_raw_fd(IntoRawFd::into_raw_fd(file)) }
}
}
tokio_thread_local! {
static QUEUE: RefCell<VecDeque<Box<dyn FnOnce() + Send>>> = RefCell::new(VecDeque::new())
}
+8 -5
View File
@@ -518,6 +518,10 @@ impl OpenOptions {
/// [`Other`]: std::io::ErrorKind::Other
/// [`PermissionDenied`]: std::io::ErrorKind::PermissionDenied
pub async fn open(&self, path: impl AsRef<Path>) -> io::Result<File> {
self.open_inner(path.as_ref()).await
}
async fn open_inner(&self, path: &Path) -> io::Result<File> {
match &self.inner {
Kind::Std(opts) => Self::std_open(opts, path).await,
#[cfg(all(
@@ -535,7 +539,7 @@ impl OpenOptions {
.check_and_init(io_uring::opcode::OpenAt::CODE)
.await?
{
Op::open(path.as_ref(), opts)?.await
Op::open(path, opts)?.await
} else {
let opts = opts.clone().into();
Self::std_open(&opts, path).await
@@ -544,12 +548,11 @@ impl OpenOptions {
}
}
async fn std_open(opts: &StdOpenOptions, path: impl AsRef<Path>) -> io::Result<File> {
let path = path.as_ref().to_owned();
async fn std_open(opts: &StdOpenOptions, path: &Path) -> io::Result<File> {
let path = path.to_owned();
let opts = opts.clone();
let std = asyncify(move || opts.open(path)).await?;
Ok(File::from_std(std))
Ok(asyncify(move || opts.open(path)).await?.into())
}
#[cfg(windows)]
+17 -3
View File
@@ -54,14 +54,23 @@ use std::{io, path::Path};
/// }
/// ```
pub async fn read(path: impl AsRef<Path>) -> io::Result<Vec<u8>> {
let path = path.as_ref().to_owned();
let path = path.as_ref();
#[cfg(all(
tokio_unstable,
feature = "io-uring",
feature = "rt",
feature = "fs",
target_os = "linux"
// libc::statx is only supported on these platforms
// FIXME: Add musl target env when our minimum supported
// rust version is 1.93. To clarify, statx support is
// introduced to musl in 1.25 as mentioned officially here:
// https://musl.libc.org/releases.html.
// However, rustup target_env building for *-linux-musl
// uses 1.25 musl on all *-linux-musl platforms starting
// in 1.93 stable rust version.
// https://blog.rust-lang.org/2025/12/05/Updating-musl-1.2.5/
any(target_env = "gnu", target_os = "android")
))]
{
use crate::fs::read_uring;
@@ -72,9 +81,14 @@ pub async fn read(path: impl AsRef<Path>) -> io::Result<Vec<u8>> {
.check_and_init(io_uring::opcode::Read::CODE)
.await?
{
return read_uring(&path).await;
return read_uring(path).await;
}
}
read_spawn_blocking(path).await
}
async fn read_spawn_blocking(path: &Path) -> io::Result<Vec<u8>> {
let path = path.to_owned();
asyncify(move || std::fs::read(path)).await
}
+18 -1
View File
@@ -19,9 +19,26 @@ const MAX_READ_SIZE: usize = 64 * 1024 * 1024;
pub(crate) async fn read_uring(path: &Path) -> io::Result<Vec<u8>> {
let file = OpenOptions::new().read(true).open(path).await?;
// TODO: use io uring in the future to obtain metadata
#[cfg(not(any(target_env = "gnu", target_os = "android")))]
let size_hint: Option<usize> = file.metadata().await.map(|m| m.len() as usize).ok();
#[cfg(
// libc::statx is only supported on these platforms
// FIXME: Add musl target env when our minimum supported
// rust version is 1.93. To clarify, statx support is
// introduced to musl in 1.25 as mentioned officially here:
// https://musl.libc.org/releases.html.
// However, rustup target_env building for *-linux-musl
// uses 1.25 musl on all *-linux-musl platforms starting
// in 1.93 stable rust version.
// https://blog.rust-lang.org/2025/12/05/Updating-musl-1.2.5/
any(target_env = "gnu", target_os = "android")
)]
let size_hint = Op::file_metadata(&file)?
.await
.map(|m| m.len() as usize)
.ok();
let fd: OwnedFd = file
.try_into_std()
.expect("unexpected in-flight operation detected")
+62 -1
View File
@@ -23,6 +23,67 @@ use std::path::Path;
/// # }
/// ```
pub async fn try_exists(path: impl AsRef<Path>) -> io::Result<bool> {
let path = path.as_ref().to_owned();
let path = path.as_ref();
#[cfg(all(
tokio_unstable,
feature = "io-uring",
feature = "rt",
feature = "fs",
// libc::statx is only supported on these platforms
// FIXME: Add musl target env when our minimum supported
// rust version is 1.93. To clarify, statx support is
// introduced to musl in 1.25 as mentioned officially here:
// https://musl.libc.org/releases.html.
// However, rustup target_env building for *-linux-musl
// uses 1.25 musl on all *-linux-musl platforms starting
// in 1.93 stable rust version.
// https://blog.rust-lang.org/2025/12/05/Updating-musl-1.2.5/
any(target_env = "gnu", target_os = "android")
))]
{
let handle = crate::runtime::Handle::current();
let driver_handle = handle.inner.driver().io();
if driver_handle
.check_and_init(io_uring::opcode::Statx::CODE)
.await?
{
return try_exists_uring(path).await;
}
}
try_exists_spawn_blocking(path).await
}
cfg_io_uring! {
#[inline]
#[cfg(
// libc::statx is only supported on these platforms
// FIXME: Add musl target env when our minimum supported
// rust version is 1.93. To clarify, statx support is
// introduced to musl in 1.25 as mentioned officially here:
// https://musl.libc.org/releases.html.
// However, rustup target_env building for *-linux-musl
// uses 1.25 musl on all *-linux-musl platforms starting
// in 1.93 stable rust version.
// https://blog.rust-lang.org/2025/12/05/Updating-musl-1.2.5/
any(target_env = "gnu", target_os = "android")
)]
async fn try_exists_uring(path: &Path) -> io::Result<bool> {
use crate::runtime::driver::op::Op;
match Op::metadata(path)?.await {
Ok(_) => Ok(true),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error),
}
}
}
async fn try_exists_spawn_blocking(path: &Path) -> io::Result<bool> {
let path = path.to_owned();
// FIXME: When MSRV is 1.81, change this to
// std::fs::exists() to be consistent with
// all other tokio::fs operations
asyncify(move || path.try_exists()).await
}
+1
View File
@@ -1,4 +1,5 @@
pub(crate) mod open;
pub(crate) mod read;
pub(crate) mod statx;
pub(crate) mod utils;
pub(crate) mod write;
+161
View File
@@ -0,0 +1,161 @@
#![cfg(all(
tokio_unstable,
feature = "io-uring",
feature = "rt",
feature = "fs",
// libc::statx is only supported on these platforms
// FIXME: Add musl target env when our minimum supported
// rust version is 1.93. To clarify, statx support is
// introduced to musl in 1.25 as mentioned officially here:
// https://musl.libc.org/releases.html.
// However, rustup target_env building for *-linux-musl
// uses 1.25 musl on all *-linux-musl platforms starting
// in 1.93 stable rust version.
// https://blog.rust-lang.org/2025/12/05/Updating-musl-1.2.5/
any(target_env = "gnu", target_os = "android")
))]
use crate::fs::File;
use crate::io::uring::utils::cstr;
use crate::runtime::driver::op::{CancelData, Cancellable, Completable, CqeResult, Op};
use io_uring::{opcode, types};
use libc::statx;
use std::ffi::{CStr, CString};
use std::fmt::{Debug, Formatter};
use std::io;
use std::mem::MaybeUninit;
use std::os::fd::AsRawFd;
use std::path::Path;
pub(crate) struct Metadata(statx);
impl Metadata {
/// Returns the size of the file, in bytes, this metadata is for.
pub(crate) fn len(&self) -> u64 {
self.0.stx_size
}
}
impl Debug for Metadata {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let mut debug = f.debug_struct("Metadata");
debug.field("len", &self.len());
debug.finish_non_exhaustive()
}
}
#[derive(Debug)]
pub(crate) struct Statx {
/// This field will be read by the kernel during the operation, so we
/// need to ensure it is valid for the entire duration of the operation.
_path: CString,
buffer: Box<MaybeUninit<statx>>,
}
impl Completable for Statx {
type Output = io::Result<Metadata>;
fn complete(self, cqe: CqeResult) -> Self::Output {
// SAFETY: On success, we always receive 0, which should guarantee
// that the information about a file is stored inside the
// statx buffer. On failure, we'll receive an Error value.
// Refer to man page description and return value:
// https://man7.org/linux/man-pages/man2/statx.2.html
cqe.result
.map(|_| Metadata(unsafe { *self.buffer.as_ptr() }))
}
fn complete_with_error(self, error: io::Error) -> Self::Output {
Err(error)
}
}
impl Cancellable for Statx {
fn cancel(self) -> CancelData {
CancelData::Statx(self)
}
}
impl Op<Statx> {
/// Submit a request to retrieve a file's status.
#[inline]
fn statx(path: &Path, flags: i32) -> io::Result<Op<Statx>> {
let path = cstr(path)?;
let mut buffer = Box::new(MaybeUninit::<statx>::uninit());
let statx_op = opcode::Statx::new(
types::Fd(libc::AT_FDCWD),
path.as_ptr(),
buffer.as_mut_ptr().cast(),
)
.flags(flags)
.mask(libc::STATX_BASIC_STATS | libc::STATX_BTIME)
.build();
// SAFETY: Parameters are valid for the entire duration of the operation
Ok(unsafe {
Op::new(
statx_op,
Statx {
_path: path,
buffer,
},
)
})
}
/// Retrieves the metadata information of the given path, following symlinks
/// if the path provided points to a symlink location.
#[inline]
pub(crate) fn metadata(path: &Path) -> io::Result<Op<Statx>> {
Op::statx(path, libc::AT_STATX_SYNC_AS_STAT)
}
/// Retrieves the metadata information of the given file
pub(crate) fn file_metadata(file: &File) -> io::Result<Op<Statx>> {
let mut buffer = Box::new(MaybeUninit::<statx>::uninit());
let empty_path: &'static CStr = c"";
// io-uring was introduced in linux 5.1
// pass in an empty path instead of null to target the file descriptor
// status as specified by man:
// https://man7.org/linux/man-pages/man2/statx.2.html
let statx_op = opcode::Statx::new(
types::Fd(file.as_raw_fd()),
// it should be fine to pass in `empty_path` whose lifetime
// does not exceed the `file_metadata()` function as a ptr here
// because we want to stat the dirfd not this pathname
empty_path.as_ptr(),
buffer.as_mut_ptr().cast(),
)
.flags(libc::AT_STATX_SYNC_AS_STAT | libc::AT_EMPTY_PATH)
.mask(libc::STATX_BASIC_STATS | libc::STATX_BTIME)
.build();
// SAFETY: Parameters are valid for the entire duration of the operation
Ok(unsafe {
Op::new(
statx_op,
Statx {
_path: empty_path.into(),
buffer,
},
)
})
}
// TODO: Once `Metadata::from_statx` is stabilized, we can use use this function
// to enable io-uring support on `tokio::fs::symlink_metadata`.
// See this PR for more detail: https://github.com/tokio-rs/tokio/pull/8080
// See `Metadata::from_statx` tracking issue to see progress:
// https://github.com/rust-lang/rust/issues/156268
/// Retrieves the metadata information of the given path without following symlinks.
#[inline]
#[allow(dead_code)]
pub(crate) fn symlink_metadata(path: &Path) -> io::Result<Op<Statx>> {
Op::statx(
path,
libc::AT_STATX_SYNC_AS_STAT | libc::AT_SYMLINK_NOFOLLOW,
)
}
}
+26
View File
@@ -6,6 +6,19 @@ use crate::io::uring::write::Write;
use crate::runtime::Handle;
#[cfg(
// libc::statx is only supported on these platforms
// FIXME: Add musl target env when our minimum supported
// rust version is 1.93. To clarify, statx support is
// introduced to musl in 1.25 as mentioned officially here:
// https://musl.libc.org/releases.html.
// However, rustup target_env building for *-linux-musl
// uses 1.25 musl on all *-linux-musl platforms starting
// in 1.93 stable rust version.
// https://blog.rust-lang.org/2025/12/05/Updating-musl-1.2.5/
any(target_env = "gnu", target_os = "android")
)]
use crate::io::uring::statx::Statx;
use io_uring::cqueue;
use io_uring::squeue::Entry;
use std::future::Future;
@@ -24,6 +37,19 @@ pub(crate) enum CancelData {
Write(Write),
ReadVec(Read<Vec<u8>, OwnedFd>),
ReadBuf(Read<Buf, ArcFd>),
#[cfg(
// libc::statx is only supported on these platforms
// FIXME: Add musl target env when our minimum supported
// rust version is 1.93. To clarify, statx support is
// introduced to musl in 1.25 as mentioned officially here:
// https://musl.libc.org/releases.html.
// However, rustup target_env building for *-linux-musl
// uses 1.25 musl on all *-linux-musl platforms starting
// in 1.93 stable rust version.
// https://blog.rust-lang.org/2025/12/05/Updating-musl-1.2.5/
any(target_env = "gnu", target_os = "android")
)]
Statx(Statx),
}
#[derive(Debug)]
+303
View File
@@ -0,0 +1,303 @@
//! Uring file operations tests.
#![cfg(all(
tokio_unstable,
feature = "io-uring",
feature = "rt",
feature = "fs",
target_os = "linux"
))]
use futures::future::Future;
use futures::future::FutureExt;
use libc::PATH_MAX;
use std::future::poll_fn;
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::sync::mpsc;
use std::task::Poll;
use std::time::Duration;
use tempfile::{tempdir, NamedTempFile};
use tokio::fs::{create_dir, metadata, set_permissions, symlink, try_exists, write};
use tokio::runtime::{Builder, Runtime};
use tokio_test::assert_pending;
use tokio_util::task::TaskTracker;
use crate::support::io_uring::io_uring_supported;
mod support {
pub(crate) mod io_uring;
}
fn multi_rt(n: usize) -> Box<dyn Fn() -> Runtime> {
Box::new(move || {
Builder::new_multi_thread()
.worker_threads(n)
.enable_all()
.build()
.unwrap()
})
}
fn current_rt() -> Box<dyn Fn() -> Runtime> {
Box::new(|| Builder::new_current_thread().enable_all().build().unwrap())
}
fn rt_combinations() -> Vec<Box<dyn Fn() -> Runtime>> {
vec![
current_rt(),
multi_rt(1),
multi_rt(2),
multi_rt(8),
multi_rt(64),
multi_rt(256),
]
}
#[test]
fn shutdown_runtime_while_performing_io_uring_ops() {
if !io_uring_supported() {
return;
}
fn run(rt: Runtime) {
let (done_tx, done_rx) = mpsc::channel();
let (_tmp, path) = create_tmp_files(1);
// keep 100 permits
const N: i32 = 100;
rt.spawn(async move {
let path = path[0].clone();
// spawning a bunch of uring operations.
let mut futs = vec![];
// spawning a bunch of uring operations.
for _ in 0..N {
let path = path.clone();
let mut fut = Box::pin(try_exists(path));
poll_fn(|cx| {
assert_pending!(fut.as_mut().poll(cx));
Poll::<()>::Pending
})
.await;
futs.push(fut);
}
tokio::task::yield_now().await;
});
std::thread::spawn(move || {
rt.shutdown_timeout(Duration::from_millis(300));
done_tx.send(()).unwrap();
});
done_rx.recv().unwrap();
}
for rt in rt_combinations() {
run(rt());
}
}
#[test]
fn stat_many_files() {
fn run(rt: Runtime) {
const NUM_FILES: usize = 512;
let (_tmp_files, paths): (Vec<NamedTempFile>, Vec<PathBuf>) = create_tmp_files(NUM_FILES);
rt.block_on(async move {
let tracker = TaskTracker::new();
for i in 0..10_000 {
let path = paths.get(i % NUM_FILES).unwrap().clone();
tracker.spawn(async move {
let exists = try_exists(path).await.unwrap();
assert!(exists);
});
}
tracker.close();
tracker.wait().await;
});
}
for rt in rt_combinations() {
run(rt());
}
}
#[tokio::test]
async fn stat_small_large_files() {
let (_tmp, path) = create_large_temp_file();
let exists = try_exists(path).await.unwrap();
assert!(exists);
let (_tmp, path) = create_small_temp_file();
let exists = try_exists(path).await.unwrap();
assert!(exists);
}
#[tokio::test]
async fn stat_nonexistent_file() {
let path = tempdir().unwrap().path().join("nonexistent_path");
let exists = try_exists(path).await.unwrap();
assert!(!exists);
}
// Error is not produced on Linux 4.19 (Linux 7.1 it works)
#[tokio::test]
#[cfg_attr(miri, ignore)] // No `chmod` in miri.
#[cfg(unix)]
async fn stat_permission_denied() {
if !io_uring_supported() {
return;
}
let dir = tempdir().unwrap();
let permission_denied_directory_path = dir.path().join("baz");
create_dir(&permission_denied_directory_path).await.unwrap();
let permission_denied_file_path = permission_denied_directory_path.join("baz.txt");
write(&permission_denied_file_path, b"Hello File!")
.await
.unwrap();
let mut perms = metadata(&permission_denied_directory_path)
.await
.unwrap()
.permissions();
perms.set_mode(0o244);
set_permissions(&permission_denied_directory_path, perms)
.await
.unwrap();
let permission_denied_result = try_exists(permission_denied_file_path).await;
assert_eq!(
permission_denied_result
.err()
.unwrap()
.raw_os_error()
.unwrap(),
libc::EACCES
);
}
#[tokio::test]
#[cfg(unix)]
async fn stat_filesystem_loop() {
let dir = tempdir().unwrap();
let first_symlink = dir.path().join("bar");
let second_symlink = dir.path().join("foo");
symlink(&first_symlink, &second_symlink).await.unwrap();
symlink(&second_symlink, &first_symlink).await.unwrap();
// Both symlinks loop on each other, so stating either one should produce
// a file system loop error. This produces a `std::io::ErrorKind::FilesystemLoop`
// error, but that error is gated behind io_error_more feature, and we can't be
// sure if that name will ever be changed, so preferred using libc::ELOOP instead
let filesystem_loop_result = try_exists(first_symlink).await;
assert_eq!(
filesystem_loop_result
.err()
.unwrap()
.raw_os_error()
.unwrap(),
libc::ELOOP
);
let filesystem_loop_result = try_exists(second_symlink).await;
assert_eq!(
filesystem_loop_result
.err()
.unwrap()
.raw_os_error()
.unwrap(),
libc::ELOOP
);
}
#[tokio::test]
async fn stat_path_name_too_long() {
let dir = tempdir().unwrap();
// if we stat a file whose name is above PATH_MAX (Linux is 4096 bytes, Windows 260 chars or 32767 chars if extended
// path is permitted), we should receive an std::io::ErrorKind::InvalidFilename error
let long_nonexistent_path = dir.path().join(vec!["a"; (PATH_MAX + 1) as usize].join(""));
let name_too_long_result = try_exists(long_nonexistent_path).await;
assert_eq!(
name_too_long_result.err().unwrap().kind(),
std::io::ErrorKind::InvalidFilename
);
}
#[tokio::test]
async fn cancel_op_future() {
if !io_uring_supported() {
return;
}
let (_tmp_file, path): (Vec<NamedTempFile>, Vec<PathBuf>) = create_tmp_files(1);
let path = path[0].clone();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let handle = tokio::spawn(async move {
poll_fn(|cx| {
let fut = try_exists(path.clone());
// the first poll should return Pending.
assert_pending!(Box::pin(fut).poll_unpin(cx));
tx.send(true).unwrap();
Poll::<()>::Pending
})
.await;
});
// Wait for the first poll
let val = rx.recv().await;
assert!(val.unwrap());
handle.abort();
let res = handle.await.unwrap_err();
assert!(res.is_cancelled());
}
fn create_tmp_files(num_files: usize) -> (Vec<NamedTempFile>, Vec<PathBuf>) {
let mut files = Vec::with_capacity(num_files);
for _ in 0..num_files {
let mut tmp = NamedTempFile::new().unwrap();
let buf = vec![20; 1023];
tmp.write_all(&buf).unwrap();
let path = tmp.path().to_path_buf();
files.push((tmp, path));
}
files.into_iter().unzip()
}
fn create_large_temp_file() -> (NamedTempFile, PathBuf) {
let mut tmp = NamedTempFile::new().unwrap();
let buf = create_buf(5000);
tmp.write_all(&buf).unwrap();
let path = tmp.path().to_path_buf();
(tmp, path)
}
fn create_small_temp_file() -> (NamedTempFile, PathBuf) {
let mut tmp = NamedTempFile::new().unwrap();
let buf = create_buf(20);
tmp.write_all(&buf).unwrap();
let path = tmp.path().to_path_buf();
(tmp, path)
}
fn create_buf(length: usize) -> Vec<u8> {
(0..length).map(|i| i as u8).collect()
}
+161
View File
@@ -0,0 +1,161 @@
#![cfg(all(
tokio_unstable,
feature = "io-uring",
feature = "rt",
feature = "fs",
target_os = "linux"
))]
mod support {
pub(crate) mod io_uring;
}
use std::fmt::Debug;
use std::fs;
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;
use tempfile::NamedTempFile;
use tokio::fs::read;
use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
use tokio::time::timeout;
use tokio_test::assert_pending;
use crate::support::io_uring::io_uring_supported;
/// Count currently-open fds in this process.
fn fd_count() -> usize {
fs::read_dir("/proc/self/fd").unwrap().count()
}
/// First poll:
/// - polls the inner `tokio::fs::read()` future once,
/// - expects `Pending` so we know we took the io_uring path,
/// - registers the task waker with Tokio's uring machinery.
///
/// Second poll:
/// - happens after the kernel completes the open and Tokio stores the CQE as
/// `Lifecycle::Completed(cqe)` and wakes the task,
/// - polls inner future again to execute `Op::file_metadata`/Statx operation
///
/// Third poll:
/// - happens after the kernel completes the statx and Tokio stores the CQE as
/// `Lifecycle::Completed(cqe)` and wakes the task,
/// - stays pending forever so the task can be aborted.
///
/// Aborting the task here drops the inner `Op::file_metadata()` future while Tokio
/// still has a completed CQE sitting in the slab.
struct PollOpenOnceThenNeverRepoll<F> {
inner: Pin<Box<F>>,
poll_senders: Vec<UnboundedSender<()>>,
poll_pending_counts: usize,
}
impl<F: Future> Future for PollOpenOnceThenNeverRepoll<F>
where
F::Output: Debug,
{
type Output = F::Output;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let poll_count = self.poll_pending_counts;
let sender_count = self.poll_senders.len();
if poll_count < sender_count {
// The first two poll should return `Poll::Pending`` because it verifies
// that io_uring is enabled and then executes the open operation in
// `tokio::fs::read`
if poll_count != sender_count - 1 {
assert_pending!(self.inner.as_mut().poll(cx));
}
self.poll_pending_counts += 1;
self.poll_senders[poll_count].send(()).unwrap();
}
Poll::Pending
}
}
async fn completed_then_dropped_before_repoll(path: PathBuf) {
// `tokio::fs::read` has an Open operation occurring (via OpenOptions) before doing a
// Statx operation (via `Op::file_metadata`), we must poll and complete the Open operation
// first to then be able to poll the Statx operation.
const POLL_PENDING_COUNT: usize = 3;
let mut poll_senders: Vec<UnboundedSender<()>> = Vec::new();
let mut poll_receivers = Vec::new();
for _ in 0..POLL_PENDING_COUNT {
let (sender, receiver) = unbounded_channel();
poll_senders.push(sender);
poll_receivers.push(receiver);
}
let handle = tokio::spawn(async move {
let fut = read(&path);
PollOpenOnceThenNeverRepoll {
inner: Box::pin(fut),
poll_senders,
poll_pending_counts: 0,
}
.await
});
for (i, poll_receiver) in poll_receivers
.iter_mut()
.enumerate()
.take(POLL_PENDING_COUNT)
{
// Wait until the inner open has been polled once and registered with io_uring.
if i == 0 {
poll_receiver.recv().await.unwrap();
} else {
// Wait until Tokio wakes the task because the open/statx completed. At this point
// the CQE should already be stored as `Lifecycle::Completed(cqe)`.
let _ = timeout(Duration::from_secs(2), poll_receiver.recv()).await;
}
}
// Abort now, before the inner statx future gets re-polled and consumes the CQE.
handle.abort();
let err = handle.await.unwrap_err();
assert!(err.is_cancelled(), "task was not cancelled as expected");
}
#[test]
fn uring_completed_then_dropped() {
if !io_uring_supported() {
return;
}
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let before = fd_count();
let tmp = NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
for _ in 0..128 {
completed_then_dropped_before_repoll(path.clone()).await;
}
// Give completions a moment to settle before counting fds.
tokio::time::sleep(Duration::from_millis(250)).await;
let after = fd_count();
let leaked = after.saturating_sub(before);
// Since we are opening 128 files, we expect that the related fds
// related to this operation will be closed. Since some other fds
// can be opened in the meantime, we expect this number to be higher
// than the counter before opening the files. This number could be
// lower, but to avoid test flakiness we check that this is at most
// half the number of the file we opened to check if there's a leak.
assert!(leaked <= 64);
});
}