Files
rustfs/experiments/io-uring-cancel-spike/tests/cancel.rs
T
houseme bf81a9bab0 fix(experiments): import io_uring cancel-safety spike and apply backlog#1051 audit remediation (#4625)
* chore(experiments): import io_uring cancel-safety spike as audit baseline (backlog#894)

Import the Spike 0 io_uring cancel-safety prototype from the closed PR #4381
branch (houseme/p2-spike0-uring-cancel-safety) as the baseline for the
backlog#1051 audit remediation. This crate is a standalone workspace and is
deliberately kept out of the main Cargo.lock/build graph (NOT production code).

Subsequent commits apply the fixes tracked in backlog#1051 sub-issues, one
commit per issue.

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(experiments/uring): drain probe SQE to its CQE before releasing buffer (rustfs/backlog#1053)

The probe path had no pending-table backstop: after pushing the read SQE,
any early return (`submit_and_wait` error, missing CQE) dropped the probe
buffer and file while the read could still be in flight in io-wq, and the
caller dropped/unmapped the ring on the error path. If the kernel then wrote
the 512-byte result into that freed heap block, it was a use-after-free — the
exact bug class this spike exists to prevent, living in its own probe path.

Fix: once the SQE is pushed, drain to its CQE via `drain_probe_cqe`, retrying
the WAIT on EINTR without re-pushing (the kernel consumed the SQE atomically
before the wait). A bounded attempt count prevents a probe against a hung
device from blocking forever; on any drain failure the buffer (and file) are
`mem::forget`-ed ("leak over UAF") so the kernel can never write into freed
memory. Unmapping the ring on its own is safe; only the user buffer must
survive.

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(experiments/uring): split probe/runtime/transient errno classes, guard offset (rustfs/backlog#1059)

`is_expected_restriction` folded EINVAL into the "environment restricted"
class, but at runtime EINVAL is triple-meaning — offset > i64::MAX (signed
loff_t), O_DIRECT misalignment, and setup entries over the cap. Implementing
the rustfs/backlog#1048 permanent-degradation latch literally against this
class would fault a healthy disk off io_uring on one alignment retry or
offset-arithmetic bug.

Document that the class is probe-time ONLY and that P2 must split errnos into
probe-restriction / runtime-parameter-error / transient (EINTR/EAGAIN). Add a
concrete guard: `submit` rejects offset > i64::MAX with an InvalidInput error
instead of letting it reach the kernel as a runtime EINVAL. The probe EINTR
half of this issue is already handled by the drain loop from rustfs/backlog#1053
(retry the wait, never re-push).

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(experiments/uring): abort on driver-thread panic instead of freeing in-flight buffers (rustfs/backlog#1054)

The ownership model's "CQE is the only reclamation point" invariant held only
while the driver thread never unwound. On a panic inside drive(), Rust drop
order freed the `pending` table (every in-flight buffer) before the ring,
while the kernel could still be writing into those buffers → mass UAF.
`catch_unwind` cannot fix this: the destructors run during the unwind, before
the catch boundary.

Move ring + pending + backlog into a `DriverState` whose `Drop` checks
`thread::panicking()` and calls `process::abort()` BEFORE any field destructor
runs — leaving the ring mapped and the buffers allocated (leak over UAF). The
capacity-overflow panic that made this reachable (caller-controlled `len`) is
closed at the source in the len-guard commit (rustfs/backlog#1057).

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(experiments/uring): reject reads above MAX_RW_COUNT to stop u32 truncation (rustfs/backlog#1057)

The SQE length field is `len as u32`, so len == 4 GiB became a 0-byte read the
kernel answered with res=0 → an Ok(empty) the caller decodes as a false EOF
(and len > 4 GiB read only the low 32 bits). Silent truncation (CWE-197),
forbidden by the repo's rust-code-quality rules.

`submit` now rejects len > MAX_RW_COUNT (2 GiB - 4 KiB) with InvalidInput; the
`len as u32` cast in the driver is consequently lossless. This also closes the
caller-controlled capacity-overflow panic feeding rustfs/backlog#1054. P2 must
chunk reads larger than the cap.

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(experiments/uring): resubmit short reads to satisfy the whole-range contract (rustfs/backlog#1058)

CQE res >= 0 was truncated and delivered as final with no resubmit loop, and
Pending did not even store the requested length. io_uring can legally
short-read a regular file (io-wq signal interruption, NOWAIT partial page
cache, O_DIRECT tail blocks), while LocalIoBackend::pread_bytes is a whole-
range contract — a short shard fed to EC bitrot verification surfaces as
intermittent, hard-to-attribute integrity/quorum errors.

Track offset/nread in Pending and drive a resubmit loop: a short non-EOF read
re-queues the remainder into buf[nread..], keeping the entry (and its buffer,
and in_flight) until the FINAL CQE of the logical read; res == 0 is treated as
a real EOF. The resubmitted SQE reuses the op's user_data, so a late
ASYNC_CANCEL from a dropped future still cancels the logical read cleanly.

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(experiments/uring): bound the shutdown drain and record cancel outcomes (rustfs/backlog#1055)

Shutdown made "drain to in_flight == 0" a hard precondition for unmapping the
ring, but ASYNC_CANCEL is best-effort: it cannot interrupt a regular-file read
already executing in io-wq, so on a D-state/NFS-hung disk the CQE may never
arrive and drain-to-zero never terminates — the driver loops forever and
shutdown()/Drop join blocks the caller (and any tokio worker) permanently.
This is an internal contradiction (safe unmap needs drain; a bad disk makes
drain unbounded) in the very environment io_uring exists to handle.

Add a bounded-drain escape hatch: after DRAIN_TIMEOUT with ops still in flight,
leak the whole DriverState (ring stays mapped, buffers stay allocated — leak
over UAF) and exit so shutdown() returns. Soften shutdown()'s hard assert to a
warning for that degraded path; clean-drain tests still assert in_flight == 0
themselves. Also record the ASYNC_CANCEL three-state result
(succeeded/not-found/already-executing) so the hung-disk signal is observable
instead of discarded.

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(experiments/uring): assert NODROP, monitor CQ overflow, handle EBUSY (rustfs/backlog#1056)

In-flight had no upper bound and could exceed CQ capacity (entries=64 → CQ=128)
with zero overflow handling: no NODROP check, no overflow read, no EBUSY
handling. A lost CQE means its pending entry is never reclaimed, drain never
completes and shutdown hangs — and the spike only avoided this by accidental
reliance on the io-uring crate's auto-flush + NODROP kernel + poll cadence,
all of which P2's eventfd/AsyncFd reaping removes.

Assert the NODROP feature at probe (degrade via ENOSYS otherwise), monitor the
kernel CQ-overflow counter each turn and surface a non-zero value as fatal, and
handle submit() EBUSY as CQ-overflow backpressure (keep the backlog, reap this
turn) instead of swallowing it. The hard in-flight bound (permits ≤ CQ capacity)
lands with the backpressure work in rustfs/backlog#1060.

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(experiments/uring): add backpressure with permits released at the CQE (rustfs/backlog#1060)

Submission was unbounded (unbounded mpsc + uncapped pending/backlog), so a
concurrent large-object read storm had no memory ceiling. The subtler trap:
the planned SQ-depth semaphore, implemented the natural RAII way (permit held
by the ReadHandle/future), would release permits at future drop while orphan
buffers stay resident in the pending table awaiting slow-disk CQEs —
decoupling the permit count from resident memory and reopening the DoS surface
exactly in the EC quorum-drop hot path.

Add a `Backpressure` semaphore sized to the SQ depth (entries < CQ capacity, so
CQ overflow is structurally unreachable). `submit` acquires before handing the
op to the driver; the driver releases the permit at the CQE (pending-table
removal), NOT at future drop, tying the in-flight/memory bound to actual kernel
residency. Permits are balanced on the shutting-down reject and send-failure
paths, and the driver wakes all waiters on exit.

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(experiments/uring): open the probe file via O_TMPFILE instead of a predictable path (rustfs/backlog#1061)

The probe wrote a predictable temp path (uring-spike-probe-{pid}-{seq}) with
std::fs::write (O_CREAT|O_TRUNC, no O_EXCL/O_NOFOLLOW): a local attacker could
pre-plant a symlink there and have the process — often root — truncate and
overwrite an arbitrary target (CWE-59/377), with a TOCTOU window between write
and open (CWE-367). This probe is the direct blueprint for P2's per-disk
startup probe, so copied verbatim it becomes a production vulnerability.

Open via O_TMPFILE (anonymous inode, no name → nothing to plant a symlink at,
no TOCTOU, no leftover), falling back to O_CREAT|O_EXCL|O_NOFOLLOW + 0600 +
per-process nonce + immediate unlink on filesystems without O_TMPFILE. P2's
per-disk probe should create inside the tested data-disk directory the same
way, which also validates that disk's filesystem + io_uring combination.

Co-Authored-By: heihutu <heihutu@gmail.com>

* docs(experiments/uring): correct invariant 2 mechanism, add invariants 6/7/8 (rustfs/backlog#1063)

Invariant 2 (the spike's flagship finding) mis-described the fd-reuse hazard:
it claimed the danger window is submission→CQE and that the kernel would
"write into someone else's file". Both are wrong — a submitted op holds a
struct file reference and is immune to fd close/reuse; the real window is SQE
construction (as_raw_fd) → io_uring_enter (backlog residency), and for a READ
the consequence is reading the WRONG file, not writing. A P2 optimization
reasoning from the false premise (drop Arc<File> after submit / registered-file
table) would step straight into it.

Correct the mechanism and add the invariants this audit hardened: driver-thread
unwind safety (6), backpressure permit released at the CQE (7), reused-buffer
content hygiene (8, detailed in rustfs/backlog#1062), plus the errno three-class
contract, bounded-drain escape hatch, and short-read resubmit responsibility.
Mark the now-remediated items in the leftover list.

Co-Authored-By: heihutu <heihutu@gmail.com>

* docs(experiments/uring): pin the reused-buffer content-hygiene invariant for P3 (rustfs/backlog#1062)

The spike leaks nothing today (fresh zeroed buffer per op + truncate to res),
but rustfs/backlog#1048's P3 constraint mandates a driver-owned aligned slab
whose buffers are reused across requests as dirty memory. Both the SPIKE
invariants and the #1048 constraint address only buffer LIFETIME (UAF), not
content hygiene: once buffers are reused, any path that forgets to bound the
caller-visible bytes to cqe.res (O_DIRECT full-block read sliced upstream,
error path returning the whole buffer) discloses a previous tenant's object
data (CWE-226) in an S3 store.

Pin invariant 8: reused-buffer bytes visible to the caller must be strictly
⊆ [0, cqe.res). Documented in SPIKE.md and marked at the delivery point in the
driver so P3 preserves it; needs a dirty-buffer + short-read regression test.

Co-Authored-By: heihutu <heihutu@gmail.com>

* test(experiments/uring): pin fd ownership and orphan-integrity directly (rustfs/backlog#1064)

The memory-safety assertions were all counter proxies, and invariant 2 (fd
owned by the pending table) had zero coverage — deleting Pending.file compiled
and left every test green because each test kept its own Arc<File> alive.

Add two direct observations: pending_table_owns_fd_after_caller_drop drops the
caller's Arc while the op is in flight and asserts F_GETFD still succeeds (only
the pending table's clone keeps the fd open; removing that field would close it
→ EBADF). orphan_in_flight_does_not_corrupt_delivered_reads keeps an orphaned
buffer in flight while 64 delivered reads must return byte-exact, asserting the
orphan buffer is not reclaimed early and its kernel writes corrupt nothing. A
driver-level poison/canary leg is noted as a P2 acceptance-matrix item (ASAN
cannot see a kernel write into a freed buffer).

Co-Authored-By: heihutu <heihutu@gmail.com>

* test(experiments/uring): cover CQ-overflow safety and read boundaries (rustfs/backlog#1065)

The suite never approached CQ capacity and never touched EOF/len boundaries.
Add no_cq_overflow_under_load (300 ops through a CQ of 128 with backpressure
capping in-flight at 64, asserting cq_overflow stays 0 and all deliver),
boundary_reads (len==0, read at EOF, a cross-EOF short read delivered to a
live receiver exercising the positioned resubmit path, and the rejected
huge-len/huge-offset guards), and pipe_half_close_reads_eof (a closed write
end surfaces res==0 EOF).

Co-Authored-By: heihutu <heihutu@gmail.com>

* test(experiments/uring): cover Drop-without-shutdown and de-flake cancel_stress (rustfs/backlog#1066)

All tests ended via explicit shutdown(), so the UringDriver Drop impl's
live-thread branch (send Shutdown before join) was never exercised; add
drop_without_shutdown_drains_and_cancels which drops the driver with ops in
flight and asserts the held futures resolve to ECANCELED (a join-first
regression or unbounded hang makes it hang).

Also de-flake cancel_stress: the exact assert delivered == OPS/2 raced the
driver — an even-i read can complete between read_at returning and drop(handle),
delivering to the still-live receiver and flipping the split. Relax to the
deterministic conservation identity plus delivered >= OPS/2.

Co-Authored-By: heihutu <heihutu@gmail.com>

* test(experiments/uring): make run-docker.sh assert each leg's real path (rustfs/backlog#1067)

Both legs ran the identical cargo test and checked only the exit code, and a
skip is indistinguishable from a real pass at that level: leg 1 depended on the
host Docker's default seccomp "usually" blocking io_uring, and leg 2 printed
"both legs passed" even if every test skipped (vacuous pass — zero real
io_uring coverage).

Add an explicit seccomp profile (seccomp-block-uring.json) that returns EPERM
for io_uring_setup/enter/register so leg 1 deterministically hits the
graceful-degradation path regardless of host defaults, and assert leg 1
actually degraded (SKIP lines present) while leg 2 did NOT skip a single test
(io_uring really ran). Either violation now fails the harness.

Co-Authored-By: heihutu <heihutu@gmail.com>

* style(experiments/uring): apply repo rustfmt (max_width=130) to the audit changes

Normalize the formatting of the remediation code to the repo rustfmt.toml.
Pure formatting; no behavior change. clippy --all-targets -D warnings is clean.

Co-Authored-By: heihutu <heihutu@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-09 23:00:57 +08:00

473 lines
18 KiB
Rust

// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Cancel-safety acceptance tests for backlog#894 Spike 0.
//!
//! In a restricted environment (Docker default seccomp, gVisor) the probe
//! fails with an expected-restriction errno and every test degrades to a
//! skip — the same contract P2's production probe must honor. Run under
//! `--security-opt seccomp=unconfined` (see run-docker.sh) to exercise the
//! real io_uring paths.
#![cfg(target_os = "linux")]
use std::fs::File;
use std::io::Write;
use std::os::fd::{AsRawFd, FromRawFd};
use std::sync::Arc;
use std::time::{Duration, Instant};
use io_uring_cancel_spike::UringDriver;
fn driver_or_skip(name: &str) -> Option<UringDriver> {
match UringDriver::probe_and_start(64) {
Ok(d) => Some(d),
Err(e) => {
assert!(
e.is_expected_restriction(),
"probe failed OUTSIDE the expected restriction errno class \
(EACCES/EPERM/ENOSYS/EINVAL/EOPNOTSUPP): {e:?}"
);
eprintln!("SKIP {name}: restricted environment, graceful degradation path taken ({e:?})");
None
}
}
}
/// Deterministic pseudo-random content so reads are verifiable.
fn make_content(len: usize) -> Vec<u8> {
let mut state: u64 = 0x2545F4914F6CDD1D;
(0..len)
.map(|_| {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state as u8
})
.collect()
}
fn temp_file_with(content: &[u8], tag: &str) -> (std::path::PathBuf, Arc<File>) {
let path = std::env::temp_dir().join(format!("uring-spike-{tag}-{}", std::process::id()));
std::fs::write(&path, content).expect("write temp file");
let file = Arc::new(File::open(&path).expect("open temp file"));
(path, file)
}
/// An OS pipe whose read side never completes until we write — the only
/// deterministic way to hold an op in flight across a future drop.
fn os_pipe() -> (Arc<File>, File) {
let mut fds = [0i32; 2];
// SAFETY: fds is a valid out-array; on success both fds are owned here
// and immediately wrapped in File which takes over closing them.
let rc = unsafe { libc::pipe(fds.as_mut_ptr()) };
assert_eq!(rc, 0, "pipe(2) failed");
let read = unsafe { File::from_raw_fd(fds[0]) };
let write = unsafe { File::from_raw_fd(fds[1]) };
(Arc::new(read), write)
}
async fn wait_until(deadline: Duration, mut cond: impl FnMut() -> bool) -> bool {
let start = Instant::now();
while start.elapsed() < deadline {
if cond() {
return true;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
cond()
}
/// Baseline: completed reads return exactly what pread would.
#[tokio::test(flavor = "multi_thread")]
async fn read_matches_std() {
let Some(driver) = driver_or_skip("read_matches_std") else {
return;
};
const LEN: usize = 8 << 20;
let content = make_content(LEN);
let (path, file) = temp_file_with(&content, "correctness");
for i in 0..64usize {
let offset = (i * 131_071) % (LEN - 70_000);
let len = 1 + (i * 8_191) % 65_536;
let got = driver
.read_at(Arc::clone(&file), offset as u64, len)
.await
.expect("read failed");
assert_eq!(got, &content[offset..offset + len], "mismatch at offset {offset} len {len}");
}
let snap = driver.shutdown();
assert_eq!(snap.delivered, 64);
assert_eq!(snap.orphan_reclaimed, 0);
let _ = std::fs::remove_file(path);
}
/// THE core spike assertion: drop the future while the op is provably still
/// in flight (blocked pipe read, no cancel submitted) and verify the buffer
/// stays owned by the driver until the CQE finally arrives.
#[tokio::test(flavor = "multi_thread")]
async fn dropped_future_buffer_lives_until_cqe() {
let Some(driver) = driver_or_skip("dropped_future_buffer_lives_until_cqe") else {
return;
};
let (pipe_read, mut pipe_write) = os_pipe();
let handle = driver.read_current(Arc::clone(&pipe_read), 4096).without_cancel_on_drop();
assert!(
wait_until(Duration::from_secs(2), || driver.stats().in_flight == 1).await,
"read never reached in-flight state"
);
drop(handle);
// No cancel was submitted and the pipe is empty: the op MUST stay in
// flight and the buffer MUST NOT be reclaimed, no matter how long the
// future has been gone.
tokio::time::sleep(Duration::from_millis(300)).await;
let snap = driver.stats();
assert_eq!(snap.in_flight, 1, "op vanished without a CQE");
assert_eq!(snap.orphan_reclaimed, 0, "buffer reclaimed before CQE — UAF window!");
// Now let the kernel complete the read; the CQE both writes into the
// driver-owned buffer (safely) and triggers reclamation.
pipe_write.write_all(&[0xAB; 512]).expect("pipe write");
assert!(
wait_until(Duration::from_secs(2), || {
let s = driver.stats();
s.in_flight == 0 && s.orphan_reclaimed == 1
})
.await,
"orphaned op was not reclaimed at CQE: {:?}",
driver.stats()
);
driver.shutdown();
}
/// Default drop path: ASYNC_CANCEL accelerates the CQE so the orphaned
/// buffer is reclaimed promptly without any data ever arriving.
#[tokio::test(flavor = "multi_thread")]
async fn async_cancel_accelerates_reclaim() {
let Some(driver) = driver_or_skip("async_cancel_accelerates_reclaim") else {
return;
};
let (pipe_read, pipe_write) = os_pipe();
let handle = driver.read_current(Arc::clone(&pipe_read), 4096);
assert!(
wait_until(Duration::from_secs(2), || driver.stats().in_flight == 1).await,
"read never reached in-flight state"
);
drop(handle); // submits IORING_OP_ASYNC_CANCEL
assert!(
wait_until(Duration::from_secs(2), || {
let s = driver.stats();
s.in_flight == 0 && s.orphan_reclaimed == 1
})
.await,
"cancel did not reclaim the orphan: {:?}",
driver.stats()
);
drop(pipe_write);
driver.shutdown();
}
/// Volume test modeling the EC quorum pattern: many concurrent reads, half
/// the futures dropped immediately. Every op must be accounted for as either
/// delivered or orphan-reclaimed, and survivors must return correct bytes.
#[tokio::test(flavor = "multi_thread")]
async fn cancel_stress_accounts_for_every_buffer() {
let Some(driver) = driver_or_skip("cancel_stress_accounts_for_every_buffer") else {
return;
};
const LEN: usize = 8 << 20;
const OPS: usize = 256;
const READ_LEN: usize = 64 << 10;
let content = make_content(LEN);
let (path, file) = temp_file_with(&content, "stress");
let mut kept = Vec::new();
for i in 0..OPS {
let offset = (i * 97_611) % (LEN - READ_LEN);
let handle = driver.read_at(Arc::clone(&file), offset as u64, READ_LEN);
if i % 2 == 0 {
drop(handle); // dropped mid-flight or post-completion — both must be safe
} else {
kept.push((offset, handle));
}
}
for (offset, handle) in kept {
let got = handle.await.expect("kept read failed");
assert_eq!(got, &content[offset..offset + READ_LEN], "mismatch at offset {offset}");
}
let snap = driver.shutdown();
assert_eq!(snap.submitted, OPS as u64);
// The exact split delivered == OPS/2 was flaky (C18, rustfs/backlog#1066):
// an even-i read can finish between read_at returning and drop(handle),
// delivering to the still-live receiver and flipping the split — all while
// the ownership model behaves correctly. Only the conservation identity is
// deterministic; the kept half guarantees delivered >= OPS/2.
assert!(snap.delivered >= (OPS / 2) as u64, "kept half not all delivered: {snap:?}");
assert_eq!(
snap.delivered + snap.orphan_reclaimed,
OPS as u64,
"some buffers are unaccounted for: {snap:?}"
);
let _ = std::fs::remove_file(path);
}
/// Shutdown with ops still blocked in flight must cancel + drain them before
/// the driver thread exits (and the ring is unmapped).
#[tokio::test(flavor = "multi_thread")]
async fn shutdown_drains_in_flight_ops() {
let Some(driver) = driver_or_skip("shutdown_drains_in_flight_ops") else {
return;
};
let (pipe_read, pipe_write) = os_pipe();
let h1 = driver.read_current(Arc::clone(&pipe_read), 1024);
let h2 = driver.read_current(Arc::clone(&pipe_read), 1024);
assert!(
wait_until(Duration::from_secs(2), || driver.stats().in_flight == 2).await,
"reads never reached in-flight state"
);
// shutdown() cancels both, drains to in_flight == 0 (asserted inside),
// and joins the thread. The held futures then resolve with ECANCELED.
let snap = driver.shutdown();
assert_eq!(snap.in_flight, 0);
assert_eq!(snap.delivered + snap.orphan_reclaimed, snap.submitted);
for h in [h1, h2] {
let err = h.await.expect_err("blocked pipe read cannot have succeeded");
assert_eq!(err.raw_os_error(), Some(libc::ECANCELED), "unexpected error: {err:?}");
}
drop(pipe_write);
}
/// Invariant 2 regression (C20, rustfs/backlog#1064): the pending table — not
/// the caller — owns the fd. Deleting `Pending.file` used to leave every test
/// green because each test kept its own Arc<File> alive; this pins it. Drop the
/// caller's Arc while the op is in flight (bare drop, no cancel) and assert the
/// fd is STILL open: only the pending table's clone keeps it alive. If that
/// field were removed, the File would close the fd and F_GETFD returns EBADF.
#[tokio::test(flavor = "multi_thread")]
async fn pending_table_owns_fd_after_caller_drop() {
let Some(driver) = driver_or_skip("pending_table_owns_fd_after_caller_drop") else {
return;
};
let (pipe_read, mut pipe_write) = os_pipe();
let raw_fd = pipe_read.as_raw_fd();
let handle = driver.read_current(Arc::clone(&pipe_read), 64).without_cancel_on_drop();
assert!(
wait_until(Duration::from_secs(2), || driver.stats().in_flight == 1).await,
"read never reached in-flight state"
);
// The pending table is now the ONLY owner of the fd.
drop(handle);
drop(pipe_read);
tokio::time::sleep(Duration::from_millis(50)).await;
// SAFETY: F_GETFD only queries the descriptor; it neither closes nor
// mutates it.
let rc = unsafe { libc::fcntl(raw_fd, libc::F_GETFD) };
assert_ne!(
rc,
-1,
"fd was closed while an op is in flight — the pending table does not own it \
(invariant 2 unprotected): {}",
std::io::Error::last_os_error()
);
// Complete the op so the driver reclaims cleanly.
pipe_write.write_all(&[0x5A; 64]).expect("pipe write");
assert!(
wait_until(Duration::from_secs(2), || driver.stats().in_flight == 0).await,
"op was not reclaimed at CQE"
);
driver.shutdown();
}
/// Memory-safety integrity (C14, rustfs/backlog#1064): while an orphaned
/// blocked-pipe read holds a driver-owned buffer in flight the whole time, many
/// delivered reads must still come back byte-exact — the kernel writing into
/// the orphan's still-owned buffer must not corrupt anything, and the orphan
/// buffer must NOT be reclaimed before its own CQE. This is a direct
/// data-integrity observation, not just a counter identity. (A driver-level
/// poison/canary leg is a P2 acceptance-matrix item; ASAN cannot see a kernel
/// write into a freed buffer, so it is not the mechanism.)
#[tokio::test(flavor = "multi_thread")]
async fn orphan_in_flight_does_not_corrupt_delivered_reads() {
let Some(driver) = driver_or_skip("orphan_in_flight_does_not_corrupt_delivered_reads") else {
return;
};
let (pipe_read, mut pipe_write) = os_pipe();
let orphan = driver.read_current(Arc::clone(&pipe_read), 4096).without_cancel_on_drop();
assert!(
wait_until(Duration::from_secs(2), || driver.stats().in_flight == 1).await,
"orphan never reached in-flight state"
);
drop(orphan); // bare drop: buffer stays owned by the driver until its CQE
const LEN: usize = 1 << 20;
let content = make_content(LEN);
let (path, file) = temp_file_with(&content, "canary");
for i in 0..64usize {
let offset = (i * 9_973) % (LEN - 4096);
let got = driver
.read_at(Arc::clone(&file), offset as u64, 4096)
.await
.expect("read failed");
assert_eq!(got, &content[offset..offset + 4096], "delivered read corrupted at offset {offset}");
}
assert_eq!(driver.stats().orphan_reclaimed, 0, "orphan buffer reclaimed before its CQE");
pipe_write.write_all(&[0u8; 512]).expect("pipe write");
driver.shutdown();
let _ = std::fs::remove_file(path);
}
/// CQ-overflow safety (C15, rustfs/backlog#1065). With backpressure capping
/// in-flight at the SQ depth (64) below CQ capacity (128), overflow is
/// structurally unreachable. Drive far more ops than CQ capacity through and
/// assert the kernel overflow counter stays 0 and every op is delivered.
#[tokio::test(flavor = "multi_thread")]
async fn no_cq_overflow_under_load() {
let Some(driver) = driver_or_skip("no_cq_overflow_under_load") else {
return;
};
const LEN: usize = 8 << 20;
const OPS: usize = 300; // > CQ capacity (128) many times over
const READ_LEN: usize = 4096;
let content = make_content(LEN);
let (path, file) = temp_file_with(&content, "overflow");
let mut kept = Vec::new();
for i in 0..OPS {
let offset = (i * 4_093) % (LEN - READ_LEN);
kept.push((offset, driver.read_at(Arc::clone(&file), offset as u64, READ_LEN)));
}
for (offset, handle) in kept {
let got = handle.await.expect("read failed");
assert_eq!(got, &content[offset..offset + READ_LEN], "mismatch at offset {offset}");
}
let snap = driver.shutdown();
assert_eq!(snap.cq_overflow, 0, "CQ overflowed under load: {snap:?}");
assert_eq!(snap.delivered, OPS as u64);
let _ = std::fs::remove_file(path);
}
/// Boundary reads on a regular file (C16, rustfs/backlog#1065): len==0, read at
/// EOF, a cross-EOF short read delivered to a live receiver (exercises the C9
/// resubmit loop), and the rejected huge-len / huge-offset guards (C6/C7).
#[tokio::test(flavor = "multi_thread")]
async fn boundary_reads() {
let Some(driver) = driver_or_skip("boundary_reads") else {
return;
};
const LEN: usize = 4096;
let content = make_content(LEN);
let (path, file) = temp_file_with(&content, "boundary");
// len == 0 → empty Ok.
let got = driver.read_at(Arc::clone(&file), 0, 0).await.expect("len=0 read");
assert!(got.is_empty(), "len=0 should return empty, got {}", got.len());
// offset == file size → EOF → empty Ok.
let got = driver.read_at(Arc::clone(&file), LEN as u64, 128).await.expect("EOF read");
assert!(got.is_empty(), "read at EOF should be empty, got {}", got.len());
// Read spanning past EOF → the available tail bytes, delivered to a live
// receiver via the resubmit-then-EOF path.
let got = driver
.read_at(Arc::clone(&file), (LEN - 10) as u64, 100)
.await
.expect("cross-EOF read");
assert_eq!(got, &content[LEN - 10..LEN], "cross-EOF read should return the tail only");
// len > MAX_RW_COUNT → rejected (C6); offset > i64::MAX → rejected (C7).
let err = driver
.read_at(Arc::clone(&file), 0, (1usize << 32) + 1)
.await
.expect_err("huge len must be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput, "huge len error: {err:?}");
let err = driver
.read_at(Arc::clone(&file), (i64::MAX as u64) + 1, 16)
.await
.expect_err("huge offset must be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput, "huge offset error: {err:?}");
driver.shutdown();
let _ = std::fs::remove_file(path);
}
/// Pipe half-close boundary (C16, rustfs/backlog#1065): an in-flight read whose
/// write end is closed observes EOF (res=0) and returns empty.
#[tokio::test(flavor = "multi_thread")]
async fn pipe_half_close_reads_eof() {
let Some(driver) = driver_or_skip("pipe_half_close_reads_eof") else {
return;
};
let (pipe_read, pipe_write) = os_pipe();
let handle = driver.read_current(Arc::clone(&pipe_read), 128);
assert!(
wait_until(Duration::from_secs(2), || driver.stats().in_flight == 1).await,
"read never reached in-flight state"
);
drop(pipe_write); // close write end → blocked read observes EOF
let got = handle.await.expect("pipe EOF read");
assert!(got.is_empty(), "closed-pipe read should be empty EOF, got {}", got.len());
driver.shutdown();
}
/// Drop-without-shutdown path (C17, rustfs/backlog#1066): every other test ends
/// via explicit shutdown(), so the UringDriver `Drop` impl's live-thread branch
/// was never exercised. Drop the driver directly with ops still in flight; the
/// Drop must send Shutdown BEFORE joining (send-after-join would deadlock at
/// recv), cancel + drain the in-flight ops, and join — the held futures then
/// resolve to ECANCELED. A regression (join-first, or an unbounded hang) makes
/// this test hang.
#[tokio::test(flavor = "multi_thread")]
async fn drop_without_shutdown_drains_and_cancels() {
let Some(driver) = driver_or_skip("drop_without_shutdown_drains_and_cancels") else {
return;
};
let (pipe_read, pipe_write) = os_pipe();
let h1 = driver.read_current(Arc::clone(&pipe_read), 1024);
let h2 = driver.read_current(Arc::clone(&pipe_read), 1024);
assert!(
wait_until(Duration::from_secs(2), || driver.stats().in_flight == 2).await,
"reads never reached in-flight state"
);
// No shutdown() — exercise Drop directly.
drop(driver);
for h in [h1, h2] {
let err = h.await.expect_err("blocked pipe read cannot have succeeded");
assert_eq!(err.raw_os_error(), Some(libc::ECANCELED), "unexpected error: {err:?}");
}
drop(pipe_write);
}