Skip to main content

aria2_core/util/
zero_copy.rs

1//! Zero-copy data transfer utilities using Linux `splice(2)`.
2//!
3//! `splice(2)` moves data between two file descriptors via a kernel pipe
4//! buffer without copying the data through user space. For downloads to
5//! regular files this enables the pipeline `socket -> pipe -> file`, all
6//! inside the kernel, eliminating the user-space `read`/`write` bounce.
7//!
8//! # Platform Support
9//!
10//! - **Linux**: Full `splice(2)` support via [`libc`]. The public
11//!   [`splice_transfer`] function performs the kernel-mediated transfer.
12//! - **Other platforms** (Windows, macOS, ...): [`splice_transfer`] always
13//!   returns [`Err`](std::io::Result::Err) with kind
14//!   [`Unsupported`](std::io::ErrorKind::Unsupported). Callers must fall back
15//!   to a plain `read` + `write` loop. Use [`is_splice_supported`] to gate
16//!   the fast path at runtime.
17//!
18//! # Safety
19//!
20//! All `splice(2)` invocations are confined to `#[cfg(target_os = "linux")]`
21//! code paths and operate on valid, open file descriptors owned by the
22//! caller. A RAII [`PipeGuard`] guarantees the intermediate pipe descriptors
23//! are closed on every exit path (including early `return` on error and
24//! panics).
25
26use std::io;
27
28// `RawFd` only exists in `std::os::fd` on Unix-like platforms. On Windows we
29// alias it to `i32` (the same underlying type) purely so the non-Linux
30// fallback stub compiles. The stub never performs real I/O, so the alias is
31// only present to satisfy the shared function signature.
32#[cfg(unix)]
33use std::os::fd::RawFd;
34#[cfg(not(unix))]
35type RawFd = i32;
36
37/// Chunk size for each `splice` round-trip. 64 KiB matches the default Linux
38/// pipe capacity, so a single `splice(fd_in -> pipe)` always fits in the pipe
39/// buffer without an `EAGAIN` short read on the pipe side.
40#[cfg(target_os = "linux")]
41const SPLICE_CHUNK: usize = 64 * 1024;
42
43/// Splice flag: hint the kernel to move pages between buffers instead of
44/// copying when possible.
45#[cfg(target_os = "linux")]
46const SPLICE_F_MOVE: u32 = libc::SPLICE_F_MOVE;
47
48/// Transfer up to `len` bytes from `fd_in` to `fd_out` using `splice(2)`.
49///
50/// A temporary kernel pipe is created and used as an intermediate buffer:
51/// each round performs `splice(fd_in -> pipe)` followed by a fully draining
52/// `splice(pipe -> fd_out)`. This keeps the entire byte path inside the
53/// kernel — no user-space buffer is ever touched.
54///
55/// # Arguments
56///
57/// * `fd_in`  - Source file descriptor (e.g. a TCP socket). Must be seekable
58///   or a stream; if `off_in` is `None`, the fd's current offset is used and
59///   advanced by the kernel.
60/// * `off_in` - Starting offset in the source. `None` uses / advances the fd's
61///   own file offset. When `Some`, the kernel updates the offset in place.
62/// * `fd_out` - Destination file descriptor (must be a regular file or a
63///   pipe — `splice` cannot target a socket with this helper).
64/// * `off_out`- Starting offset in the destination, semantics mirror `off_in`.
65/// * `len`    - Maximum number of bytes to transfer.
66///
67/// # Returns
68///
69/// The number of bytes actually transferred. `0` indicates EOF on the source
70/// before any byte was read in the current call.
71///
72/// # Errors
73///
74/// Returns [`io::Error`] on any underlying syscall failure. Transient
75/// `EINTR` interruptions are retried automatically and never surface to the
76/// caller. On non-Linux platforms this always returns
77/// [`Unsupported`](std::io::ErrorKind::Unsupported).
78///
79/// # Examples
80///
81/// ```no_run
82/// # #[cfg(target_os = "linux")] {
83/// use std::os::fd::AsRawFd;
84/// use aria2_core::util::zero_copy::splice_transfer;
85///
86/// let src = std::fs::File::open("in.bin")?;
87/// let dst = std::fs::OpenOptions::new().write(true).create(true)
88///     .truncate(true).open("out.bin")?;
89/// let n = splice_transfer(src.as_raw_fd(), None, dst.as_raw_fd(), None, 1 << 20)?;
90/// println!("transferred {n} bytes");
91/// # }
92/// # Ok::<(), std::io::Error>(())
93/// ```
94#[cfg(target_os = "linux")]
95pub fn splice_transfer(
96    fd_in: RawFd,
97    off_in: Option<i64>,
98    fd_out: RawFd,
99    off_out: Option<i64>,
100    len: usize,
101) -> io::Result<usize> {
102    // Create the intermediate kernel pipe buffer.
103    let mut pipe_fds = [0i32; 2];
104    // SAFETY: `pipe_fds` is a valid 2-element array of `c_int`. `pipe(2)`
105    // writes two descriptors on success and returns 0.
106    if unsafe { libc::pipe(pipe_fds.as_mut_ptr()) } != 0 {
107        return Err(io::Error::last_os_error());
108    }
109    // RAII: closes both pipe ends on every exit path, including panics.
110    let guard = PipeGuard {
111        read: pipe_fds[0],
112        write: pipe_fds[1],
113    };
114
115    // Mutable copies so the kernel can advance them in place via the pointer
116    // we hand to `splice`. `None` keeps using the fd's native file offset.
117    let mut in_offset = off_in;
118    let mut out_offset = off_out;
119    let mut total_transferred = 0usize;
120
121    while total_transferred < len {
122        let remaining = len - total_transferred;
123        let chunk = remaining.min(SPLICE_CHUNK);
124
125        // Phase 1: source -> pipe. `EINTR` with zero bytes moved is retried.
126        let n_read = do_splice(fd_in, in_offset.as_mut(), guard.write, None, chunk)?;
127        if n_read == 0 {
128            // EOF on the source.
129            break;
130        }
131        let n_read = n_read as usize;
132
133        // Phase 2: drain pipe -> destination completely. A single `splice`
134        // into a regular file normally transfers the full amount, but we loop
135        // defensively in case of a short write (e.g. ENOSPC mid-way would
136        // surface as an error instead).
137        let mut drained = 0usize;
138        while drained < n_read {
139            let to_write = n_read - drained;
140            let n_written = do_splice(guard.read, None, fd_out, out_offset.as_mut(), to_write)?;
141            if n_written == 0 {
142                // Destination accepted no bytes — break to avoid an infinite
143                // loop. For regular files this should not happen; surface it.
144                return Err(io::Error::new(
145                    io::ErrorKind::WriteZero,
146                    "splice: destination accepted zero bytes",
147                ));
148            }
149            drained += n_written as usize;
150        }
151        total_transferred += drained;
152    }
153
154    Ok(total_transferred)
155}
156
157/// Non-Linux fallback: always returns [`Unsupported`](std::io::ErrorKind).
158///
159/// The signature matches the Linux implementation so callers can use the same
160/// code unconditionally; they should first check [`is_splice_supported`] and
161/// fall back to `read` + `write` when this returns `Err`.
162#[cfg(not(target_os = "linux"))]
163pub fn splice_transfer(
164    _fd_in: RawFd,
165    _off_in: Option<i64>,
166    _fd_out: RawFd,
167    _off_out: Option<i64>,
168    _len: usize,
169) -> io::Result<usize> {
170    Err(io::Error::new(
171        io::ErrorKind::Unsupported,
172        "splice is only supported on Linux",
173    ))
174}
175
176/// Reports whether `splice(2)` is available on the current platform.
177///
178/// Callers should gate the fast path with this check and fall back to a
179/// user-space `read` + `write` loop when it returns `false`.
180pub fn is_splice_supported() -> bool {
181    cfg!(target_os = "linux")
182}
183
184/// Single `splice(2)` invocation with automatic `EINTR` retry.
185///
186/// Both offset pointers are optional: `None` passes `NULL` so the kernel uses
187/// the fd's native file offset. When `Some(&mut i64)` is supplied, the kernel
188/// advances the pointed-to offset in place by the number of bytes moved.
189///
190/// Returns the number of bytes transferred (`0` indicates EOF on a read side,
191/// or end-of-data on a pipe).
192#[cfg(target_os = "linux")]
193fn do_splice(
194    fd_in: RawFd,
195    off_in: Option<&mut i64>,
196    fd_out: RawFd,
197    off_out: Option<&mut i64>,
198    len: usize,
199) -> io::Result<isize> {
200    // `ptr::from_mut` yields a `*mut i64` that stays valid for the borrow's
201    // lifetime (the entire call). `None` becomes a NULL pointer.
202    let in_ptr = off_in
203        .map(std::ptr::from_mut)
204        .unwrap_or(std::ptr::null_mut());
205    let out_ptr = off_out
206        .map(std::ptr::from_mut)
207        .unwrap_or(std::ptr::null_mut());
208
209    loop {
210        // SAFETY: `fd_in` and `fd_out` are valid open descriptors supplied by
211        // the caller. The offset pointers (when non-NULL) point to mutable
212        // locals owned by the caller's stack frame and remain valid for the
213        // duration of this call.
214        let r = unsafe { libc::splice(fd_in, in_ptr, fd_out, out_ptr, len, SPLICE_F_MOVE) };
215        if r < 0 {
216            let err = io::Error::last_os_error();
217            // Retry on signal interruption. Per POSIX, `splice` returns the
218            // byte count if any data was moved before the signal, so `EINTR`
219            // only occurs with zero bytes transferred — safe to retry.
220            if err.raw_os_error() == Some(libc::EINTR) {
221                continue;
222            }
223            return Err(err);
224        }
225        return Ok(r);
226    }
227}
228
229/// RAII guard closing both ends of a pipe created for a splice transfer.
230///
231/// Closing both descriptors in `Drop` guarantees no file-descriptor leak,
232/// even when the enclosing `splice_transfer` returns early on error or
233/// unwinds. Close errors are deliberately ignored: there is no recovery
234/// action available during drop, and a failed `close` on a pipe is benign.
235#[cfg(target_os = "linux")]
236struct PipeGuard {
237    read: i32,
238    write: i32,
239}
240
241#[cfg(target_os = "linux")]
242impl Drop for PipeGuard {
243    fn drop(&mut self) {
244        // SAFETY: both fds were obtained from a successful `pipe(2)` call and
245        // are not closed elsewhere. Ignoring the return value is safe for
246        // pipe close.
247        unsafe {
248            libc::close(self.read);
249            libc::close(self.write);
250        }
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn test_is_splice_supported() {
260        // On Linux this returns true; on every other platform false.
261        assert_eq!(is_splice_supported(), cfg!(target_os = "linux"));
262    }
263
264    #[cfg(not(target_os = "linux"))]
265    #[test]
266    fn test_splice_unsupported_on_non_linux() {
267        // The fallback must refuse gracefully and never panic.
268        let result = splice_transfer(0, None, 1, None, 1024);
269        assert!(result.is_err());
270        assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::Unsupported);
271    }
272
273    #[cfg(target_os = "linux")]
274    #[test]
275    fn test_splice_transfer_between_files() {
276        // End-to-end: write known bytes to a source file, splice them into a
277        // destination file, then verify the destination content matches.
278        use std::os::fd::AsRawFd;
279
280        let dir = tempfile::tempdir().expect("tempdir");
281        let src_path = dir.path().join("src.bin");
282        let dst_path = dir.path().join("dst.bin");
283        let payload = b"splice test data"; // 16 bytes
284
285        std::fs::write(&src_path, payload).expect("write src");
286
287        let src_file = std::fs::File::open(&src_path).expect("open src");
288        let dst_file = std::fs::OpenOptions::new()
289            .write(true)
290            .create(true)
291            .truncate(true)
292            .open(&dst_path)
293            .expect("open dst");
294
295        let n = splice_transfer(
296            src_file.as_raw_fd(),
297            None,
298            dst_file.as_raw_fd(),
299            None,
300            payload.len(),
301        )
302        .expect("splice_transfer");
303        assert_eq!(n, payload.len());
304
305        // Drop the file handles before re-reading on Windows-like locks.
306        drop(src_file);
307        drop(dst_file);
308
309        let content = std::fs::read(&dst_path).expect("read dst");
310        assert_eq!(content, payload);
311    }
312}