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}