1#![allow(unsafe_code)]
16#![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
34const QD: u32 = 256;
36
37pub const MAX_FILE: u64 = 1 << 20; pub const MAX_LARGE_FILE: u64 = 64 * 1024 * 1024; const SPLICE_CHUNK: usize = 1 * 1024 * 1024; static LARGE_FILE_ATTEMPTS: AtomicU64 = AtomicU64::new(0);
53static LARGE_FILE_SUCCESSES: AtomicU64 = AtomicU64::new(0);
54
55#[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
64static SPLICE_SUPPORTED: OnceLock<bool> = OnceLock::new();
69
70#[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 major > 5 || (major == 5 && minor >= 19)
87}
88
89pub struct Job<'a> {
95 pub src: &'a Path,
97 pub tmp: &'a Path,
99 pub len: u64,
101}
102
103#[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 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 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 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
174pub 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
202fn 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
213struct 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 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; }
278 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 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 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 if unsafe { ring.submission().push(&sqe) }.is_err() {
311 break;
312 }
313 submitted += 1;
314 }
315 reap(ring, submitted, opens, results, false);
316}
317
318fn 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 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
362fn 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 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; 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 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 let sqe1 = opcode::Splice::new(
419 types::Fd(src_fd),
420 offset as i64, types::Fd(pipe_write_fd),
422 -1_i64, this_chunk as u32,
424 )
425 .build()
426 .flags(squeue::Flags::IO_LINK)
427 .user_data((chunk_idx * 2) as u64);
428
429 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 let sqe2 = opcode::Splice::new(
437 types::Fd(pipe_read_fd),
438 -1_i64, types::Fd(dst_fd),
440 offset as i64, this_chunk as u32,
442 )
443 .build()
444 .user_data((chunk_idx * 2 + 1) as u64);
445
446 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 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 tracing::warn!(
482 "io_uring splice failed for {} ({} bytes): {e}; falling back to portable copy",
483 job.src.display(),
484 job.len
485 );
486 drop(dst_file);
488 let _ = std::fs::remove_file(job.tmp);
489 return Err(e);
490 }
491
492 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
513fn maximize_pipe_size(pipe_fd: &OwnedFd) -> usize {
517 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 rustix::pipe::fcntl_getpipe_size(pipe_fd).unwrap_or(65536)
524 }
525}