test(driver): deterministic fault injection for the cancel-safety escape hatches (backlog#1103)#11
Merged
Conversation
…ape hatches (rustfs/backlog#1103) The audit hardening (backlog#1051) added the abort-on-panic barrier (#1054), the bounded shutdown drain (#1055), and the probe leak-over-UAF fallback (#1053), but these three escape hatches were only ever triggered incidentally, never asserted. backlog#1103 tracks closing that gap. These failure modes cannot be reached through the normal API — a driver-thread panic, a completion that never arrives, and a probe that cannot confirm its read op — so each needs a fault-injection seam. Add a `fault-injection` cargo feature (off by default and in every published build; all gated code is `#[cfg(feature = "fault-injection")]`, so a default build compiles none of it) plus three deterministic tests: - driver_panic_aborts_instead_of_freeing_in_flight_buffers (#1054/C2): a child-process re-exec panics the driver thread with an op in flight; the parent asserts SIGABRT, proving DriverState::Drop aborts rather than unwinding and freeing buffers the kernel can still write into. - bounded_drain_bails_out_and_leaks_on_a_stuck_op (#1055/C4): an op whose completion is dropped on the floor forces shutdown's bounded drain onto its DRAIN_TIMEOUT leak path; asserts it returns (leaked, memory-safe) instead of blocking forever or unmapping under the kernel. The timeout is shortened via env so the test is fast. - probe_drain_failure_leaks_and_degrades (#1053/C1): forces the probe drain to fail after the real CQE has arrived (no live write to race); asserts the probe degrades to a ReadOp failure not misclassified as an environment restriction, without corrupting global state. Each test honors the existing skip contract (graceful skip in a restricted environment, real run under seccomp=unconfined). run-docker.sh and CI now build, lint, and run with --all-features so the seams are compiled and exercised. Verified: ./run-docker.sh both legs pass (leg 1 all skip, leg 2 runs real io_uring, 3 new tests pass); cargo clippy --all-targets --all-features -D warnings clean; cargo fmt --check clean. Co-Authored-By: heihutu <heihutu@gmail.com>
houseme
added a commit
that referenced
this pull request
Jul 11, 2026
* fix(driver): fail stranded callers before bounded-drain leak (rustfs/backlog#1161) The bounded-drain escape hatch did `sem.close(); mem::forget(state)`, which forgot each pending op's oneshot `done` sender without dropping or sending on it. A ReadHandle still awaited after shutdown then polled its receiver forever: the oneshot neither completed nor errored, so the task pended permanently — the one driver-gone path that became an infinite hang instead of an error, breaking the crate's error-never-hang contract. Fail every stranded sender with a driver-gone error before forgetting the pending table. `oneshot::Sender::send` consumes the sender without touching the kernel-owned buffer, so the leak-over-UAF property is preserved while an awaited handle resolves with an error. Add a fault-injection test that awaits a handle across the bailout and asserts it resolves with an error within a bounded window. Co-Authored-By: heihutu <heihutu@gmail.com> * fix(driver): classify ring.submit() errors instead of retrying forever (rustfs/backlog#1162) Every non-EBUSY submit() error hit a silent "retry next turn" arm. That is correct for EINTR, but a persistent failure (e.g. EPERM from a seccomp/LSM policy applied after startup) means the queued SQEs are never accepted: their CQEs never arrive, their callers' oneshots never resolve, and — since the driver is not shutting down — the drain deadline never arms. The loop spins at the 50 ms heartbeat forever with no error, log, counter, or fallback. Classify submit errors: EBUSY (reap backpressure) and EINTR (signal) stay transient and reset the counter; any other errno increments a new submit_errors stat, logs once, and after MAX_CONSECUTIVE_SUBMIT_ERRORS consecutive failures transitions the shard to shutdown so the drain and the bounded-drain bailout fail every pending caller with an error — they fall back to the std backend instead of stalling silently. Co-Authored-By: heihutu <heihutu@gmail.com> * fix(driver): wake the loop on drop-cancel and flush resubmits same-turn (rustfs/backlog#1163) Two same-root wakeup gaps let work sit idle for a full LOOP_HEARTBEAT (50 ms): - A short-read resubmit only did backlog.push_back(sqe) during reap, which runs after the turn's push+submit, so the remainder was not handed to the kernel until the next heartbeat woke the loop. Flush the backlog and submit once more after reap when it is non-empty (idle turns skip it). - ReadHandle::drop sent Msg::Cancel without signaling wake_efd — the Submitted state carried no wake reference — so on an otherwise idle ring the cancel was not even queued for up to 50 ms. Carry the shard's wake_efd in HandleState::Submitted and signal it after the cancel send. Co-Authored-By: heihutu <heihutu@gmail.com> * fix(driver): degrade instead of panicking on driver-thread spawn failure (rustfs/backlog#1164) start_shard mapped every fallible step into ProbeFailure so try_new can fall back to the std backend — except the spawn, which used .expect(). Thread creation fails with EAGAIN under a cgroup pids-limit or RLIMIT_NPROC, exactly the constrained environments the degradation design exists for, and the panic unwound async disk init/reconnect instead of quietly selecting std. Map the spawn error to ProbeFailure::Setup and propagate with ?. The spawn runs after the probe read already drained, so on failure the ring/eventfds moved into the closure drop cleanly with no SQE in flight. Add a fault-injection seam and a test asserting a forced spawn failure degrades (no panic) and leaves global state intact for a later normal probe. Co-Authored-By: heihutu <heihutu@gmail.com> * fix(driver): time-bound the probe wait and probe only the first shard (rustfs/backlog#1165) Two probe defects that both bite startup: - drain_probe_cqe bounded only the EINTR retry count, not wall-clock time. submit_and_wait(1) parks in io_cqring_wait until a CQE or a signal, so a probe read that never completes (temp_dir on a hung/D-state or NFS device) blocked async disk init forever. Bound the wait by time: on EXT_ARG kernels (>= 5.11) pass a Timespec to the enter and treat ETIME as "re-check the deadline"; on older kernels fall back to the blocking wait, which the deadline still re-checks between returns. On expiry return an error so the caller's leak-over-UAF fallback degrades the disk to the std backend. - probe_and_start_sharded ran probe_real_read on EVERY shard, contradicting its own doc ("probing happens on the first shard") and doing shards-1 redundant O_TMPFILE round-trips per disk on every start and renew. Probe only the first shard; the rest still create a ring and verify NODROP. Co-Authored-By: heihutu <heihutu@gmail.com> * fix(driver): complete the C7 errno contract in reap and the offset guard (rustfs/backlog#1166) Two gaps versus the driver's own documented three-class errno contract: - The reap loop turned every res < 0 into a final error, including EINTR/EAGAIN, which the contract classifies as "transient -> retry, never surface" — and surfacing them discarded a resubmit's already-read prefix. Retry EINTR/EAGAIN by resubmitting the remaining range, bounded per logical read by MAX_TRANSIENT_RETRIES (reset on progress) so a storm cannot spin the driver thread. Streams still finish; ECANCELED and every other errno terminate. - The submit offset guard only rejected offset > i64::MAX, so a request whose block-aligned end (kernel_offset + region_len) crossed i64::MAX still reached the kernel as a runtime EINVAL/EOVERFLOW — the errno class C7 says to pre-empt at submit. Reject it in submit, which also makes every resubmit offset provably <= i64::MAX. Co-Authored-By: heihutu <heihutu@gmail.com> * fix(driver): eventfd leak on bailout, NODROP overflow wording, cancel dedup (rustfs/backlog#1167) Three diagnostic-consistency defects: - The bounded-drain bailout leaked the ring (still holding a registered cq_efd and in-flight ops) via mem::forget(state), then returned and dropped the cq_efd parameter, closing the fd out from under the still-mapped ring — violating start_shard's documented "cq_efd outlives the ring" invariant. Leak the eventfd alongside the ring on that path. - Overflow was reported as fatal "CQEs lost", but with NODROP (asserted at probe) overflow CQEs are buffered in the kernel overflow list and flushed on the next enter, not lost. Reword the log and StatsSnapshot doc to a backpressure warning. - AsyncCancel SQEs were not deduplicated: a drop-cancel plus a shutdown could queue two cancels per op, pushing completions past 2*entries and making transient overflow reachable. Track queued cancel ids in a HashSet (bounded by the pending table, entries removed on reap) so each op gets at most one cancel. Co-Authored-By: heihutu <heihutu@gmail.com> * fix(driver): disambiguate O_DIRECT tail short reads via fstat, not a heuristic (rustfs/backlog#1168) The short-read resubmit loop treated any non-block-multiple O_DIRECT read as EOF ("the kernel returns block multiples except at the file tail"). That holds for local block filesystems but not universally: NFS O_DIRECT partial-RPC completions, FUSE chunking, and signal-split direct I/O can legally return a non-block-multiple count mid-file. Assuming EOF there silently truncated the delivered range, violating the whole-range pread contract. Confirm EOF with the actual file length (fstat, cheap, driver-thread) instead of inferring it: deliver only when offset+nread is genuinely at/past EOF; for a mid-file non-multiple short read — which an O_DIRECT read cannot resubmit from a non-block-aligned offset — surface an error so the integration falls back to the std backend, preserving correctness rather than truncating. A failed fstat keeps the conservative EOF assumption. Buffered reads (align == 1) are unaffected. Co-Authored-By: heihutu <heihutu@gmail.com> * perf(driver): cut idle churn and cap io-wq workers (rustfs/backlog#1169) Each shard thread woke every 50 ms and, even on a fully idle turn, drained two eventfds and called ring.submit(), which on a non-SQPOLL ring always issues an io_uring_enter syscall with zero SQEs. Across disks x shards driver threads this is thousands of pointless timer wakeups and syscalls per second, forever. - Skip submit() when the SQ is empty: nothing to submit means nothing to enter. A non-empty SQ (freshly pushed, or residual after EBUSY) still submits. - Adaptive heartbeat: 50 ms only while shutting down or with in-flight ops to reap; otherwise wait up to IDLE_HEARTBEAT (1 s). New work still wakes the loop instantly via wake_efd and completions via cq_efd, so latency is unaffected. Also cap each ring's io-wq bounded worker pool via register_iowq_max_workers so a cold-read burst cannot materialize thousands of PF_IO_WORKER threads against TasksMax/RLIMIT_NPROC — the kernel default is min(sq_entries, 4*nCPU) per ring, and there is one ring per shard per disk. Best-effort: older kernels keep the default. Co-Authored-By: heihutu <heihutu@gmail.com> * test(driver): deterministic sharded cancel-routing test (rustfs/backlog#1180) The sharded stress test cannot prove cancel routing: its dropped regular-file reads complete on their own, so a cancel routed to the wrong ring (answered -ENOENT) still conserves. Add a test where every op is a blocked-pipe read that can only finish by being canceled, with more ops than shards so every shard owns one. A mis-routed cancel would leave its read stuck and in_flight would never reach 0; the test asserts every orphan is reclaimed and that a cancel, not a timeout, did it. Co-Authored-By: heihutu <heihutu@gmail.com> * docs(changelog): record the audit hardening pass and fix v0.1.0 links (rustfs/backlog#1181) The changelog claimed "No unreleased changes" although PR #11 (fault injection) landed after the 0.1.0 tag, and every reference link pointed at a nonexistent `v0.1.0` ref (the only tag is `0.1.0`, no `v`). Record the #11 seams and the backlog#1160 audit fixes under [Unreleased], add the [#11] link, and rewrite the compare/tag/blob links to the existing `0.1.0` ref. Co-Authored-By: heihutu <heihutu@gmail.com> * refactor(driver): consolidate the submit/cancel/resubmit paths (rustfs/backlog#1160) The audit-fix series accreted duplicated logic across the driver loop. Fold it into single, named helpers without changing behavior (both run-docker.sh legs still pass, real io_uring included): - Two submit blocks — the main push+submit with full errno classification and a bare `let _ = submit()` for the reap-flush — become one `submit_ring` helper. The reap-flush now shares the same classification instead of silently dropping submit errors. - Three AsyncCancel-enqueue sites (drop-cancel, shutdown, submit-error shutdown) become `queue_cancel`, keeping the per-op dedup in one place. - Two identical resubmit-SQE builds (short-read and transient-errno retry) become `Pending::resubmit_sqe`. - Two push-to-SQ loops become `flush_backlog`. The loop's submit → reap → conditional re-flush order is kept deliberately: a cache-hit read completes inline inside submit()'s io_uring_enter and is reaped the same turn, so reordering would cost the hot path an extra iteration; the second submit only fires when reap queued a resubmit. Co-Authored-By: heihutu <heihutu@gmail.com> * chore: release 0.2.0 Bump to 0.2.0 for the backlog#1160 cancel-safety / degradation hardening pass and the driver-loop refactor. Promote the CHANGELOG [Unreleased] section to [0.2.0], and update the version and design-note references in Cargo.toml, the README, and the crate docs. Co-Authored-By: heihutu <heihutu@gmail.com> * docs: rewrite README from the public API and remove docs/DESIGN.md Refactor the README to match what the code actually exposes: all three read entry points (read_at / read_at_direct / read_current), ReadHandle and StatsSnapshot, and the degradation contract added in the backlog#1160 hardening (classified probe/submit failures, transient-errno retry, fstat-disambiguated EOF). Correct the acceptance-test counts (16 cancel-safety + 5 fault-injection). Remove docs/DESIGN.md and its now-dangling links from the README and crate docs so there is a single source of truth — the per-invariant rationale lives in the module/function docs and the README. The historical CHANGELOG link into the 0.1.0 tag stays valid. Drop the obsolete `exclude = ["docs/**"]` from Cargo.toml. Co-Authored-By: heihutu <heihutu@gmail.com> * fix(driver): clear clippy blockers on the O_DIRECT and drop-cancel paths The Linux-only driver failed `clippy -D warnings`: - the O_DIRECT aligned read buffer was bound `mut`, but it is only read (`as_ptr`/`len`) before being moved into the pending table, which owns and mutates it — so the binding needs no `mut` (unused_mut). - the `ReadHandle::drop` cancel path nested `if !finished && cancel_on_drop` inside the `if let Submitted` guard; collapse into one let-chain (collapsible_if). No behavior change. Verified with `cargo clippy --target x86_64-unknown-linux-gnu --all-targets -- -D warnings` (cross-checks the `cfg(target_os = "linux")` driver module from macOS). Co-Authored-By: heihutu <heihutu@gmail.com> --------- Co-authored-by: houseme <heihutu.ee@gmail.com> Co-authored-by: heihutu <heihutu@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Closes the verification gap tracked in rustfs/backlog#1103: the three cancel-safety escape hatches added during the audit hardening (backlog#1051) were only ever triggered incidentally, never asserted deterministically.
process::abort()(never unwind + free in-flight buffers)These failure modes are unreachable through the normal API, so each needs a fault-injection seam.
How
A new
fault-injectioncargo feature gates every seam. It is off by default and in every published build — all seam code is#[cfg(feature = "fault-injection")], so a defaultcargo buildcompiles none of it (verified: default build unchanged).Seams added to
src/driver.rs:Msg::TestPanic+UringDriver::test_inject_panic()— panic the driver thread with ops in flight.RUSTFS_URING_FAULT_DRAIN_TIMEOUT_MS(read once at thread spawn) — keep the #1055 test fast.RUSTFS_URING_FAULT_STUCK_DRAIN— drop an op's real completion so the bounded drain must time out.RUSTFS_URING_FAULT_PROBE_DRAIN, applied after the real CQE arrives so no live kernel write races the leak.Three deterministic tests in
tests/fault_injection.rs(gated on the feature), each honoring the existing skip contract (graceful skip in a restricted environment, real run underseccomp=unconfined):driver_panic_aborts_instead_of_freeing_in_flight_buffersbounded_drain_bails_out_and_leaks_on_a_stuck_opshutdownreturns afterDRAIN_TIMEOUTwithin_flight > 0, process alive (leaked, memory-safe)probe_drain_failure_leaks_and_degradesReadOperror (not an environment restriction); a subsequent normal probe still worksrun-docker.shand CI (clippy/build --tests/test) now use--all-featuresso the seams are compiled, linted, and exercised — otherwise the feature-gated tests would be dead code in CI.Verification
./run-docker.sh— both legs pass: leg 1 (explicit seccomp) all tests skip gracefully; leg 2 (seccomp=unconfined) runs real io_uring, all 3 new tests pass. Log confirms the abort barrier:driver thread panicked with 1 ops in flight; aborting to avoid UAF.cargo clippy --all-targets --all-features -- -D warnings— clean.cargo fmt --check— clean.🤖 Generated with Claude Code