Skip to main content

ripsync_core/io/
uring.rs

1//! `io_uring` batched small-file copy backend (Linux only).
2//!
3//! This is the one place in `ripsync-core` where raw `unsafe` is justified: the
4//! `io_uring` submission interface requires pointing the kernel at user buffers.
5//! The rest of the crate keeps `#![forbid(unsafe_code)]`; here we opt in locally
6//! and annotate every `unsafe` block with a `// SAFETY:` rationale.
7//!
8//! Strategy: copy a chunk of files by (1) opening each source and temp file,
9//! (2) submitting one `read` per file and reaping all completions, then
10//! (3) submitting one `write` per file and reaping. Two ring round-trips replace
11//! `2·N` read/write syscalls. Files larger than [`MAX_FILE`] but at most
12//! [`MAX_LARGE_FILE`] are handled via linked [`Splice`] SQEs when the kernel
13//! supports it (≥5.19). Anything larger or unsupported is reported back so the
14//! caller can fall back to the portable path.
15#![allow(unsafe_code)]
16// Lengths are bounded by `MAX_FILE` (1 MiB) and completion results are checked
17// non-negative before use, so these casts are provably lossless here.
18#![allow(
19    clippy::cast_possible_truncation,
20    clippy::cast_sign_loss,
21    clippy::cast_possible_wrap
22)]
23
24use std::fs::{File, OpenOptions};
25use std::io;
26use std::os::fd::{AsRawFd, OwnedFd};
27use std::os::unix::fs::OpenOptionsExt;
28use std::path::Path;
29use std::sync::atomic::{AtomicU64, Ordering};
30use std::sync::OnceLock;
31
32use io_uring::{opcode, squeue, types, IoUring};
33
34/// Submission/completion queue depth, and the per-chunk batch size.
35const QD: u32 = 256;
36
37/// Largest file handled by the single-shot uring path; bigger files fall back
38/// or use the splice path.
39pub const MAX_FILE: u64 = 1 << 20; // 1 MiB
40
41/// Largest file handled by the linked-splice uring path (64 MiB).
42pub const MAX_LARGE_FILE: u64 = 64 * 1024 * 1024; // 64 MiB
43
44/// Default chunk size for splice operations (1 MiB, matches typical
45/// `/proc/sys/fs/pipe-max-size`).
46const SPLICE_CHUNK: usize = 1 * 1024 * 1024; // 1 MiB
47
48// ---------------------------------------------------------------------------
49// Metrics
50// ---------------------------------------------------------------------------
51
52static LARGE_FILE_ATTEMPTS: AtomicU64 = AtomicU64::new(0);
53static LARGE_FILE_SUCCESSES: AtomicU64 = AtomicU64::new(0);
54
55/// Return (attempts, successes) counters for large-file splice copies.
56#[must_use]
57pub fn large_file_stats() -> (u64, u64) {
58    (
59        LARGE_FILE_ATTEMPTS.load(Ordering::Relaxed),
60        LARGE_FILE_SUCCESSES.load(Ordering::Relaxed),
61    )
62}
63
64// ---------------------------------------------------------------------------
65// Kernel probe
66// ---------------------------------------------------------------------------
67
68static SPLICE_SUPPORTED: OnceLock<bool> = OnceLock::new();
69
70/// Check whether the running kernel supports `IORING_OP_SPLICE` (≥5.19).
71///
72/// The probe runs once and caches the result. On non-Linux or pre-5.19 kernels
73/// this returns `false` and callers should fall back to the portable path.
74#[must_use]
75pub fn kernel_supports_splice() -> bool {
76    *SPLICE_SUPPORTED.get_or_init(probe_kernel_splice)
77}
78
79fn probe_kernel_splice() -> bool {
80    let uname = rustix::system::uname();
81    let release = uname.release().to_str().unwrap_or("0.0");
82    let mut parts = release.split(|c: char| !c.is_ascii_digit());
83    let major: u32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
84    let minor: u32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
85    // Splice opcode exists since 5.7; we require 5.19 for reliability.
86    major > 5 || (major == 5 && minor >= 19)
87}
88
89// ---------------------------------------------------------------------------
90// Public API
91// ---------------------------------------------------------------------------
92
93/// One copy request: read all of `src`, write it to the fresh temp file `tmp`.
94pub struct Job<'a> {
95    /// Source file path.
96    pub src: &'a Path,
97    /// Destination temporary path (created by this backend).
98    pub tmp: &'a Path,
99    /// Expected source length in bytes.
100    pub len: u64,
101}
102
103/// Copy every job, returning a per-job result (bytes written, or the error that
104/// should trigger a portable-path fallback for that single file).
105///
106/// Small files (≤[`MAX_FILE`]) use the existing read/write batching path.
107/// Medium files (≤[`MAX_LARGE_FILE`]) use linked splice SQEs when the kernel
108/// supports it. Larger files are rejected so the caller falls back.
109#[must_use]
110pub fn copy_batch(jobs: &[Job]) -> Vec<io::Result<u64>> {
111    let mut results: Vec<io::Result<u64>> = Vec::with_capacity(jobs.len());
112    for _ in jobs {
113        results.push(Ok(0));
114    }
115
116    let mut ring = match IoUring::new(QD) {
117        Ok(r) => r,
118        Err(e) => {
119            for r in &mut results {
120                *r = Err(io::Error::other(format!("io_uring init: {e}")));
121            }
122            return results;
123        }
124    };
125
126    // Partition: small (≤1 MiB), large (1–64 MiB), huge (>64 MiB).
127    let mut small_indices: Vec<usize> = Vec::new();
128    let mut large_indices: Vec<usize> = Vec::new();
129
130    for (i, job) in jobs.iter().enumerate() {
131        if job.len <= MAX_FILE {
132            small_indices.push(i);
133        } else if job.len <= MAX_LARGE_FILE {
134            large_indices.push(i);
135        } else {
136            results[i] = Err(io::Error::new(
137                io::ErrorKind::Unsupported,
138                "file too large for uring path",
139            ));
140        }
141    }
142
143    // Process small files in chunks (existing path).
144    for chunk in chunk_indices(small_indices.len(), QD as usize) {
145        let mapped: Vec<usize> = chunk.iter().map(|&idx| small_indices[idx]).collect();
146        copy_chunk(&mut ring, jobs, &mapped, &mut results);
147    }
148
149    // Process large files via linked splice (one at a time).
150    let splice_ok = kernel_supports_splice();
151    for &job_idx in &large_indices {
152        if !splice_ok {
153            results[job_idx] = Err(io::Error::new(
154                io::ErrorKind::Unsupported,
155                "kernel too old for io_uring splice; upgrade to ≥5.19",
156            ));
157            continue;
158        }
159        LARGE_FILE_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
160        match copy_large_file_splice(&mut ring, &jobs[job_idx]) {
161            Ok(bytes) => {
162                LARGE_FILE_SUCCESSES.fetch_add(1, Ordering::Relaxed);
163                results[job_idx] = Ok(bytes);
164            }
165            Err(e) => {
166                results[job_idx] = Err(e);
167            }
168        }
169    }
170
171    results
172}
173
174/// Copy a single large file via linked splice SQEs.
175///
176/// This is the entry point used by the portable copy ladder in [`crate::copy`]
177/// when io_uring is available and the file is 1–64 MiB.
178pub fn copy_single_large(src: &Path, tmp: &Path, len: u64) -> io::Result<u64> {
179    if !kernel_supports_splice() {
180        return Err(io::Error::new(
181            io::ErrorKind::Unsupported,
182            "kernel too old for io_uring splice; upgrade to ≥5.19",
183        ));
184    }
185    if len > MAX_LARGE_FILE {
186        return Err(io::Error::new(
187            io::ErrorKind::Unsupported,
188            "file too large for uring splice path",
189        ));
190    }
191    LARGE_FILE_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
192    let mut ring = IoUring::new(QD)?;
193    match copy_large_file_splice(&mut ring, &Job { src, tmp, len }) {
194        Ok(bytes) => {
195            LARGE_FILE_SUCCESSES.fetch_add(1, Ordering::Relaxed);
196            Ok(bytes)
197        }
198        Err(e) => Err(e),
199    }
200}
201
202// ---------------------------------------------------------------------------
203// Small-file batching (existing path, unchanged logic)
204// ---------------------------------------------------------------------------
205
206/// Yield index ranges of at most `size` over `0..n`.
207fn chunk_indices(n: usize, size: usize) -> impl Iterator<Item = Vec<usize>> {
208    (0..n)
209        .step_by(size)
210        .map(move |start| (start..(start + size).min(n)).collect())
211}
212
213/// State for one opened job within a chunk.
214struct Open {
215    job_idx: usize,
216    src: File,
217    tmp: File,
218    buf: Vec<u8>,
219    read: usize,
220    failed: bool,
221}
222
223fn copy_chunk(ring: &mut IoUring, jobs: &[Job], chunk: &[usize], results: &mut [io::Result<u64>]) {
224    let mut opens: Vec<Open> = Vec::with_capacity(chunk.len());
225
226    for &job_idx in chunk {
227        let job = &jobs[job_idx];
228        if job.len > MAX_FILE {
229            results[job_idx] = Err(io::Error::new(
230                io::ErrorKind::Unsupported,
231                "file too large for uring path",
232            ));
233            continue;
234        }
235        match open_pair(job) {
236            Ok((src, tmp)) => {
237                let len = usize::try_from(job.len).unwrap_or(0);
238                opens.push(Open {
239                    job_idx,
240                    src,
241                    tmp,
242                    buf: vec![0u8; len],
243                    read: 0,
244                    failed: false,
245                });
246            }
247            Err(e) => results[job_idx] = Err(e),
248        }
249    }
250
251    submit_reads(ring, &mut opens, results);
252    submit_writes(ring, &mut opens, results);
253
254    // Empty files and any survivors succeed with their full length.
255    for o in &opens {
256        if !o.failed {
257            results[o.job_idx] = Ok(o.buf.len() as u64);
258        }
259    }
260}
261
262fn open_pair(job: &Job) -> io::Result<(File, File)> {
263    let src = File::open(job.src)?;
264    let tmp = OpenOptions::new()
265        .write(true)
266        .create_new(true)
267        .mode(0o600)
268        .open(job.tmp)?;
269    Ok((src, tmp))
270}
271
272fn submit_reads(ring: &mut IoUring, opens: &mut [Open], results: &mut [io::Result<u64>]) {
273    let mut submitted = 0u32;
274    for (slot, o) in opens.iter().enumerate() {
275        if o.buf.is_empty() {
276            continue; // zero-length file: nothing to read
277        }
278        // SAFETY: `o.buf` lives in `opens` for the whole call (not moved until
279        // after we reap completions), so the kernel-visible pointer/length stay
280        // valid until the matching CQE is consumed below.
281        let sqe = opcode::Read::new(
282            types::Fd(o.src.as_raw_fd()),
283            o.buf.as_ptr().cast_mut(),
284            o.buf.len() as u32,
285        )
286        .offset(0)
287        .build()
288        .user_data(slot as u64);
289        // SAFETY: the SQE references `o.buf` which outlives submission/reaping.
290        if unsafe { ring.submission().push(&sqe) }.is_err() {
291            break;
292        }
293        submitted += 1;
294    }
295    reap(ring, submitted, opens, results, true);
296}
297
298fn submit_writes(ring: &mut IoUring, opens: &mut [Open], results: &mut [io::Result<u64>]) {
299    let mut submitted = 0u32;
300    for (slot, o) in opens.iter().enumerate() {
301        if o.failed || o.read == 0 {
302            continue;
303        }
304        // SAFETY: `o.buf[..o.read]` stays valid in `opens` until reaping.
305        let sqe = opcode::Write::new(types::Fd(o.tmp.as_raw_fd()), o.buf.as_ptr(), o.read as u32)
306            .offset(0)
307            .build()
308            .user_data(slot as u64);
309        // SAFETY: same lifetime argument as the read phase.
310        if unsafe { ring.submission().push(&sqe) }.is_err() {
311            break;
312        }
313        submitted += 1;
314    }
315    reap(ring, submitted, opens, results, false);
316}
317
318/// Wait for `count` completions and record their results.
319fn reap(
320    ring: &mut IoUring,
321    count: u32,
322    opens: &mut [Open],
323    results: &mut [io::Result<u64>],
324    is_read: bool,
325) {
326    if count == 0 {
327        return;
328    }
329    if let Err(e) = ring.submit_and_wait(count as usize) {
330        for o in opens.iter_mut() {
331            o.failed = true;
332            results[o.job_idx] = Err(io::Error::other(format!("uring submit: {e}")));
333        }
334        return;
335    }
336    let cqes: Vec<(usize, i32)> = ring
337        .completion()
338        .map(|c| (c.user_data() as usize, c.result()))
339        .collect();
340    for (slot, res) in cqes {
341        let Some(o) = opens.get_mut(slot) else {
342            continue;
343        };
344        if res < 0 {
345            o.failed = true;
346            results[o.job_idx] = Err(io::Error::from_raw_os_error(-res));
347        } else if is_read {
348            o.read = res as usize;
349            if o.read != o.buf.len() {
350                // Short read (file changed under us): fall back for this file.
351                o.failed = true;
352                results[o.job_idx] =
353                    Err(io::Error::new(io::ErrorKind::UnexpectedEof, "short read"));
354            }
355        } else if res as usize != o.read {
356            o.failed = true;
357            results[o.job_idx] = Err(io::Error::new(io::ErrorKind::WriteZero, "short write"));
358        }
359    }
360}
361
362// ---------------------------------------------------------------------------
363// Large-file splice path
364// ---------------------------------------------------------------------------
365
366/// Copy a single large file using linked `IORING_OP_SPLICE` SQEs.
367///
368/// Strategy:
369/// 1. Create a pipe and maximize its capacity.
370/// 2. For each chunk, submit a linked pair: splice(src→pipe) → splice(pipe→dst).
371/// 3. All pairs are submitted in one batch, then all completions are reaped.
372/// 4. If any SQE fails, the whole operation fails (caller falls back).
373fn copy_large_file_splice(ring: &mut IoUring, job: &Job) -> io::Result<u64> {
374    let src_file = File::open(job.src)?;
375    let dst_file = OpenOptions::new()
376        .write(true)
377        .create_new(true)
378        .mode(0o600)
379        .open(job.tmp)?;
380
381    let src_fd = src_file.as_raw_fd();
382    let dst_fd = dst_file.as_raw_fd();
383
384    // Create pipe and maximize its size.
385    let (pipe_read, pipe_write) = rustix::pipe::pipe()?;
386    let pipe_size = maximize_pipe_size(&pipe_write);
387
388    let chunk_size = pipe_size.min(SPLICE_CHUNK);
389    let num_chunks = ((job.len as usize) + chunk_size - 1) / chunk_size;
390    let total_sqes = num_chunks * 2; // each chunk = src→pipe + pipe→dst
391
392    if total_sqes > QD as usize {
393        return Err(io::Error::new(
394            io::ErrorKind::Other,
395            format!(
396                "too many splice SQEs needed ({total_sqes}) for {} byte file; \
397                 increase QD or reduce file size",
398                job.len
399            ),
400        ));
401    }
402
403    let pipe_read_fd = pipe_read.as_raw_fd();
404    let pipe_write_fd = pipe_write.as_raw_fd();
405
406    // Submit all linked splice pairs.
407    let mut submitted: u32 = 0;
408    for chunk_idx in 0..num_chunks {
409        let offset = (chunk_idx * chunk_size) as u64;
410        let remaining = job.len.saturating_sub(offset);
411        let this_chunk = (remaining as usize).min(chunk_size);
412
413        if this_chunk == 0 {
414            break;
415        }
416
417        // SQE 1: splice src → pipe (fill pipe from source file).
418        let sqe1 = opcode::Splice::new(
419            types::Fd(src_fd),
420            offset as i64,       // off_in: read from this file offset
421            types::Fd(pipe_write_fd),
422            -1_i64,              // off_out: pipe, must be -1
423            this_chunk as u32,
424        )
425        .build()
426        .flags(squeue::Flags::IO_LINK)
427        .user_data((chunk_idx * 2) as u64);
428
429        // SAFETY: pipe_write_fd and src_fd are valid for the duration.
430        if unsafe { ring.submission().push(&sqe1) }.is_err() {
431            return Err(io::Error::other("uring submission queue full (splice src→pipe)"));
432        }
433        submitted += 1;
434
435        // SQE 2: splice pipe → dst (drain pipe to destination file).
436        let sqe2 = opcode::Splice::new(
437            types::Fd(pipe_read_fd),
438            -1_i64,              // off_in: pipe, must be -1
439            types::Fd(dst_fd),
440            offset as i64,       // off_out: write to this file offset
441            this_chunk as u32,
442        )
443        .build()
444        .user_data((chunk_idx * 2 + 1) as u64);
445
446        // SAFETY: pipe_read_fd and dst_fd are valid for the duration.
447        if unsafe { ring.submission().push(&sqe2) }.is_err() {
448            return Err(io::Error::other("uring submission queue full (splice pipe→dst)"));
449        }
450        submitted += 1;
451    }
452
453    // Submit all SQEs and reap completions.
454    if submitted == 0 {
455        return Ok(0);
456    }
457
458    if let Err(e) = ring.submit_and_wait(submitted as usize) {
459        return Err(io::Error::other(format!("uring splice submit: {e}")));
460    }
461
462    let mut total_copied: u64 = 0;
463    let mut first_error: Option<io::Error> = None;
464
465    for cqe in ring.completion() {
466        let res = cqe.result();
467        if res < 0 {
468            let err = io::Error::from_raw_os_error(-res);
469            if first_error.is_none() {
470                first_error = Some(io::Error::other(format!(
471                    "uring splice SQE failed: {err}; consider upgrading kernel to ≥5.19"
472                )));
473            }
474        } else {
475            total_copied += res as u64;
476        }
477    }
478
479    if let Some(e) = first_error {
480        // Log warning about splice failure.
481        tracing::warn!(
482            "io_uring splice failed for {} ({} bytes): {e}; falling back to portable copy",
483            job.src.display(),
484            job.len
485        );
486        // Clean up partial destination.
487        drop(dst_file);
488        let _ = std::fs::remove_file(job.tmp);
489        return Err(e);
490    }
491
492    // Verify we copied the expected amount.
493    if total_copied != job.len {
494        tracing::warn!(
495            "io_uring splice short copy for {}: expected {} bytes, got {total_copied}",
496            job.src.display(),
497            job.len
498        );
499        drop(dst_file);
500        let _ = std::fs::remove_file(job.tmp);
501        return Err(io::Error::new(
502            io::ErrorKind::UnexpectedEof,
503            format!(
504                "splice short copy: expected {} bytes, got {total_copied}",
505                job.len
506            ),
507        ));
508    }
509
510    Ok(total_copied)
511}
512
513/// Maximize the pipe buffer size up to the system limit.
514///
515/// Returns the actual pipe size in bytes.
516fn maximize_pipe_size(pipe_fd: &OwnedFd) -> usize {
517    // Try to set to 1 MiB; the kernel will clamp to `/proc/sys/fs/pipe-max-size`.
518    let desired = SPLICE_CHUNK;
519    if rustix::pipe::fcntl_setpipe_size(pipe_fd, desired).is_ok() {
520        rustix::pipe::fcntl_getpipe_size(pipe_fd).unwrap_or(desired)
521    } else {
522        // Fall back to whatever the kernel gave us.
523        rustix::pipe::fcntl_getpipe_size(pipe_fd).unwrap_or(65536)
524    }
525}