Skip to main content

aria2_core/filesystem/
positioned_disk_writer.rs

1//! Positioned disk writer using OS-native `pwrite`/`seek_write` for concurrent
2//! writes to non-overlapping offsets without a global async mutex.
3//!
4//! Platform support:
5//! - Unix: [`std::os::unix::fs::FileExt::write_at`] (wraps `pwrite(2)`)
6//! - Windows: [`std::os::windows::fs::FileExt::seek_write`]
7//!
8//! # Concurrency model
9//!
10//! The underlying file handle is wrapped in a [`std::sync::Mutex`] held ONLY
11//! for the brief duration of each synchronous `pwrite`/`seek_write` syscall.
12//! This is fundamentally different from the legacy
13//! `Arc<tokio::sync::Mutex<DirectDiskAdaptor>>` design which held the lock
14//! across async await points and serialized all writes.
15//!
16//! Here the lock is held only for the synchronous syscall (microseconds),
17//! never across `.await` points. When multiple [`PositionedDiskWriter`]
18//! instances reference the same file path (each opening its own file
19//! descriptor), non-overlapping writes execute concurrently at the OS level
20//! because `pwrite` is atomic and offset-based — it does not mutate the
21//! shared file cursor.
22
23use std::path::{Path, PathBuf};
24use std::sync::Mutex;
25
26use async_trait::async_trait;
27use tracing::debug;
28
29use crate::error::{Aria2Error, Result};
30
31use super::disk_writer::SeekableDiskWriter;
32
33/// A disk writer that performs positioned (offset-based) I/O via OS-native
34/// `pwrite`/`seek_write` syscalls.
35///
36/// The file descriptor is protected by a [`std::sync::Mutex`] held only for
37/// the duration of each synchronous syscall — never across `.await` points.
38/// This enables true concurrency for non-overlapping writes when multiple
39/// writers reference the same file.
40///
41/// Uses [`std::fs::File`] (not `tokio::fs::File`) because `FileExt::write_at`
42/// is a synchronous method available only on `std::fs::File`. Since `pwrite`
43/// is a fast non-blocking syscall (it never waits on async I/O completion),
44/// running it synchronously inside a tokio task is acceptable — it does not
45/// stall the runtime for meaningful durations.
46pub struct PositionedDiskWriter {
47    /// `std::fs::File` wrapped in `Option` to support lazy open from `&self`.
48    /// The `std::sync::Mutex` (NOT `tokio::sync::Mutex`) is intentional: the
49    /// lock is held only for the synchronous syscall, never across await
50    /// points, so it cannot deadlock the async runtime.
51    file: Mutex<Option<std::fs::File>>,
52    path: PathBuf,
53    total_size: Option<u64>,
54}
55
56impl PositionedDiskWriter {
57    /// Create a new `PositionedDiskWriter` for the given path.
58    ///
59    /// If `total_size` is provided and the file is newly created (size 0), the
60    /// file is pre-allocated to `total_size` bytes on first open. This avoids
61    /// fragmentation and enables concurrent writes to arbitrary offsets
62    /// without per-write file extension.
63    pub fn new(path: &Path, total_size: Option<u64>) -> Self {
64        Self {
65            file: Mutex::new(None),
66            path: path.to_path_buf(),
67            total_size,
68        }
69    }
70
71    /// Returns the configured total size, if any.
72    pub fn total_size(&self) -> Option<u64> {
73        self.total_size
74    }
75
76    /// Lazily open the underlying file if not already open.
77    ///
78    /// This is synchronous because `pwrite`/`seek_write` are synchronous
79    /// syscalls. The `std::sync::Mutex` is held only for the file open
80    /// operation (microseconds), never across await points.
81    ///
82    /// Idempotent: if the file is already open, returns `Ok(())` immediately.
83    fn ensure_open_sync(&self) -> Result<()> {
84        let mut guard = self
85            .file
86            .lock()
87            .map_err(|e| Aria2Error::Io(format!("file mutex poisoned: {e}")))?;
88        if guard.is_some() {
89            return Ok(());
90        }
91
92        // Create parent directories if needed (resume scenario may have missing dirs)
93        if let Some(parent) = self.path.parent()
94            && !parent.as_os_str().is_empty()
95            && !parent.exists()
96        {
97            std::fs::create_dir_all(parent)?;
98            debug!("Created parent directories for {:?}", self.path);
99        }
100
101        let mut opts = std::fs::OpenOptions::new();
102        opts.create(true).write(true).read(true);
103        let file = opts.open(&self.path)?;
104        debug!("Opened file for positioned I/O: {:?}", self.path);
105
106        // Pre-allocate the file if a total size was specified and the file is
107        // newly created (current size 0). For resume scenarios where the file
108        // already has content, we do NOT truncate — preserving existing data.
109        if let Some(size) = self.total_size {
110            let current_size = file.metadata()?.len();
111            if current_size == 0 && size > 0 {
112                file.set_len(size)?;
113                debug!("Pre-allocated file to {} bytes: {:?}", size, self.path);
114            }
115        }
116
117        *guard = Some(file);
118        Ok(())
119    }
120
121    /// Acquire the file mutex guard, returning a descriptive error if poisoned.
122    fn lock_file(&self) -> Result<std::sync::MutexGuard<'_, Option<std::fs::File>>> {
123        self.file
124            .lock()
125            .map_err(|e| Aria2Error::Io(format!("file mutex poisoned: {e}")))
126    }
127
128    /// Returns the raw file descriptor of the underlying file, if open.
129    ///
130    /// This is used by the Linux splice download path to obtain the file fd
131    /// for `splice(2)` zero-copy transfer. Returns `None` if the file has not
132    /// been opened yet (caller should call `open()` first).
133    ///
134    /// The caller must ensure the writer is not dropped or closed while the
135    /// returned fd is in use. The fd is valid only while the writer holds the
136    /// file open.
137    #[cfg(unix)]
138    pub fn raw_fd(&self) -> Option<std::os::unix::io::RawFd> {
139        let guard = self.file.lock().ok()?;
140        guard.as_ref().map(std::os::unix::io::AsRawFd::as_raw_fd)
141    }
142}
143
144#[async_trait]
145impl SeekableDiskWriter for PositionedDiskWriter {
146    async fn open(&mut self) -> Result<()> {
147        self.ensure_open_sync()
148    }
149
150    async fn write_at(&mut self, offset: u64, data: &[u8]) -> Result<()> {
151        self.ensure_open_sync()?;
152        let guard = self.lock_file()?;
153        let file = guard.as_ref().ok_or_else(|| {
154            Aria2Error::Io("file not open after ensure_open_sync — invariant violated".into())
155        })?;
156        write_all_at(file, data, offset)
157    }
158
159    /// Zero-copy write: accepts `Bytes` directly. Since `pwrite` takes `&[u8]`,
160    /// we simply dereference the `Bytes` (no copy — `Bytes` derefs to `[u8]`).
161    async fn write_bytes_at(&mut self, offset: u64, data: bytes::Bytes) -> Result<()> {
162        self.write_at(offset, &data).await
163    }
164
165    async fn read_at(&mut self, offset: u64, buf: &mut [u8]) -> Result<usize> {
166        self.ensure_open_sync()?;
167        let guard = self.lock_file()?;
168        let file = guard.as_ref().ok_or_else(|| {
169            Aria2Error::Io("file not open after ensure_open_sync — invariant violated".into())
170        })?;
171        read_exact_at(file, buf, offset)
172    }
173
174    async fn truncate(&mut self, length: u64) -> Result<()> {
175        self.ensure_open_sync()?;
176        let guard = self.lock_file()?;
177        if let Some(ref file) = *guard {
178            file.set_len(length)?;
179        }
180        Ok(())
181    }
182
183    async fn flush(&mut self) -> Result<()> {
184        let guard = self.lock_file()?;
185        if let Some(ref file) = *guard {
186            // sync_all (fsync) ensures durability. pwrite already placed data
187            // in the kernel page cache (visible to other readers); sync_all
188            // forces it to stable storage.
189            file.sync_all()?;
190        }
191        Ok(())
192    }
193
194    async fn len(&self) -> Result<u64> {
195        let guard = self.lock_file()?;
196        if let Some(ref file) = *guard {
197            Ok(file.metadata()?.len())
198        } else if let Some(size) = self.total_size {
199            Ok(size)
200        } else {
201            Ok(0)
202        }
203    }
204
205    fn path(&self) -> &Path {
206        &self.path
207    }
208}
209
210// =========================================================================
211// Platform-specific positioned I/O helpers
212// =========================================================================
213
214/// Positioned write that loops to handle partial writes.
215///
216/// Writes the entire `buf` at `offset` without modifying the file cursor,
217/// preserving `pwrite(2)` semantics while guaranteeing a complete write.
218fn write_all_at(file: &std::fs::File, mut buf: &[u8], mut offset: u64) -> Result<()> {
219    while !buf.is_empty() {
220        let n = positioned_write(file, buf, offset)?;
221        if n == 0 {
222            return Err(Aria2Error::Io(
223                "positioned write returned 0 — failed to write whole buffer".into(),
224            ));
225        }
226        offset += n as u64;
227        buf = &buf[n..];
228    }
229    Ok(())
230}
231
232/// Positioned read that loops to fill as much of `buf` as possible.
233///
234/// Returns the number of bytes read (may be less than `buf.len()` at EOF).
235fn read_exact_at(file: &std::fs::File, buf: &mut [u8], offset: u64) -> Result<usize> {
236    let mut filled = 0usize;
237    let mut current_offset = offset;
238    while filled < buf.len() {
239        let n = positioned_read(file, &mut buf[filled..], current_offset)?;
240        if n == 0 {
241            break; // EOF reached
242        }
243        filled += n;
244        current_offset += n as u64;
245    }
246    Ok(filled)
247}
248
249/// Single positioned write syscall. Returns bytes written.
250#[cfg(unix)]
251fn positioned_write(file: &std::fs::File, buf: &[u8], offset: u64) -> Result<usize> {
252    use std::os::unix::fs::FileExt;
253    Ok(file.write_at(buf, offset)?)
254}
255
256/// Single positioned read syscall. Returns bytes read.
257#[cfg(unix)]
258fn positioned_read(file: &std::fs::File, buf: &mut [u8], offset: u64) -> Result<usize> {
259    use std::os::unix::fs::FileExt;
260    Ok(file.read_at(buf, offset)?)
261}
262
263/// Single positioned write syscall (Windows). Returns bytes written.
264#[cfg(windows)]
265fn positioned_write(file: &std::fs::File, buf: &[u8], offset: u64) -> Result<usize> {
266    use std::os::windows::fs::FileExt;
267    Ok(file.seek_write(buf, offset)?)
268}
269
270/// Single positioned read syscall (Windows). Returns bytes read.
271#[cfg(windows)]
272fn positioned_read(file: &std::fs::File, buf: &mut [u8], offset: u64) -> Result<usize> {
273    use std::os::windows::fs::FileExt;
274    Ok(file.seek_read(buf, offset)?)
275}
276
277#[cfg(not(any(unix, windows)))]
278fn positioned_write(_file: &std::fs::File, _buf: &[u8], _offset: u64) -> Result<usize> {
279    Err(Aria2Error::Io(
280        "positioned write not supported on this platform".into(),
281    ))
282}
283
284#[cfg(not(any(unix, windows)))]
285fn positioned_read(_file: &std::fs::File, _buf: &mut [u8], _offset: u64) -> Result<usize> {
286    Err(Aria2Error::Io(
287        "positioned read not supported on this platform".into(),
288    ))
289}
290
291/// Create the best available positioned writer for the current platform.
292///
293/// On Linux with the `io_uring` feature enabled, returns an [`IoUringDiskWriter`]
294/// that uses the io_uring syscall interface for async positioned I/O. On all
295/// other platforms (or without the feature), returns a [`PositionedDiskWriter`]
296/// that uses synchronous `pwrite`/`seek_write`.
297///
298/// # Runtime requirement (io_uring)
299///
300/// When the `io_uring` feature is enabled on Linux, the returned writer MUST be
301/// driven from within a `tokio_uring` runtime context (e.g. inside
302/// `tokio_uring::start`). Using it inside a regular `tokio` runtime will panic
303/// because `tokio_uring::fs` operations require the io_uring reactor.
304///
305/// This factory is intentionally NOT wired into the default download pipeline.
306/// The main pipeline uses [`PositionedDiskWriter`] directly via `CachedDiskWriter`.
307pub fn create_positioned_writer(
308    path: &Path,
309    total_size: Option<u64>,
310) -> Box<dyn SeekableDiskWriter> {
311    #[cfg(all(target_os = "linux", feature = "io_uring"))]
312    {
313        Box::new(IoUringDiskWriter::new(path, total_size))
314    }
315    #[cfg(not(all(target_os = "linux", feature = "io_uring")))]
316    {
317        Box::new(PositionedDiskWriter::new(path, total_size))
318    }
319}
320
321// =========================================================================
322// Tests
323// =========================================================================
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use std::sync::Arc;
329
330    #[tokio::test]
331    async fn test_positioned_write_basic() {
332        let dir = tempfile::tempdir().unwrap();
333        let path = dir.path().join("test_basic.bin");
334
335        let mut writer = PositionedDiskWriter::new(&path, Some(1024));
336        writer.open().await.unwrap();
337        writer.write_at(0, b"hello world").await.unwrap();
338        writer.flush().await.unwrap();
339
340        let mut buf = [0u8; 11];
341        let n = writer.read_at(0, &mut buf).await.unwrap();
342        assert_eq!(n, 11);
343        assert_eq!(&buf, b"hello world");
344    }
345
346    #[tokio::test]
347    async fn test_positioned_write_at_offset() {
348        let dir = tempfile::tempdir().unwrap();
349        let path = dir.path().join("test_offset.bin");
350
351        let mut writer = PositionedDiskWriter::new(&path, None);
352        writer.open().await.unwrap();
353        // "data at 100" is 11 bytes
354        writer.write_at(100, b"data at 100").await.unwrap();
355        writer.flush().await.unwrap();
356
357        // Read back at offset 100
358        let mut buf = [0u8; 11];
359        let n = writer.read_at(100, &mut buf).await.unwrap();
360        assert_eq!(n, 11);
361        assert_eq!(&buf, b"data at 100");
362
363        // Verify offset 0 is zero-filled (sparse hole / OS zero-fill on extend)
364        let mut buf0 = [0xFFu8; 12];
365        let n0 = writer.read_at(0, &mut buf0).await.unwrap();
366        assert_eq!(n0, 12, "should read full 12 bytes from zero-filled region");
367        assert!(
368            buf0.iter().all(|&b| b == 0),
369            "offset 0 should be zero-filled, got {:?}",
370            buf0
371        );
372    }
373
374    #[tokio::test]
375    async fn test_positioned_writer_truncate_and_len() {
376        let dir = tempfile::tempdir().unwrap();
377        let path = dir.path().join("test_trunc.bin");
378
379        let mut writer = PositionedDiskWriter::new(&path, Some(2048));
380        writer.open().await.unwrap();
381
382        // Pre-allocated to total_size
383        let len = writer.len().await.unwrap();
384        assert_eq!(len, 2048);
385
386        writer.truncate(512).await.unwrap();
387        let len = writer.len().await.unwrap();
388        assert_eq!(len, 512);
389    }
390
391    #[tokio::test]
392    async fn test_positioned_writer_len_before_open() {
393        let dir = tempfile::tempdir().unwrap();
394        let path = dir.path().join("test_len_before_open.bin");
395
396        let writer = PositionedDiskWriter::new(&path, Some(9999));
397        let len = writer.len().await.unwrap();
398        assert_eq!(len, 9999, "should return total_size before open");
399    }
400
401    #[tokio::test]
402    async fn test_positioned_writer_len_no_total_size_before_open() {
403        let dir = tempfile::tempdir().unwrap();
404        let path = dir.path().join("test_len_none.bin");
405
406        let writer = PositionedDiskWriter::new(&path, None);
407        let len = writer.len().await.unwrap();
408        assert_eq!(len, 0, "should return 0 before open when no total_size");
409    }
410
411    #[tokio::test]
412    async fn test_positioned_writer_resume_does_not_truncate() {
413        // Verify that opening an existing file with total_size does NOT truncate
414        // existing data — critical for resume scenarios.
415        let dir = tempfile::tempdir().unwrap();
416        let path = dir.path().join("test_resume.bin");
417
418        // First writer: create and write data
419        {
420            let mut w = PositionedDiskWriter::new(&path, Some(1024));
421            w.open().await.unwrap();
422            w.write_at(0, b"resume-data").await.unwrap();
423            w.flush().await.unwrap();
424        }
425
426        // Second writer: open existing file with same total_size
427        {
428            let mut w = PositionedDiskWriter::new(&path, Some(1024));
429            w.open().await.unwrap();
430            let mut buf = [0u8; 11];
431            let n = w.read_at(0, &mut buf).await.unwrap();
432            assert_eq!(n, 11);
433            assert_eq!(&buf, b"resume-data", "existing data must survive reopen");
434        }
435    }
436
437    #[tokio::test]
438    async fn test_positioned_writer_creates_parent_dirs() {
439        let dir = tempfile::tempdir().unwrap();
440        let path = dir.path().join("nested").join("deep").join("file.bin");
441
442        let mut writer = PositionedDiskWriter::new(&path, Some(64));
443        writer.open().await.unwrap();
444        writer.write_at(0, b"x").await.unwrap();
445        writer.flush().await.unwrap();
446
447        assert!(path.exists(), "file should be created with parent dirs");
448    }
449
450    #[tokio::test]
451    async fn test_concurrent_writes_non_overlapping() {
452        // Test with a shared writer wrapped in Arc<tokio::sync::Mutex<>>.
453        // The internal std::sync::Mutex is held only for the syscall
454        // (microseconds), so even with the outer serialization the test
455        // validates positioned-write correctness and data integrity.
456        let dir = tempfile::tempdir().unwrap();
457        let path = dir.path().join("test_concurrent_shared.bin");
458
459        let chunk_size: usize = 64 * 1024;
460        let num_tasks: usize = 4;
461
462        let mut writer = PositionedDiskWriter::new(&path, Some((chunk_size * num_tasks) as u64));
463        writer.open().await.unwrap();
464        let writer = Arc::new(tokio::sync::Mutex::new(writer));
465
466        let mut handles = Vec::with_capacity(num_tasks);
467        for i in 0..num_tasks {
468            let offset = (i as u64) * chunk_size as u64;
469            let fill = (i as u8) + 1;
470            let data = bytes::Bytes::from(vec![fill; chunk_size]);
471            let w = writer.clone();
472            handles.push(tokio::spawn(async move {
473                let mut guard = w.lock().await;
474                guard.write_bytes_at(offset, data).await.unwrap();
475            }));
476        }
477
478        for handle in handles {
479            handle.await.unwrap();
480        }
481
482        {
483            let mut guard = writer.lock().await;
484            guard.flush().await.unwrap();
485        }
486
487        let content = tokio::fs::read(&path).await.unwrap();
488        assert_eq!(content.len(), chunk_size * num_tasks);
489        for i in 0..num_tasks {
490            let start = i * chunk_size;
491            let expected = (i as u8) + 1;
492            let chunk = &content[start..start + chunk_size];
493            assert!(
494                chunk.iter().all(|&b| b == expected),
495                "data mismatch in task {} chunk",
496                i
497            );
498        }
499    }
500
501    #[tokio::test]
502    async fn test_concurrent_writes_separate_writers() {
503        // True OS-level concurrency: each task opens its OWN writer to the SAME
504        // file path and writes to non-overlapping offsets. pwrite is atomic and
505        // offset-based, so concurrent non-overlapping writes are safe.
506        let dir = tempfile::tempdir().unwrap();
507        let path = dir.path().join("test_concurrent_sep.bin");
508
509        let chunk_size: usize = 64 * 1024;
510        let num_tasks: usize = 4;
511
512        // Pre-create and allocate the file via one writer, then drop it.
513        {
514            let mut w0 = PositionedDiskWriter::new(&path, Some((chunk_size * num_tasks) as u64));
515            w0.open().await.unwrap();
516            w0.flush().await.unwrap();
517        }
518
519        let mut handles = Vec::with_capacity(num_tasks);
520        for i in 0..num_tasks {
521            let offset = (i as u64) * chunk_size as u64;
522            let fill = (i as u8) + 1;
523            let data = vec![fill; chunk_size];
524            let path_clone = path.clone();
525            handles.push(tokio::spawn(async move {
526                let mut w = PositionedDiskWriter::new(&path_clone, None);
527                w.open().await.unwrap();
528                w.write_at(offset, &data).await.unwrap();
529                w.flush().await.unwrap();
530            }));
531        }
532
533        for handle in handles {
534            handle.await.unwrap();
535        }
536
537        let content = tokio::fs::read(&path).await.unwrap();
538        assert_eq!(content.len(), chunk_size * num_tasks);
539        for i in 0..num_tasks {
540            let start = i * chunk_size;
541            let expected = (i as u8) + 1;
542            let chunk = &content[start..start + chunk_size];
543            assert!(
544                chunk.iter().all(|&b| b == expected),
545                "data mismatch in separate-writer task {} chunk",
546                i
547            );
548        }
549    }
550
551    #[tokio::test]
552    async fn test_positioned_writer_write_bytes_at_zero_copy() {
553        let dir = tempfile::tempdir().unwrap();
554        let path = dir.path().join("test_zero_copy.bin");
555
556        let mut writer = PositionedDiskWriter::new(&path, Some(256));
557        writer.open().await.unwrap();
558
559        let data = bytes::Bytes::from(vec![0xAB; 128]);
560        writer.write_bytes_at(0, data).await.unwrap();
561        writer.flush().await.unwrap();
562
563        let mut buf = [0u8; 128];
564        let n = writer.read_at(0, &mut buf).await.unwrap();
565        assert_eq!(n, 128);
566        assert!(buf.iter().all(|&b| b == 0xAB));
567    }
568}
569
570// =========================================================================
571// io_uring backend (Linux only, feature-gated)
572//
573// IMPORTANT: `tokio-uring` requires its own single-threaded runtime
574// (`tokio_uring::start` / `tokio_uring::Runtime`). All async operations on
575// `IoUringDiskWriter` MUST be driven from within a `tokio_uring` runtime
576// context. Using them inside a regular `tokio` runtime will panic at the
577// first I/O call because the io_uring reactor is not installed.
578//
579// This backend is intentionally NOT wired into the default download pipeline
580// (which uses `PositionedDiskWriter` via `CachedDiskWriter`). It is an opt-in
581// experimental path selected via [`create_positioned_writer`] when the
582// `io_uring` feature is enabled on Linux.
583// =========================================================================
584
585#[cfg(all(target_os = "linux", feature = "io_uring"))]
586mod io_uring_backend {
587    use std::path::{Path, PathBuf};
588
589    use async_trait::async_trait;
590    use tracing::debug;
591
592    use crate::error::{Aria2Error, Result};
593
594    use super::SeekableDiskWriter;
595
596    /// A disk writer that performs positioned I/O via the Linux `io_uring`
597    /// syscall interface using [`tokio_uring::fs::File`].
598    ///
599    /// # Runtime requirement
600    ///
601    /// All async methods MUST be called from within a `tokio_uring` runtime
602    /// context (e.g. inside `tokio_uring::start`). The underlying
603    /// `tokio_uring::fs::File` operations register buffers and completion
604    /// entries with the io_uring instance, which is only available inside a
605    /// `tokio_uring` runtime.
606    ///
607    /// # Concurrency model
608    ///
609    /// Like [`super::PositionedDiskWriter`], `write_at` takes `&mut self`, so
610    /// calls on a single instance are serialized at the application level.
611    /// True OS-level concurrency is achieved by opening separate writer
612    /// instances (each with its own file descriptor) to the same file path and
613    /// writing to non-overlapping offsets — `io_uring` submits these as
614    /// independent SQEs that the kernel can complete in parallel.
615    pub struct IoUringDiskWriter {
616        /// The `tokio_uring::fs::File`, wrapped in `Option` for lazy open and
617        /// clean close/reopen semantics.
618        file: Option<tokio_uring::fs::File>,
619        path: PathBuf,
620        total_size: Option<u64>,
621    }
622
623    impl IoUringDiskWriter {
624        /// Create a new `IoUringDiskWriter` for the given path.
625        ///
626        /// If `total_size` is provided and the file is newly created (size 0),
627        /// the file is pre-allocated to `total_size` bytes on first open. This
628        /// matches [`super::PositionedDiskWriter`] semantics for fragmentation
629        /// avoidance and concurrent-write readiness.
630        pub fn new(path: &Path, total_size: Option<u64>) -> Self {
631            Self {
632                file: None,
633                path: path.to_path_buf(),
634                total_size,
635            }
636        }
637
638        /// Returns the configured total size, if any.
639        pub fn total_size(&self) -> Option<u64> {
640            self.total_size
641        }
642    }
643
644    #[async_trait]
645    impl SeekableDiskWriter for IoUringDiskWriter {
646        async fn open(&mut self) -> Result<()> {
647            if self.file.is_some() {
648                return Ok(());
649            }
650
651            // Create parent directories if needed (resume scenario may have
652            // missing dirs). This is a synchronous call but it is fast and
653            // only runs once per open.
654            if let Some(parent) = self.path.parent()
655                && !parent.as_os_str().is_empty()
656                && !parent.exists()
657            {
658                std::fs::create_dir_all(parent)?;
659                debug!("Created parent directories for {:?}", self.path);
660            }
661
662            // Pre-allocate the file if a total size was specified and the file
663            // is newly created (current size 0). `tokio_uring::fs::File` does
664            // not expose `set_len`, so we open a transient `std::fs::File`,
665            // call `set_len`, and drop it before opening via tokio_uring.
666            //
667            // Resume safety: we do NOT truncate existing data — only extend
668            // the file if it is empty.
669            if let Some(size) = self.total_size {
670                let std_file = std::fs::OpenOptions::new()
671                    .create(true)
672                    .write(true)
673                    .read(true)
674                    .open(&self.path)?;
675                let current_size = std_file.metadata()?.len();
676                if current_size == 0 && size > 0 {
677                    std_file.set_len(size)?;
678                    debug!("Pre-allocated file to {} bytes: {:?}", size, self.path);
679                }
680                drop(std_file);
681            }
682
683            // Open via tokio_uring with read+write+create (no truncate) to
684            // preserve any existing data for resume scenarios.
685            let file = tokio_uring::fs::OpenOptions::new()
686                .create(true)
687                .write(true)
688                .read(true)
689                .open(&self.path)
690                .await
691                .map_err(|e| Aria2Error::Io(format!("io_uring open failed: {e}")))?;
692            debug!("Opened file for io_uring positioned I/O: {:?}", self.path);
693
694            self.file = Some(file);
695            Ok(())
696        }
697
698        async fn write_at(&mut self, offset: u64, data: &[u8]) -> Result<()> {
699            let file = self
700                .file
701                .as_ref()
702                .ok_or_else(|| Aria2Error::Io("io_uring file not open".into()))?;
703            write_all_at_uring(file, data, offset).await
704        }
705
706        /// Zero-copy write: accepts `Bytes` directly. Since io_uring `write_at`
707        /// takes a buffer reference, we simply dereference the `Bytes` (no copy
708        /// — `Bytes` derefs to `[u8]`).
709        async fn write_bytes_at(&mut self, offset: u64, data: bytes::Bytes) -> Result<()> {
710            self.write_at(offset, &data).await
711        }
712
713        async fn read_at(&mut self, offset: u64, buf: &mut [u8]) -> Result<usize> {
714            let file = self
715                .file
716                .as_ref()
717                .ok_or_else(|| Aria2Error::Io("io_uring file not open".into()))?;
718            read_exact_at_uring(file, buf, offset).await
719        }
720
721        async fn truncate(&mut self, length: u64) -> Result<()> {
722            // `tokio_uring::fs::File` does not expose `set_len`. Close the
723            // io_uring file, truncate via `std::fs::File::set_len`, then reopen
724            // via tokio_uring to get a fresh file handle.
725            if let Some(file) = self.file.take() {
726                let _ = file.close().await.map_err(|e| {
727                    Aria2Error::Io(format!("io_uring close during truncate failed: {e}"))
728                })?;
729            }
730            std::fs::OpenOptions::new()
731                .write(true)
732                .open(&self.path)?
733                .set_len(length)?;
734            let file = tokio_uring::fs::OpenOptions::new()
735                .create(true)
736                .write(true)
737                .read(true)
738                .open(&self.path)
739                .await
740                .map_err(|e| {
741                    Aria2Error::Io(format!("io_uring reopen after truncate failed: {e}"))
742                })?;
743            self.file = Some(file);
744            Ok(())
745        }
746
747        async fn flush(&mut self) -> Result<()> {
748            if let Some(ref file) = self.file {
749                file.sync_all()
750                    .await
751                    .map_err(|e| Aria2Error::Io(format!("io_uring sync_all failed: {e}")))?;
752            }
753            Ok(())
754        }
755
756        async fn len(&self) -> Result<u64> {
757            if self.file.is_some() {
758                // Use a synchronous `stat` (fast, non-blocking syscall) to get
759                // the file size. This avoids requiring a tokio runtime context
760                // (which may not be available inside tokio_uring::start).
761                Ok(std::fs::metadata(&self.path)
762                    .map(|m| m.len())
763                    .unwrap_or_else(|_| self.total_size.unwrap_or(0)))
764            } else if let Some(size) = self.total_size {
765                Ok(size)
766            } else {
767                Ok(0)
768            }
769        }
770
771        fn path(&self) -> &Path {
772            &self.path
773        }
774
775        /// Close the writer and release the io_uring file handle.
776        async fn close(&mut self) -> Result<()> {
777            if let Some(file) = self.file.take() {
778                file.close()
779                    .await
780                    .map_err(|e| Aria2Error::Io(format!("io_uring close failed: {e}")))?;
781            }
782            Ok(())
783        }
784    }
785
786    /// Positioned write via io_uring that loops to handle partial writes.
787    ///
788    /// Writes the entire `buf` at `offset`, guaranteeing a complete write.
789    /// `tokio_uring::fs::File::write_at` returns `(io::Result<usize>, B)` where
790    /// `B` is the original buffer (for `&[u8]` this is a `Copy` reference, so
791    /// looping is zero-allocation).
792    async fn write_all_at_uring(
793        file: &tokio_uring::fs::File,
794        mut buf: &[u8],
795        mut offset: u64,
796    ) -> Result<()> {
797        while !buf.is_empty() {
798            let (res, _) = file.write_at(buf, offset).await;
799            let n = res.map_err(|e| Aria2Error::Io(format!("io_uring write_at failed: {e}")))?;
800            if n == 0 {
801                return Err(Aria2Error::Io(
802                    "io_uring write_at returned 0 — failed to write whole buffer".into(),
803                ));
804            }
805            offset += n as u64;
806            buf = &buf[n..];
807        }
808        Ok(())
809    }
810
811    /// Positioned read via io_uring that loops to fill as much of `buf` as
812    /// possible. Returns the number of bytes read (may be less than
813    /// `buf.len()` at EOF).
814    async fn read_exact_at_uring(
815        file: &tokio_uring::fs::File,
816        mut buf: &mut [u8],
817        mut offset: u64,
818    ) -> Result<usize> {
819        let mut filled = 0usize;
820        while !buf.is_empty() {
821            let (res, returned_buf) = file.read_at(buf, offset).await;
822            let n = res.map_err(|e| Aria2Error::Io(format!("io_uring read_at failed: {e}")))?;
823            if n == 0 {
824                break; // EOF reached
825            }
826            filled += n;
827            offset += n as u64;
828            buf = &mut returned_buf[n..];
829        }
830        Ok(filled)
831    }
832}
833
834// Re-export the io_uring writer at the module level when the feature is on.
835#[cfg(all(target_os = "linux", feature = "io_uring"))]
836pub use io_uring_backend::IoUringDiskWriter;
837
838// =========================================================================
839// io_uring tests (Linux + feature only)
840//
841// These tests are excluded on Windows/macOS and when the `io_uring` feature is
842// off. They use `tokio_uring::start` to drive the io_uring runtime.
843// =========================================================================
844
845#[cfg(all(test, target_os = "linux", feature = "io_uring"))]
846mod io_uring_tests {
847    use super::IoUringDiskWriter;
848
849    #[test]
850    fn test_iouring_basic_write_read() {
851        tokio_uring::start(async {
852            let dir = tempfile::tempdir().unwrap();
853            let path = dir.path().join("iouring_basic.bin");
854
855            let mut writer = IoUringDiskWriter::new(&path, Some(1024));
856            writer.open().await.unwrap();
857            writer.write_at(0, b"hello io_uring").await.unwrap();
858            writer.flush().await.unwrap();
859
860            let mut buf = [0u8; 14];
861            let n = writer.read_at(0, &mut buf).await.unwrap();
862            assert_eq!(n, 14);
863            assert_eq!(&buf, b"hello io_uring");
864        });
865    }
866
867    #[test]
868    fn test_iouring_write_at_offset() {
869        tokio_uring::start(async {
870            let dir = tempfile::tempdir().unwrap();
871            let path = dir.path().join("iouring_offset.bin");
872
873            let mut writer = IoUringDiskWriter::new(&path, None);
874            writer.open().await.unwrap();
875            writer.write_at(100, b"offset data").await.unwrap();
876            writer.flush().await.unwrap();
877
878            let mut buf = [0u8; 11];
879            let n = writer.read_at(100, &mut buf).await.unwrap();
880            assert_eq!(n, 11);
881            assert_eq!(&buf, b"offset data");
882        });
883    }
884
885    #[test]
886    fn test_iouring_truncate_and_len() {
887        tokio_uring::start(async {
888            let dir = tempfile::tempdir().unwrap();
889            let path = dir.path().join("iouring_trunc.bin");
890
891            let mut writer = IoUringDiskWriter::new(&path, Some(2048));
892            writer.open().await.unwrap();
893
894            let len = writer.len().await.unwrap();
895            assert_eq!(len, 2048);
896
897            writer.truncate(512).await.unwrap();
898            let len = writer.len().await.unwrap();
899            assert_eq!(len, 512);
900        });
901    }
902
903    #[test]
904    fn test_iouring_close_reopen() {
905        tokio_uring::start(async {
906            let dir = tempfile::tempdir().unwrap();
907            let path = dir.path().join("iouring_close.bin");
908
909            let mut writer = IoUringDiskWriter::new(&path, None);
910            writer.open().await.unwrap();
911            writer.write_at(0, b"before close").await.unwrap();
912            writer.flush().await.unwrap();
913            writer.close().await.unwrap();
914
915            writer.open().await.unwrap();
916            writer.write_at(12, b" after reopen").await.unwrap();
917            writer.flush().await.unwrap();
918            writer.close().await.unwrap();
919
920            let content = std::fs::read(&path).unwrap();
921            assert_eq!(&content, b"before close after reopen");
922        });
923    }
924
925    #[test]
926    fn test_iouring_resume_does_not_truncate() {
927        tokio_uring::start(async {
928            let dir = tempfile::tempdir().unwrap();
929            let path = dir.path().join("iouring_resume.bin");
930
931            // First writer: create and write data
932            {
933                let mut w = IoUringDiskWriter::new(&path, Some(1024));
934                w.open().await.unwrap();
935                w.write_at(0, b"resume-data").await.unwrap();
936                w.flush().await.unwrap();
937                w.close().await.unwrap();
938            }
939
940            // Second writer: open existing file with same total_size
941            {
942                let mut w = IoUringDiskWriter::new(&path, Some(1024));
943                w.open().await.unwrap();
944                let mut buf = [0u8; 11];
945                let n = w.read_at(0, &mut buf).await.unwrap();
946                assert_eq!(n, 11);
947                assert_eq!(&buf, b"resume-data", "existing data must survive reopen");
948            }
949        });
950    }
951
952    #[test]
953    fn test_iouring_creates_parent_dirs() {
954        tokio_uring::start(async {
955            let dir = tempfile::tempdir().unwrap();
956            let path = dir.path().join("nested").join("deep").join("file.bin");
957
958            let mut writer = IoUringDiskWriter::new(&path, Some(64));
959            writer.open().await.unwrap();
960            writer.write_at(0, b"x").await.unwrap();
961            writer.flush().await.unwrap();
962
963            assert!(path.exists(), "file should be created with parent dirs");
964        });
965    }
966
967    #[test]
968    fn test_iouring_write_bytes_at() {
969        tokio_uring::start(async {
970            let dir = tempfile::tempdir().unwrap();
971            let path = dir.path().join("iouring_bytes.bin");
972
973            let mut writer = IoUringDiskWriter::new(&path, Some(256));
974            writer.open().await.unwrap();
975
976            let data = bytes::Bytes::from(vec![0xAB; 128]);
977            writer.write_bytes_at(0, data).await.unwrap();
978            writer.flush().await.unwrap();
979
980            let mut buf = [0u8; 128];
981            let n = writer.read_at(0, &mut buf).await.unwrap();
982            assert_eq!(n, 128);
983            assert!(buf.iter().all(|&b| b == 0xAB));
984        });
985    }
986}