test(integration): make halt-on-skip-forward test deterministic

The wallclock-based halt timing failed on fast CI runners where a 2 GB
synthetic-disc skip-forward sweep finishes in <100 ms — well under the
200 ms halt fire delay. Reader now signals halt on first read; the
inner-loop halt check on iteration 2 breaks 'outer. No wallclock race.
This commit is contained in:
MattJackson
2026-04-25 17:33:52 -07:00
parent b4951b1c5b
commit 4bd38787c6
+32 -24
View File
@@ -355,20 +355,28 @@ fn test_file_sector_reader_round_trip() {
// - halted = false (no user stop) // - halted = false (no user stop)
// - ISO file is `total_bytes` size on disk (sparse zeros) // - ISO file is `total_bytes` size on disk (sparse zeros)
/// Reader that returns Err for every read. Models the worst case where the /// Reader that returns Err for every read. Optionally signals a halt
/// drive can read nothing on this disc — Pass 1 must still walk to end of disc. /// flag on the first read so tests can exercise the halt-during-skip-forward
/// path deterministically (no wallclock dependency).
struct FailingSectorReader { struct FailingSectorReader {
capacity: u32, capacity: u32,
/// Per-call delay so the test exercises the skip-forward path realistically /// If set, signals halt on the first `read_sectors` call. Cleared after
/// without burning real wallclock. /// the first signal so subsequent reads are plain Err.
err_delay_ms: u64, halt_on_first_read: Option<Arc<AtomicBool>>,
} }
impl FailingSectorReader { impl FailingSectorReader {
fn new(capacity: u32) -> Self { fn new(capacity: u32) -> Self {
Self { Self {
capacity, capacity,
err_delay_ms: 0, halt_on_first_read: None,
}
}
fn with_halt_on_first_read(capacity: u32, halt: Arc<AtomicBool>) -> Self {
Self {
capacity,
halt_on_first_read: Some(halt),
} }
} }
} }
@@ -381,8 +389,8 @@ impl SectorReader for FailingSectorReader {
_buf: &mut [u8], _buf: &mut [u8],
_recovery: bool, _recovery: bool,
) -> Result<usize> { ) -> Result<usize> {
if self.err_delay_ms > 0 { if let Some(h) = self.halt_on_first_read.take() {
std::thread::sleep(Duration::from_millis(self.err_delay_ms)); h.store(true, Ordering::Relaxed);
} }
Err(libfreemkv::error::Error::DiscRead { sector: lba as u64 }) Err(libfreemkv::error::Error::DiscRead { sector: lba as u64 })
} }
@@ -469,34 +477,30 @@ fn test_disc_copy_completes_full_disc_with_failing_reader() {
// the CopyResult. // the CopyResult.
} }
// ── 7. Halt during Pass 1 of an all-failing-read sweep returns promptly ── // ── 7. Halt during Pass 1 skip-forward path returns promptly (deterministic)
// //
// Per RIP_DESIGN.md §3: halt is the only legitimate early exit from Pass 1. // Per RIP_DESIGN.md §3: halt is the only legitimate early exit from Pass 1.
// Even when every read is failing (skip-forward path), a halt must be // Even when every read is failing (skip-forward path), a halt must be
// honored within a small bounded time. // honored within a small bounded time.
//
// Deterministic fixture: the reader signals halt on its FIRST read. The
// inner copy loop's halt check fires on the next iteration, breaking out
// of 'outer. This avoids any wallclock race on fast CI runners (where a
// 2 GB synthetic disc can sweep skip-forward in <100 ms).
#[test] #[test]
fn test_disc_copy_halts_promptly_on_failing_reader() { fn test_disc_copy_halts_promptly_on_failing_reader() {
let capacity_sectors: u32 = 1024 * 1024; // 2 GB synthetic disc — plenty of work let capacity_sectors: u32 = 1024 * 1024; // 2 GB synthetic disc
let mut reader = FailingSectorReader {
capacity: capacity_sectors, let halt = Arc::new(AtomicBool::new(false));
err_delay_ms: 1, // small per-read delay so halt has something to interrupt let mut reader =
}; FailingSectorReader::with_halt_on_first_read(capacity_sectors, halt.clone());
let disc = synthetic_disc(capacity_sectors); let disc = synthetic_disc(capacity_sectors);
let tmp = tempfile::NamedTempFile::new().expect("tempfile create"); let tmp = tempfile::NamedTempFile::new().expect("tempfile create");
let iso_path = tmp.path().to_path_buf(); let iso_path = tmp.path().to_path_buf();
drop(tmp); drop(tmp);
let halt = Arc::new(AtomicBool::new(false));
let halt_setter = halt.clone();
// Trigger halt after 200 ms.
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(200));
halt_setter.store(true, Ordering::Relaxed);
});
let opts = CopyOptions { let opts = CopyOptions {
decrypt: false, decrypt: false,
skip_on_error: true, skip_on_error: true,
@@ -522,6 +526,10 @@ fn test_disc_copy_halts_promptly_on_failing_reader() {
assert!(result.halted, "result.halted must be true"); assert!(result.halted, "result.halted must be true");
assert!( assert!(
!result.complete, !result.complete,
"halted run cannot be complete (bytes_pending > 0 likely)" "halted run cannot be complete (bytes_pending > 0 expected)"
);
assert!(
result.bytes_pending > 0,
"halt fired before sweep completed; bytes_pending must be > 0"
); );
} }