diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80e64e92..2895180d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,7 +96,7 @@ jobs: - run: cargo test --tests working-directory: libfreemkv - # The memory orderings in `scsi::fd_handoff` compile to the same instructions + # The memory orderings in `scsi::fd_handoff` (and `halt`) compile to the same instructions # on x86_64, so the WRONG ones pass every run; loom enumerates the executions # the C++20 model permits and is the only regression coverage that code has. # Its own job because RUSTFLAGS is part of the rust-cache key. Takes the sibling @@ -123,6 +123,9 @@ jobs: workspaces: libfreemkv - run: cargo test --lib --no-default-features --features scsi fd_handoff working-directory: libfreemkv + # LT3: `Halt`'s cancel Release / observe Acquire pair (stop design §2.1, SS-23). + - run: cargo test --lib --no-default-features --features scsi loom_halt + working-directory: libfreemkv # `lint` runs clippy without --cfg loom, so this is the only place the # model code is linted at all. - run: cargo clippy --lib --no-default-features --features scsi --profile test -- -D warnings diff --git a/src/aacs/content.rs b/src/aacs/content.rs index d18d6fef..e3565385 100644 --- a/src/aacs/content.rs +++ b/src/aacs/content.rs @@ -87,6 +87,31 @@ pub fn aacs_unit_seed_encrypted(unit: &[u8], format: crate::disc::ContentFormat) } } +/// Clear the Copy_permission_indicator of every source packet of a unit freemkv has +/// DECRYPTED, so the flag is true of the bytes written (KU design §5.4, K-13). `BdTs` +/// only; HD DVD (`MpegPs`) is excluded (§2.6). Never called on a unit left as ciphertext. +pub(crate) fn clear_copy_permission_indicator(unit: &mut [u8], format: crate::disc::ContentFormat) { + if format != crate::disc::ContentFormat::BdTs || unit.len() < ALIGNED_UNIT_LEN { + return; + } + // KS-5 [BD] §3.10.2: "shall be set to 11₂ if the data is encrypted, or … 00₂ if the data + // is not encrypted"; KS-6: CPI is the top 2 of TP_extra_header's 32 bits (ATS kept). + // KS-22 libaacs `_verify_ts` (corroboration): "buf[i] &= ~0xc0;" per 192-byte packet. + for packet in unit[..ALIGNED_UNIT_LEN].chunks_mut(BD_SOURCE_PACKET_BYTES) { + packet[0] &= 0x3F; + } +} + +/// Test helper: `plain` as a decrypt writes it, CPI cleared in every source packet (KS-5; +/// KU design §5.4). For a buffer of whole decrypted units only. +#[cfg(test)] +pub(crate) fn cpi_cleared(mut plain: Vec) -> Vec { + for packet in plain.chunks_mut(BD_SOURCE_PACKET_BYTES) { + packet[0] &= 0x3F; + } + plain +} + /// Was `unit` cut on its file's unit grid? An encrypted BD-TS unit's clear seed /// always starts a source packet, so its TS sync (0x47) sits at byte 4; a chunk /// read off the grid starts mid-unit on ciphertext. Clear units, and `MpegPs` @@ -103,10 +128,11 @@ pub(crate) fn aacs_unit_on_grid(unit: &[u8], format: crate::disc::ContentFormat) /// True when an aligned unit is flagged encrypted AND still looks scrambled /// (structure not yet restored) — genuine encrypted content NOT yet decrypted. /// -/// Composes [`aacs_unit_encrypted`] (the authoritative flag, which decryption never rewrites) -/// with an IDEMPOTENT "structure restored?" check, so callers that may run twice over the same -/// buffer (re-decrypt, sampling, diagnosis) get a stable answer. Only meaningful at the -/// clip-FILE-anchored boundary. +/// Composes [`aacs_unit_encrypted`] (the authoritative flag) with an IDEMPOTENT "structure +/// restored?" check, so callers that may run twice over the same buffer (re-decrypt, sampling, +/// diagnosis) get a stable answer. A unit freemkv decrypted has its flag CLEARED (KS-5 `[BD]` +/// §3.10.2: "00₂ if the data is not encrypted"; KU design §5.4), so it reads clear here; a +/// unit left as ciphertext keeps it. Only meaningful at the clip-FILE-anchored boundary. pub fn aacs_unit_needs_decrypt(unit: &[u8], format: crate::disc::ContentFormat) -> bool { // "Still needs key" = flagged encrypted AND not structurally clean per the // ONE definition, [`is_clean`]'s min(E,4) proof floor. Never a second threshold: @@ -1623,9 +1649,9 @@ mod spec_guards { assert!(changed, "every block of the final 6128 bytes is decrypted"); } - /// per spec; do not change without a spec citation — KS-3 [BD] §3.10.1: "A new CBC - /// cipher chain is started for each Aligned Unit": the mapped decrypt of a multi-unit - /// buffer, in either order, never chains one unit's ciphertext into the next. + /// per spec; do not change without a spec citation proving otherwise — KS-3 [BD] + /// §3.10.1: "A new CBC cipher chain is started for each Aligned Unit": the mapped decrypt + /// of a multi-unit buffer, in either order, never chains one unit into the next. #[test] fn cbc_chain_restarts_every_unit() { use crate::decrypt::{AacsKeyMap, DecryptKeys, Phase, decrypt_sectors_mapped}; @@ -1662,7 +1688,11 @@ mod spec_guards { for order in [[0, 1], [1, 0]] { let mut buf: Vec = order.iter().flat_map(|&i| enc[i].clone()).collect(); decrypt_sectors_mapped(&mut buf, &keys, 0, &map).expect("both units keyed"); - let want: Vec = order.iter().flat_map(|&i| plain[i].clone()).collect(); + // KS-5: a decrypted unit's CPI reads 00₂ (KU design §5.4); the chain rule is KS-3. + let want: Vec = order + .iter() + .flat_map(|&i| super::cpi_cleared(plain[i].clone())) + .collect(); assert!( buf == want, "order {order:?}: each unit decrypts on its own chain" diff --git a/src/aacs/crypto.rs b/src/aacs/crypto.rs index 507e8ca6..55908783 100644 --- a/src/aacs/crypto.rs +++ b/src/aacs/crypto.rs @@ -24,7 +24,9 @@ thread_local! { } /// Build an AES-128 key schedule for a caller that will drive -/// [`cbc_decrypt_blocks`] over several regions under one key. +/// [`cbc_decrypt_blocks`] over several regions under one key. Also used by the +/// `aacs::derive` cvalue-candidate ECB scan (L104), which shares one schedule +/// per Processing Key across every candidate instead of building one per call. pub(crate) fn new_cipher_for(key: &[u8; 16]) -> Aes128 { new_cipher(key) } @@ -50,6 +52,13 @@ pub(crate) fn aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { /// AES-128-ECB decrypt a single 16-byte block. `[C]` §2.1.1 (`AES-128D`). pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { let cipher = Aes128::new(&(*key).into()); + aes_ecb_decrypt_with_cipher(&cipher, data) +} + +/// AES-128-ECB decrypt a single block under an already-expanded key schedule. Split out of +/// [`aes_ecb_decrypt`] for a candidate-scan loop that reuses one Processing Key's schedule +/// across many `(uv, cvalue)` pairs instead of rebuilding it per candidate (L104). +pub(crate) fn aes_ecb_decrypt_with_cipher(cipher: &Aes128, data: &[u8; 16]) -> [u8; 16] { let mut block: Array = (*data).into(); cipher.decrypt_block(&mut block); let mut out = [0u8; 16]; @@ -128,9 +137,19 @@ pub(crate) fn cbc_decrypt_blocks(cipher: &Aes128, data: &mut [u8]) { } } +// Per-thread count of `aes_g` calls. Test-only: L103 hoists the slot-independent +// `aes_g(kp, nonce)` out of the AACS 2.1 variant per-slot loop (~46k slots/disc), so a test +// can assert it now runs once per variant MKB, not once per slot. +#[cfg(test)] +thread_local! { + pub(crate) static AES_G_CALLS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + // AES-G(x1, x2) = AES-128D(x1, x2) XOR x2 (`[C]` §2.1.3, uses AES-128D). Used by the Media Key // Variant chain to derive Kvn and Kvu. pub(crate) fn aes_g(x1: &[u8; 16], x2: &[u8; 16]) -> [u8; 16] { + #[cfg(test)] + AES_G_CALLS.with(|c| c.set(c.get() + 1)); let mut out = aes_ecb_decrypt(x1, x2); for i in 0..16 { out[i] ^= x2[i]; diff --git a/src/aacs/derive.rs b/src/aacs/derive.rs index a76a0b93..5d8f0cda 100644 --- a/src/aacs/derive.rs +++ b/src/aacs/derive.rs @@ -51,6 +51,10 @@ pub(crate) fn try_pk_against_tables( .count(); for pk in processing_keys { + // `pk` is loop-invariant across every (uv, cvalue) candidate below (up to ~46k on a + // UHD MKB): build its AES-128 schedule once (L104) and reuse it via + // `validate_processing_key_with_cipher`, byte-identical to `validate_processing_key`. + let cipher = new_cipher_for(pk); for i in 0..num_uvs { if (i + 1) * 16 > cvalues.len() { continue; @@ -61,7 +65,7 @@ pub(crate) fn try_pk_against_tables( } let uv = &uvs[record_start + 1..record_start + 5]; let cv = &cvalues[i * 16..(i + 1) * 16]; - if let Some(mk) = validate_processing_key(pk, cv, uv, mk_dv) { + if let Some(mk) = validate_processing_key_with_cipher(&cipher, cv, uv, mk_dv) { return Some(mk); } } @@ -69,6 +73,38 @@ pub(crate) fn try_pk_against_tables( None } +// Same relation as [`validate_processing_key`], but driven by a caller-supplied AES-128 key +// schedule for `pk` instead of building one internally — the cvalue-loop fast path +// ([`try_pk_against_tables`]) shares one schedule across every candidate for a given pk (L104). +fn validate_processing_key_with_cipher( + cipher: &aes::Aes128, + cvalue: &[u8], + uv: &[u8], + mk_dv: &[u8; 16], +) -> Option<[u8; 16]> { + if cvalue.len() < 16 || uv.len() < 4 { + return None; + } + + // Step 1: mk = AES-128D(pk, cvalue), via the shared schedule. + let mut cv = [0u8; 16]; + cv.copy_from_slice(&cvalue[..16]); + let mut mk = aes_ecb_decrypt_with_cipher(cipher, &cv); + + // Step 2: XOR uv into the last 4 bytes of mk (mk[12..16]). + for a in 0..4 { + mk[12 + a] ^= uv[a]; + } + + // Step 3 + 4: dec_vd = AES-128D(mk, mk_dv); verify magic. + let dec_vd = aes_ecb_decrypt(&mk, mk_dv); + const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]; + if dec_vd[..8] == VERIFY_MAGIC { + return Some(mk); + } + None +} + // Validate a processing key against a cvalue/UV pair; returns the Media Key // if valid. `[C]` §3.2.4 (mk = AES-128D(pk,cvalue); mk[12..16] ^= uv) then // §3.2.5.1.4 (dec_vd = AES-128D(mk,mk_dv); valid iff dec_vd[0..8] == magic). @@ -1563,6 +1599,162 @@ mod position_recovery_tests { ); } + // ── L104: cvalue-loop cipher hoist — equivalence, call-count, and spec quote ────── + + /// Test-only ORACLE: `try_pk_against_tables` as it read before L104 — rebuilds an + /// AES-128 schedule for `pk` on every candidate via `validate_processing_key` + /// (UNCHANGED by L104; still builds its own schedule per call, since `dk_walk` and + /// `recover_dk_position` keep calling it that way). Used only to prove the hoisted + /// loop agrees with it, never as production code. + fn try_pk_against_tables_oracle( + processing_keys: &[[u8; 16]], + uvs: &[u8], + cvalues: &[u8], + mk_dv: &[u8; 16], + ) -> Option<[u8; 16]> { + let num_uvs = uvs + .chunks(5) + .take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0) + .count(); + for pk in processing_keys { + for i in 0..num_uvs { + if (i + 1) * 16 > cvalues.len() { + continue; + } + let record_start = i * 5; + if record_start + 5 > uvs.len() { + continue; + } + let uv = &uvs[record_start + 1..record_start + 5]; + let cv = &cvalues[i * 16..(i + 1) * 16]; + if let Some(mk) = validate_processing_key(pk, cv, uv, mk_dv) { + return Some(mk); + } + } + } + None + } + + /// L104 equivalence: the hoisted-cipher loop must return byte-identical results to the + /// pre-hoist oracle — a matching PK behind decoys, a one-bit-away PK, two PKs tried in + /// order, and an empty table. + #[test] + fn try_pk_against_tables_matches_the_pre_hoist_oracle() { + let (_dkey, mk, pk, cv, mk_dv) = four_level_parts(); + let slots = [ + (U_MASK_SHIFT, 0x0000_1100u32), + (U_MASK_SHIFT, 0x0000_2200u32), + (U_MASK_SHIFT, UV_SLOT4), + ]; + let mut cvalues = vec![0x44u8; 16]; + cvalues.extend_from_slice(&[0x55u8; 16]); + cvalues.extend_from_slice(&cv); + let mkb = build_mkb(&slots, &cvalues, &mk_dv); + let uvs = mkb_find_subdiff_records(&mkb).expect("subdiff"); + + assert_eq!( + try_pk_against_tables(&[pk], &uvs, &cvalues, &mk_dv), + try_pk_against_tables_oracle(&[pk], &uvs, &cvalues, &mk_dv), + ); + assert_eq!( + try_pk_against_tables(&[pk], &uvs, &cvalues, &mk_dv), + Some(mk) + ); + + let mut stranger = pk; + stranger[0] ^= 0x01; + assert_eq!( + try_pk_against_tables(&[stranger], &uvs, &cvalues, &mk_dv), + try_pk_against_tables_oracle(&[stranger], &uvs, &cvalues, &mk_dv), + ); + assert_eq!( + try_pk_against_tables(&[stranger], &uvs, &cvalues, &mk_dv), + None + ); + + assert_eq!( + try_pk_against_tables(&[stranger, pk], &uvs, &cvalues, &mk_dv), + try_pk_against_tables_oracle(&[stranger, pk], &uvs, &cvalues, &mk_dv), + ); + + assert_eq!( + try_pk_against_tables(&[pk], &[], &[], &mk_dv), + try_pk_against_tables_oracle(&[pk], &[], &[], &mk_dv), + ); + } + + /// L104 "red first" observable: `KEY_EXPANSIONS` (crypto.rs, test-only) counts AES-128 + /// key schedules built through `new_cipher`/`new_cipher_for`. Before this fix the count + /// would read 0 here — `validate_processing_key`'s internal `Aes128::new` was never + /// routed through the counted constructor — so this exact assertion (`== pks tried`, + /// not 0 and not `pks × candidates`) only holds once the loop shares one schedule per + /// `pk` across every `(uv, cvalue)` candidate instead of rebuilding it per candidate. + #[test] + fn try_pk_against_tables_builds_one_key_schedule_per_pk_not_per_candidate() { + let (_dkey, mk, pk, cv, mk_dv) = four_level_parts(); + // Five decoy slots plus the keyed one: several candidates per PK, so a + // per-candidate rebuild (6) would disagree with a per-pk one (1). + let mut slots: Vec<(u8, u32)> = (0..5u32) + .map(|i| (U_MASK_SHIFT, 0x0000_1100u32 + (i << 8))) + .collect(); + slots.push((U_MASK_SHIFT, UV_SLOT4)); + let mut cvalues = Vec::new(); + for i in 0..5u8 { + cvalues.extend_from_slice(&[i; 16]); + } + cvalues.extend_from_slice(&cv); + let mkb = build_mkb(&slots, &cvalues, &mk_dv); + let uvs = mkb_find_subdiff_records(&mkb).expect("subdiff"); + + let stranger = [0xFFu8; 16]; // matches nothing: forces a full 6-candidate sweep + KEY_EXPANSIONS.with(|c| c.set(0)); + let got = try_pk_against_tables(&[stranger, pk], &uvs, &cvalues, &mk_dv); + assert_eq!(got, Some(mk), "sanity: the real pk still resolves"); + assert_eq!( + KEY_EXPANSIONS.with(|c| c.get()), + 2, + "one AES-128 schedule per pk tried (2), not one per (pk, candidate) pair (up to 12)" + ); + } + + /// Verbatim from `[C]` (AACS Introduction and Common Cryptographic Elements, Final Rev + /// 0.953) §3.2.4 "Calculation of Media Key", PDF p.14. Per spec; do not change this + /// relation without a spec citation proving otherwise. + const SPEC_MEDIA_KEY_FROM_CVALUE: &str = "Using that Processing Key K and the appropriate \ + 16 bytes of encrypted key data C, the device calculates the 128-bit Media Key Km as \ + follows: Km = AES-128D(K, C) \u{2295} (00000000000000000000000016 || uv)"; + + /// The hoisted cvalue loop must still satisfy the `[C]` §3.2.4 relation above, checked by + /// building the expected Km directly from the spec formula (inverted to plant `C`), + /// independent of any production code path. + #[test] + fn try_pk_against_tables_matches_the_c_dot_3_2_4_media_key_relation() { + let pk = [0x2Bu8; 16]; + const UV: u32 = 0x0000_0007; + let mk: [u8; 16] = [ + 0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80, 0x90, 0xA0, 0xB0, 0xC0, 0x00, 0x00, + 0x00, 0x00, + ]; + // Invert the spec relation: C = AES-128E(K, Km ⊕ (0^96 || uv)). + let mut pre = mk; + for (b, u) in pre[12..16].iter_mut().zip(UV.to_be_bytes()) { + *b ^= u; + } + let cv = aes_ecb_encrypt(&pk, &pre); + let mut vd = [0x22u8; 16]; + vd[..8].copy_from_slice(&VERIFY_MAGIC); + let mk_dv = aes_ecb_encrypt(&mk, &vd); + + let mut uvs = vec![0u8]; + uvs.extend_from_slice(&UV.to_be_bytes()); + + assert_eq!( + try_pk_against_tables(&[pk], &uvs, &cv, &mk_dv), + Some(mk), + "{SPEC_MEDIA_KEY_FROM_CVALUE}" + ); + } + // The 0xC0 revoked marker (`[C]` §3.2.5.1.5) TERMINATES the subset-difference // table; slots after it must not be walked. Fixture puts the keyed slot // AFTER the marker; removing it resolves the same MKB, proving the gate works. diff --git a/src/aacs/variant.rs b/src/aacs/variant.rs index feb9da00..adc1977e 100644 --- a/src/aacs/variant.rs +++ b/src/aacs/variant.rs @@ -312,12 +312,20 @@ fn variant_uv_slots(records: &[MkbRecord]) -> Option> { /// per-slot body stays a lean `(Kp, uv, slot)` call. struct VariantMkb<'a> { records: &'a [MkbRecord], - nonce: [u8; 16], vkd_table: &'a [u8], /// Per-slot C table from `0x0c` (16 bytes/slot), same index /// [`walk_processing_key`] uses. NOT `0x2d` (VARIANTS + Nonce). cvalues: &'a [u8], mk_dv: [u8; 16], + /// `Kvn = AES-G(Kp, Nonce) & 0xFFFF` (`[C]` §2.1.3 / this module's chain doc). Depends only + /// on `Kp` and the MKB's Nonce — both loop-invariant across every slot a given `Kp` is tried + /// against (L103) — so [`derive_media_key_variant`] computes it ONCE, not per slot. + /// + /// The closest published precedent for this `Kvn` shape is `[C]` §3.2.5.2.2 "Variant + /// Number Record" (Table 3-12), PDF p.29: "Kvn = [AES-G(Kp, Nonce)]lsb_10" (10-bit, for a + /// content-variation number, record type `0x0D`). This module's `0x2d`/`0x2f` chain is a + /// distinct, reverse-engineered AACS 2.1 scheme — same `AES-G(Kp, Nonce)` shape, 16-bit. + kvn: u16, } /// Derive+verify the Media Key for ONE known `(Kp, uv, slot)`. VID-free — @@ -364,14 +372,12 @@ fn variant_km_for_slot( kpnew[i] = kmp[i] ^ KEY_CORRECTION_DATA[i]; } - // Step: Kvn = AES-G(Kp, Nonce) & 0xFFFF (low 16 bits, BE). - let kvn_block = aes_g(kp, &m.nonce); - let kvn = u16::from_be_bytes([kvn_block[14], kvn_block[15]]); - - // Step: VKD_idx = Kvn XOR VARIANTS[uv]; VKD = vkd_table[VKD_idx]. + // Step: VKD_idx = Kvn XOR VARIANTS[uv]; VKD = vkd_table[VKD_idx]. Kvn = AES-G(Kp, Nonce) & + // 0xFFFF is loop-invariant for this Kp (L103): hoisted into `m.kvn` by the caller instead + // of being recomputed for each of the ~46k slots this function is tried against. let v_for_uv = variants_for_uv(m.records, slot_index) .ok_or(MediaKeyVariantError::VariantsTableUnavailable)?; - let vkd_idx = kvn ^ v_for_uv; + let vkd_idx = m.kvn ^ v_for_uv; let off = (vkd_idx as usize) * 16; if off + 16 > m.vkd_table.len() { return Err(MediaKeyVariantError::VkdIndexOutOfRange); @@ -419,12 +425,17 @@ pub fn derive_media_key_variant( .ok_or(MediaKeyVariantError::MkbIncomplete)?; let mk_dv = mkb_find_mk_dv(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?; let slots = variant_uv_slots(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?; + // Kvn = AES-G(Kp, Nonce) & 0xFFFF depends only on `pk` and the MKB's Nonce — neither + // varies per slot — so compute it ONCE here rather than once per slot in + // `variant_km_for_slot` (L103: was tens of ms/disc across ~46k slots). + let kvn_block = aes_g(pk, &nonce); + let kvn = u16::from_be_bytes([kvn_block[14], kvn_block[15]]); let m = VariantMkb { records: mkb_records, - nonce, vkd_table, cvalues, mk_dv, + kvn, }; // Try `pk` against each slot; return the first verified Km. If none verify, @@ -1183,6 +1194,182 @@ mod tests { } } + // ── L103: Kvn hoist — equivalence, call-count, and spec quote ───────────────────── + + /// Test-only ORACLE: `variant_km_for_slot` as it read before L103 — recomputes `Kvn = + /// AES-G(Kp, Nonce) & 0xFFFF` on every call instead of once per + /// [`derive_media_key_variant`] call. Takes `nonce` explicitly since the production + /// `VariantMkb` no longer carries it (only `kvn` does). Byte-identical body to + /// `variant_km_for_slot` otherwise; used only to prove the hoist agrees with it. + fn variant_km_for_slot_oracle( + m: &VariantMkb<'_>, + nonce: &[u8; 16], + kp: &[u8; 16], + uv: u32, + slot_index: usize, + ) -> Result<[u8; 16], MediaKeyVariantError> { + let cv_off = slot_index + .checked_mul(16) + .ok_or(MediaKeyVariantError::MkbIncomplete)?; + let c_slice = m + .cvalues + .get(cv_off..cv_off + 16) + .ok_or(MediaKeyVariantError::MkbIncomplete)?; + let mut c_block = [0u8; 16]; + c_block.copy_from_slice(c_slice); + + let mut kmp = aes_ecb_decrypt(kp, &c_block); + let uv_bytes = uv.to_be_bytes(); + for i in 0..4 { + kmp[12 + i] ^= uv_bytes[i]; + } + if kmp[15] & 0b0000_0010 != 0 { + return Err(MediaKeyVariantError::SoftCorrectionRequired); + } + if kmp[15] & 0b0000_0100 != 0 { + return Err(MediaKeyVariantError::OnlineChallengeRequired); + } + let mut kpnew = [0u8; 16]; + for i in 0..16 { + kpnew[i] = kmp[i] ^ KEY_CORRECTION_DATA[i]; + } + + // The pre-L103 line: recomputed on EVERY call, unlike the hoisted `m.kvn`. + let kvn_block = aes_g(kp, nonce); + let kvn = u16::from_be_bytes([kvn_block[14], kvn_block[15]]); + + let v_for_uv = variants_for_uv(m.records, slot_index) + .ok_or(MediaKeyVariantError::VariantsTableUnavailable)?; + let vkd_idx = kvn ^ v_for_uv; + let off = (vkd_idx as usize) * 16; + if off + 16 > m.vkd_table.len() { + return Err(MediaKeyVariantError::VkdIndexOutOfRange); + } + let mut vkd = [0u8; 16]; + vkd.copy_from_slice(&m.vkd_table[off..off + 16]); + + let mut km = aes_ecb_decrypt(&kpnew, &vkd); + for i in 0..4 { + km[12 + i] ^= uv_bytes[i]; + } + const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]; + if aes_ecb_decrypt(&km, &m.mk_dv)[..8] != VERIFY_MAGIC { + return Err(MediaKeyVariantError::MediaKeyVerifyFailed); + } + Ok(km) + } + + /// Build the `VariantMkb` + `nonce` the equivalence tests below drive both + /// `variant_km_for_slot` and its oracle through, from a planted fixture's records. + fn variant_mkb_from_records<'a>( + records: &'a [MkbRecord], + kp: &[u8; 16], + ) -> ([u8; 16], VariantMkb<'a>) { + let nonce = variant_nonce(records).expect("0x2d present"); + let vkd_table = variant_key_data(records).expect("0x2f present"); + let cvalues = mkb_find_body(records, REC_MEDIA_KEY_VARIANT_DATA) + .or_else(|| mkb_find_body(records, REC_MEDIA_KEY_DATA)) + .expect("cvalues present"); + let mk_dv = mkb_find_mk_dv(records).expect("0x86 present"); + let kvn_block = aes_g(kp, &nonce); + let kvn = u16::from_be_bytes([kvn_block[14], kvn_block[15]]); + ( + nonce, + VariantMkb { + records, + vkd_table, + cvalues, + mk_dv, + kvn, + }, + ) + } + + /// L103 equivalence: the hoisted-`kvn` `variant_km_for_slot` must return byte-identical + /// results to the pre-hoist oracle — the covering slot (single-slot fixture), a + /// one-bit-away Kp, and the two-slot fixture's decoy AND real slot. + #[test] + fn variant_km_for_slot_matches_the_pre_hoist_oracle() { + let p = plant_variant_mkb(); + let (nonce, m) = variant_mkb_from_records(&p.records, &p.kp); + assert_eq!( + variant_km_for_slot(&m, &p.kp, p.uv, 0), + variant_km_for_slot_oracle(&m, &nonce, &p.kp, p.uv, 0), + ); + assert_eq!(variant_km_for_slot(&m, &p.kp, p.uv, 0), Ok(p.km)); + + let mut stranger = p.kp; + stranger[0] ^= 0x01; + assert_eq!( + variant_km_for_slot(&m, &stranger, p.uv, 0), + variant_km_for_slot_oracle(&m, &nonce, &stranger, p.uv, 0), + ); + + let w = plant_walk_variant_mkb(); + let kp = aesg3(&w.dk.key, 1); + let (wnonce, wm) = variant_mkb_from_records(&w.records, &kp); + // Decoy slot 0 (must fail identically both ways). + assert_eq!( + variant_km_for_slot(&wm, &kp, 0x0800, 0), + variant_km_for_slot_oracle(&wm, &wnonce, &kp, 0x0800, 0), + ); + // Real slot 1 (must succeed identically both ways). + assert_eq!( + variant_km_for_slot(&wm, &kp, 0x0400, 1), + variant_km_for_slot_oracle(&wm, &wnonce, &kp, 0x0400, 1), + ); + assert_eq!(variant_km_for_slot(&wm, &kp, 0x0400, 1), Ok(w.km)); + } + + /// L103 "red first" observable: `AES_G_CALLS` (crypto.rs, test-only) counts calls to + /// `aes_g`. Before this fix, `derive_media_key_variant` on a 2-slot MKB (decoy then the + /// covering slot) called `aes_g` once PER SLOT tried — 2 here — because `Kvn` was + /// recomputed inside `variant_km_for_slot`. After the hoist it is computed once, before + /// the slot loop, regardless of how many slots are tried. + #[test] + fn derive_media_key_variant_computes_kvn_once_per_call_not_per_slot() { + use crate::aacs::crypto::AES_G_CALLS; + let w = plant_walk_variant_mkb(); + let kp = aesg3(&w.dk.key, 1); + AES_G_CALLS.with(|c| c.set(0)); + let got = derive_media_key_variant(&w.records, &kp); + assert_eq!( + got, + Ok(w.km), + "sanity: the covering kp still derives the planted Media Key" + ); + assert_eq!( + AES_G_CALLS.with(|c| c.get()), + 1, + "aes_g(kp, nonce) must run once per derive_media_key_variant call, not once per \ + slot tried (this MKB tries 2: the decoy, then the covering slot)" + ); + } + + /// Verbatim from `[C]` (AACS Introduction and Common Cryptographic Elements, Final Rev + /// 0.953) §2.1.3 "AES-based One-way Function (AES-G)", PDF p.8 — the SAME relation + /// `KS_17_AES_G` registers for `crypto::aes_g`. Per spec; do not change without a + /// citation proving otherwise. + #[test] + fn kvn_uses_the_registered_c_dot_2_1_3_aes_g_relation() { + use crate::spec::keys::KS_17_AES_G; + assert!( + KS_17_AES_G + .text + .contains("AES-G(x1, x2) = AES-128D(x1, x2)"), + "KS-17 must still state the AES-G relation this module's Kvn step relies on" + ); + // The relation itself, applied directly: Kvn's hoisted `aes_g(kp, nonce)` must equal + // AES-128D(kp, nonce) XOR nonce — not a constant, not `aes_ecb_decrypt` alone. + let kp = [0x5Bu8; 16]; + let nonce = [0x7Eu8; 16]; + let mut expected = aes_ecb_decrypt(&kp, &nonce); + for i in 0..16 { + expected[i] ^= nonce[i]; + } + assert_eq!(aes_g(&kp, &nonce), expected); + } + /// Sanity-check the fixture before anything is asserted through it: an MKB /// the record finders cannot read would make every "returns an error" body /// look correct. diff --git a/src/decrypt.rs b/src/decrypt.rs index 0029f8e5..fd36def4 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -141,19 +141,27 @@ impl DecryptKeys { /// half (parity of the unit's index within the segment) and the ALTERNATE half is /// left untouched (ciphertext) for the muxer to drop. Every non-forensic range — /// the base Unit Key, a multi-CPS unit — is `All` (decrypt every unit), so the -/// common disc is byte-for-byte unchanged. +/// common disc is byte-for-byte unchanged. `Verify` is a forensic index whose every +/// phase probe faulted (KU design §5.3): each unit is kept only if it verifies. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Phase { All, Even, Odd, + /// Phase unknown: decrypt each unit, keep it (CPI cleared) only if [`is_clean`] + /// passes, else restore its ciphertext for the muxer to drop. No public spec for + /// FMTS (KS-25, KS-26); per unit because each unit is its own CBC chain (KS-3). + /// + /// [`is_clean`]: crate::aacs::content::is_clean + Verify, } // Does this unit belong to the phase we hold the key for? `Phase::All` means the whole range is // ours; a wrong index gets the wrong half. fn unit_is_our_phase(unit_lba: u32, range_start: u32, unit_sectors: u32, phase: Phase) -> bool { let want_odd = match phase { - Phase::All => return true, + // `Verify` tries every unit; the per-unit verify decides what is ours. + Phase::All | Phase::Verify => return true, Phase::Even => false, Phase::Odd => true, }; @@ -301,7 +309,8 @@ impl AacsKeyMap { // A unit in NO range is pass-through content (base/default) — read // it. Only an alternate-phase forensic unit is dropped from the plan. let keep = match self.entry_for(lba) { - None | Some((_, Phase::All, _)) => true, + // A `Verify` range reads both halves: which one is ours is unknown. + None | Some((_, Phase::All | Phase::Verify, _)) => true, Some((_, phase, range_start)) => { let unit_ix = (lba - range_start) / us; let is_odd = unit_ix % 2 == 1; @@ -383,8 +392,8 @@ pub(crate) fn span_in_content_ranges(lba: u32, count: u32, ranges: &[(u32, u32)] } } -// AACS scheme step: apply `map`'s per-unit keys to `buf`, in-place — no key trial, no -// `is_clean` verdict; the refusal decision belongs to `decrypt_span`. +// AACS scheme step: apply `map`'s per-unit keys to `buf`, in-place — no key trial; the one +// `is_clean` verdict is `Phase::Verify`'s keep-or-restore. Refusal belongs to `decrypt_span`. fn apply_aacs_map( buf: &mut [u8], keys: &DecryptKeys, @@ -468,11 +477,27 @@ fn apply_aacs_map( // removed by the drive's single de-bus point before this buffer arrived — // this path only applies the CPS unit key. let key = &unit_keys[key_idx].1; + if phase == Phase::Verify { + // KU design §5.3 (no public FMTS spec, KS-25/KS-26): keep a unit only if it + // verifies; KS-3 [BD] §3.10.1 "A new CBC cipher chain is started for each Aligned Unit". + let mut ciphertext = [0u8; aacs::content::ALIGNED_UNIT_LEN]; + ciphertext.copy_from_slice(chunk); + aacs::content::decrypt_unit(chunk, key); + if aacs::content::is_clean(chunk, format) { + aacs::content::clear_copy_permission_indicator(chunk, format); + } else { + chunk.copy_from_slice(&ciphertext); // flagged ciphertext: the muxer drops it + } + return; + } aacs::content::decrypt_unit(chunk, key); // Correct-phase forensic verify (silent unless the map is wrong). if matches!(phase, Phase::Even | Phase::Odd) && !aacs::content::is_clean(chunk, format) { verify_failed.store(true, std::sync::atomic::Ordering::Relaxed); } + // KS-5 [BD] §3.10.2: CPI "shall be set to 00₂ if the data is not encrypted": a unit + // we decrypted (damaged or not) is no longer ciphertext (KU design §5.4, K-13). + aacs::content::clear_copy_permission_indicator(chunk, format); }; let nthreads = decrypt_threads(); @@ -1177,7 +1202,8 @@ mod tests { decrypt_sectors_mapped(&mut buf, &keys, 0, &map) .expect("unit-key decrypt over de-bussed content must succeed"); assert_eq!( - buf, clear, + buf, + aacs::content::cpi_cleared(clear), "bus removal (at read) then unit-key decrypt must recover the plaintext exactly" ); } @@ -1601,7 +1627,8 @@ mod tests { decrypt_sectors_mapped_in_content(&mut inside, &keys, 0, &map, Some(&[(0, 3)])) .expect("an in-content unit decrypts"); assert_eq!( - inside, clear, + inside, + aacs::content::cpi_cleared(clear), "unit inside content ranges must decrypt to plaintext" ); } @@ -1644,7 +1671,7 @@ mod tests { for i in 0..n { assert_eq!( &buf[i * ul..(i + 1) * ul], - clear_of(i).as_slice(), + aacs::content::cpi_cleared(clear_of(i)).as_slice(), "unit {i} must recover to its own plaintext on the parallel path" ); } @@ -1857,9 +1884,10 @@ mod tests { // Units at LBA 300, 303, 306: content is 300..303 and 306..309. decrypt_sectors_mapped_in_content(&mut buf, &keys, 300, &map, Some(&[(300, 3), (306, 3)])) .expect("mixed buffer decrypts"); - assert_eq!(&buf[..ul], clear.as_slice(), "unit 0 is content"); + let plain = aacs::content::cpi_cleared(clear); + assert_eq!(&buf[..ul], plain.as_slice(), "unit 0 is content"); assert_eq!(&buf[ul..2 * ul], enc.as_slice(), "unit 1 sits in the gap"); - assert_eq!(&buf[2 * ul..], clear.as_slice(), "unit 2 is content"); + assert_eq!(&buf[2 * ul..], plain.as_slice(), "unit 2 is content"); } } @@ -1868,7 +1896,7 @@ mod spec_guards { use super::*; use crate::aacs::content::{ALIGNED_UNIT_LEN, encrypt_unit}; use crate::disc::ContentFormat; - use crate::spec::keys::KS_5_CPI; + use crate::spec::keys::{KS_5_CPI, KS_6_TP_EXTRA_HEADER, KS_22_LIBAACS_VERIFY_TS}; const OURS: [u8; 16] = [0xA1; 16]; const ALT: [u8; 16] = [0xB2; 16]; @@ -1892,8 +1920,8 @@ mod spec_guards { /// per spec; do not change without a spec citation — KS-5 [BD] §3.10.2: "shall be set /// to 11₂ if the data is encrypted": a unit left as ciphertext keeps its CPI bits. - /// Covers the alternate FMTS phase and an orphan; the failed `Phase::Verify` leg is - /// added with `Phase::Verify` (KU-L1). + /// Covers the alternate FMTS phase, an orphan, and a `Phase::Verify` unit that fails + /// `is_clean` and is restored. #[test] fn ciphertext_units_keep_cpi() { assert!( @@ -1936,5 +1964,79 @@ mod spec_guards { ); assert_eq!(orphan, before, "the orphan keeps every byte, CPI included"); assert_eq!(orphan[0] & 0xC0, 0xC0); + // A `Verify` unit our key does not open: restored, every byte and CPI kept. + let mut failed = encrypted(&ALT, 4); + let before = failed.clone(); + let map = AacsKeyMap::from_ranges_phased(vec![(0, 3, 0, Phase::Verify)]); + decrypt_sectors_mapped(&mut failed, &keys, 0, &map).expect("a failed verify is no error"); + assert_eq!( + failed, before, + "the failed Verify unit is restored as ciphertext" + ); + assert_eq!(cpi(&failed), cpi(&before), "CPI of all 32 packets"); + assert_eq!(failed[0] & 0xC0, 0xC0, "packet 0's clear CPI still 11₂"); + } + + /// per spec; do not change without a spec citation — KS-5 [BD] §3.10.2: "shall be set + /// to 11₂ if the data is encrypted, or … 00₂ if the data is not encrypted"; KS-6 (CPI is + /// the top 2 bits of TP_extra_header); corroborated by KS-22 libaacs `buf[i] &= ~0xc0`. + #[test] + fn every_decrypted_unit_clears_cpi_in_all_32_packets() { + assert!(KS_5_CPI.text.contains("00₂ if the data is not encrypted")); + assert!( + KS_6_TP_EXTRA_HEADER + .text + .contains("Copy_permission_indicator 2 uimsbf Arrival_time_stamp 30 uimsbf") + ); + assert!(KS_22_LIBAACS_VERIFY_TS.text.contains("buf[i] &= ~0xc0;")); + let keys = DecryptKeys::Aacs { + unit_keys: vec![(0, OURS)], + format: ContentFormat::BdTs, + }; + let check = |label: &str, got: &[u8], plain: Vec| { + assert!( + cpi(got).iter().all(|&c| c == 0), + "{label}: CPI of all 32 packets" + ); + // Byte 0 of each packet keeps its 6 Arrival_time_stamp bits (KS-6). + let want = crate::aacs::content::cpi_cleared(plain); + assert_eq!(got, &want[..], "{label}: ATS and payload untouched"); + }; + + // A map-keyed unit (`Phase::All`, the base Unit Key). + let mut unit = encrypted(&OURS, 7); + let mut plain = unit.clone(); + crate::aacs::content::decrypt_unit(&mut plain, &OURS); + let map = AacsKeyMap::from_ranges(vec![(0, 3, 0)]); + decrypt_sectors_mapped(&mut unit, &keys, 0, &map).expect("map-keyed unit decrypts"); + check("map-keyed", &unit, plain); + + // A DAMAGED map-keyed unit: media garbage, still decrypted, so no longer ciphertext. + let mut damaged: Vec = (0..ALIGNED_UNIT_LEN) + .map(|i| (i as u8).wrapping_mul(37) ^ 0x5A) + .collect(); + damaged[0] |= 0xC0; + damaged[4] = 0x47; // the clear seed's sync: read on the unit grid + let mut plain = damaged.clone(); + crate::aacs::content::decrypt_unit(&mut plain, &OURS); + decrypt_sectors_mapped(&mut damaged, &keys, 0, &map).expect("no verify on Phase::All"); + check("damaged map-keyed", &damaged, plain); + + // Our half of a forensic segment (`Phase::Even`, unit 0 of the range). + let mut ours = encrypted(&OURS, 9); + let mut plain = ours.clone(); + crate::aacs::content::decrypt_unit(&mut plain, &OURS); + let map = AacsKeyMap::from_ranges_phased(vec![(0, 3, 0, Phase::Even)]); + decrypt_sectors_mapped(&mut ours, &keys, 0, &map).expect("our phase decrypts"); + check("our forensic phase", &ours, plain); + + // A kept `Phase::Verify` unit (it verified under our key). The on-arrival leg + // joins with the on-arrival proof (KU-L2). + let mut kept = encrypted(&OURS, 11); + let mut plain = kept.clone(); + crate::aacs::content::decrypt_unit(&mut plain, &OURS); + let map = AacsKeyMap::from_ranges_phased(vec![(0, 3, 0, Phase::Verify)]); + decrypt_sectors_mapped(&mut kept, &keys, 0, &map).expect("a verified unit decrypts"); + check("kept Verify", &kept, plain); } } diff --git a/src/dirimage/tests.rs b/src/dirimage/tests.rs index 621cb66a..b0a6cf2c 100644 --- a/src/dirimage/tests.rs +++ b/src/dirimage/tests.rs @@ -667,6 +667,8 @@ fn an_aacs_folder_with_scrambled_content_is_rejected() { /// not a bare is_ok(), so it cannot go vacuous. #[test] fn both_doors_agree_on_a_clear_folder_that_kept_its_aacs_directory() { + // Spawns the prefetch producer, a Drive holder. + let _serial = crate::sector::prefetched::holder_test_lock(); // `s` MUST outlive `stream`: the pipeline's producer thread is still // reading the folder until the stream is dropped, and `Scratch::drop` // removes the directory out from under it. diff --git a/src/disc/encrypt.rs b/src/disc/encrypt.rs index 45040f70..927bac2f 100644 --- a/src/disc/encrypt.rs +++ b/src/disc/encrypt.rs @@ -387,12 +387,12 @@ impl AacsCertUnlocker<'_> { return Err(CertFail::Other(CertUnlockFailure::NoHostCert)); } - // Borrow checker can't split `session` across `scsi_mut()` + `&drive_id`. + // `run_bus` borrows the whole Drive, so hand it a copy of the identity. let drive_id = session.drive_id.clone(); let fu_certs = crate::unlock_bridge::map_host_certs(&host_certs); // `matched` is only ever "AACS"/"DVD"/"" on this disc-keyed route, never a drive unlock. let (_matched, unlock_res) = crate::unlock_bridge::run_bus( - session.scsi_mut(), + session, &drive_id, freemkv_unlock::DiscKind::Aacs, &fu_certs, @@ -444,17 +444,28 @@ fn cert_unlock_outcome(e: CertUnlockFailure) -> crate::aacs::trace::UnlockOutcom } } +// The scan's one bus step (§2.3), shared by AACS and CSS: the op token is checked before +// and after, ahead of any result mapping; the Drive's `Progress` is busy throughout, as +// it spans the first keydb parse in `host_certs` (ST4-2). +pub(super) fn bus_step_guard( + session: &mut crate::drive::Drive, + step: impl FnOnce(&mut crate::drive::Drive) -> T, +) -> Result { + let _busy = session.progress().map(crate::halt::Progress::busy); + session.check_token()?; + let r = step(session); + session.check_token()?; + Ok(r) +} + // The AACS bus step of a live scan, run after the AACS files are captured. A dead bus // aborts like `Drive::init`, and a Stop during the AKE is `Halted`. pub(super) fn aacs_bus_step( session: &mut crate::drive::Drive, opts: &ScanOptions, ) -> Result { - if session.is_halted() { - return Err(Error::Halted); - } let t0 = std::time::Instant::now(); - let r = Disc::do_handshake_cert(session, opts); + let r = bus_step_guard(session, |s| Disc::do_handshake_cert(s, opts))?; tracing::info!( target: "freemkv::scan", phase = "do_handshake", @@ -462,9 +473,6 @@ pub(super) fn aacs_bus_step( elapsed_ms = t0.elapsed().as_millis() as u64, "end" ); - if session.is_halted() { - return Err(Error::Halted); - } match r { Ok(CertRoute::Handshake(h)) => Ok(BusOutcome::Handshake(h)), Ok(CertRoute::FirmwareUnlocked) => Ok(BusOutcome::FirmwareUnlocked), @@ -1151,7 +1159,15 @@ mod tests { // Capture a synthetic AACS disc and `finish` it with the given bus outcome. fn finish_disc(disc: &mut MemDisc, udf: udf::UdfFs, bus: BusOutcome) -> Disc { let cap = capture(disc, &udf, from_for(&bus)).expect("capture"); - Disc::finish(disc, 10_000, udf, Some((cap, bus)), &ScanOptions::default()).expect("scan") + Disc::finish( + disc, + 10_000, + udf, + Some((cap, bus)), + &ScanOptions::default(), + None, + ) + .expect("scan") } // Scan a synthetic AACS disc with the given handshake outcome. diff --git a/src/disc/extract.rs b/src/disc/extract.rs index 8dca91c8..3e290266 100644 --- a/src/disc/extract.rs +++ b/src/disc/extract.rs @@ -1885,6 +1885,8 @@ mod tests { // Expected plaintext after a correct per-extent decrypt. let mut expect = clear_aacs_unit(0xA1); expect.extend_from_slice(&clear_aacs_unit(0xB2)); + // KS-5: a decrypted unit's CPI reads 00₂ (KU design §5.4). + let expect = crate::aacs::content::cpi_cleared(expect); // Lay the disc by hand: root dir with one BDMV/STREAM/00001.m2ts whose // ICB carries two Short ADs. diff --git a/src/disc/mod.rs b/src/disc/mod.rs index f91ffdc4..6ab2d0be 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -1866,7 +1866,7 @@ impl Disc { let batch = detect_max_batch_sectors(session.device_path()); let mut buffered = udf::BufferedSectorReader::new(session, batch); let udf_fs = udf::read_filesystem(&mut buffered)?; - buffered.prefetch(udf_fs.metadata_start(), udf_fs.metadata_sectors()); + buffered.prefetch(udf_fs.metadata_start(), udf_fs.metadata_sectors())?; Ok((buffered, udf_fs)) } @@ -1897,9 +1897,7 @@ impl Disc { /// into [`Error::EmptyImage`] via `image_read_sectors`. A Stop ([`Error::Halted`], /// including one during a backoff sleep) is returned, never retried. fn read_capacity_retrying(session: &mut Drive) -> Result { - Self::read_capacity_retrying_with(session, |d, t| { - crate::drive::sleep_until_halted(&d.halt_flag(), t) - }) + Self::read_capacity_retrying_with(session, |d, t| d.pause(t)) } // `sleep` is the backoff wait (injectable for tests); it returns Halted on a Stop. @@ -1951,15 +1949,23 @@ impl Disc { } /// Scan a disc — parse filesystem, playlists, streams, and capture AACS - /// inputs. The main entry point; the session must be open. One order, the - /// standard one: (1) DVD only: CSS bus-auth, (2) read capacity + UDF, (3) read - /// the AACS files (`Unit_Key_RO.inf`, content cert, MKB) with plain READs, - /// (4) the AACS handshake when the disc has AACS, (5) titles + labels. + /// inputs; the session must be open. Order: (1) DVD only: CSS bus-auth, (2) read + /// capacity + UDF, (3) the AACS files (`Unit_Key_RO.inf`, content cert, MKB) with + /// plain READs, (4) the AACS handshake if AACS, (5) titles + labels. /// /// A live AACS disc whose `Unit_Key_RO.inf` cannot be read fails with /// [`Error::AacsKeyFileUnreadable`] before any AACS command (see /// [`ScanOptions::raw_copy`]). A dead bus during a handshake aborts the scan. + /// `opts.halt` is the scan's op token unless the drive has one attached (§2.2). pub fn scan(session: &mut Drive, opts: &ScanOptions) -> Result { + let mut session = session.alias(opts.halt.as_ref()); + let disc = Self::scan_live(&mut session, opts)?; + // LS6: a Stop after the last CDB still ends the scan; no `Disc` is returned. + session.check_token()?; + Ok(disc) + } + + fn scan_live(session: &mut Drive, opts: &ScanOptions) -> Result { let dvd = session.disc_is_dvd(); // Max read speed; removes riplock on DVD. session.set_speed(0xFFFF); @@ -1973,7 +1979,7 @@ impl Disc { tracing::info!(target: "freemkv::scan", capacity, "phase: UDF read"); // Pre-read small files (AACS, MPLS, CLPI, META, *.bdmv): one command each otherwise. if let Ok(ranges) = udf_fs.metadata_sector_ranges(&mut buffered) { - buffered.prefetch_ranges(&ranges); + buffered.prefetch_ranges(&ranges)?; } let aacs = if aacs_dir_present(&udf_fs) { @@ -2010,11 +2016,13 @@ impl Disc { opts: &ScanOptions, ) -> Result { let bus_key = aacs.as_ref().and_then(|(c, b)| encrypt::bus_key(c, b)); - let rereads = FeRereads::new(opts.halt.clone()); + // The op token: the drive's attached token (the `opts.halt` alias or the caller's). + let op = buffered.inner_mut().token().cloned(); + let rereads = FeRereads::new(op.clone()); let streams = Self::bus_stream_files(&mut buffered, &udf_fs, bus_key.is_some(), rereads)?; tracing::info!(target: "freemkv::scan", "phase: parsing titles/streams"); - let disc = Self::finish(&mut buffered, capacity, udf_fs, aacs, opts)?; + let disc = Self::finish(&mut buffered, capacity, udf_fs, aacs, opts, op.as_ref())?; tracing::info!(target: "freemkv::scan", titles = disc.titles.len(), format = ?disc.content_format, "phase: titles parsed"); // The SINGLE de-bus point, wired before the caller samples keys or muxes (the @@ -2031,19 +2039,10 @@ impl Disc { // CSS bus-auth for a live DVD. A dead bus aborts like `Drive::init`; a Stop is `Halted`. fn css_bus_step(session: &mut Drive) -> Result<()> { tracing::info!(target: "freemkv::scan", "phase: CSS — bus-auth unlock (pre-scan)"); - if session.is_halted() { - return Err(Error::Halted); - } let drive_id = session.drive_id.clone(); - let (_, res) = crate::unlock_bridge::run_bus( - session.scsi_mut(), - &drive_id, - freemkv_unlock::DiscKind::Css, - &[], - ); - if session.is_halted() { - return Err(Error::Halted); - } + let (_, res) = encrypt::bus_step_guard(session, |s| { + crate::unlock_bridge::run_bus(s, &drive_id, freemkv_unlock::DiscKind::Css, &[]) + })?; match res { Ok(Some(_)) => {} Ok(None) => tracing::warn!( @@ -2462,7 +2461,7 @@ impl Disc { } else { None }; - Self::finish(reader, capacity, udf_fs, aacs, opts) + Self::finish(reader, capacity, udf_fs, aacs, opts, opts.halt.as_ref()) } // Everything after the AACS bus step, for live and image scans alike: the AACS @@ -2473,6 +2472,7 @@ impl Disc { udf_fs: udf::UdfFs, aacs: Option<(encrypt::AacsCapture, encrypt::BusOutcome)>, opts: &ScanOptions, + halt: Option<&crate::halt::Halt>, ) -> Result { let scan_with_t0 = std::time::Instant::now(); tracing::info!(target: "freemkv::scan", phase = "scan_with", "begin"); @@ -2490,16 +2490,16 @@ impl Disc { let mut dvd_nav_feature: Option = None; let (mut titles, content_format) = if udf_fs.find_dir("/BDMV").is_some() { ( - Self::scan_bluray_titles(reader, &udf_fs, opts.halt.as_ref())?, + Self::scan_bluray_titles(reader, &udf_fs, halt)?, ContentFormat::BdTs, ) } else if udf_fs.find_dir("/HVDVD_TS").is_some() { ( - Self::scan_hddvd_titles(reader, &udf_fs, opts.halt.as_ref())?, + Self::scan_hddvd_titles(reader, &udf_fs, halt)?, ContentFormat::MpegPs, ) } else if udf_fs.find_dir("/VIDEO_TS").is_some() { - let (dvd_titles, nav) = Self::scan_dvd_titles(reader, &udf_fs, opts.halt.as_ref())?; + let (dvd_titles, nav) = Self::scan_dvd_titles(reader, &udf_fs, halt)?; dvd_nav_feature = nav; (dvd_titles, ContentFormat::MpegPs) } else { @@ -2560,7 +2560,7 @@ impl Disc { // forced flags match the muxer's rip-time result (shared PGS classifier); // rip leaves it off since the muxer detects forced without a second read. if opts.probe_forced_subtitles { - Self::probe_forced_subtitles_for_bdts_titles(reader, &mut titles, opts.halt.as_ref()); + Self::probe_forced_subtitles_for_bdts_titles(reader, &mut titles, halt); } crate::labels::fill_defaults(&mut titles); @@ -2924,7 +2924,7 @@ impl Disc { 0x00, ]; let mut buf = [0u8; 8]; - let result = session.checked_exec( + let result = session.exec( &cdb, crate::scsi::DataDirection::FromDevice, &mut buf, @@ -3260,6 +3260,23 @@ impl Disc { merged_extents(self.titles.iter().flat_map(|t| &t.extents)) } + /// `n_decl` (KU design §2.3 step 3): the CPS units the disc's title-key file + /// DECLARES, parsed by [`crate::aacs::inf::parse_title_keys`] (BD/UHD + /// `Unit_Key_RO.inf` or HD DVD VTKF). `None` when absent or unparseable (fails closed). + pub(crate) fn declared_cps_units(&self) -> Option { + use crate::aacs::mkb::AacsVersion; + let aacs = self.aacs.as_ref()?; + let version = if aacs.version >= 2 { + AacsVersion::V20 + } else { + AacsVersion::V10 + }; + // KS-14 [BD] §3.9.3: "Num_of_CPS_Unit field (16 bits) indicates the number of CPS + // Units on the disc" — the declared count, never the number of keys held. + let ukf = crate::aacs::inf::parse_title_keys(&aacs.uk_ro, version)?; + Some(ukf.encrypted_keys.len()) + } + // The 40-hex AACS disc id (SHA1 of Unit_Key_RO.inf, no 0x prefix), or // empty when uncaptured. Names the disc in an Error::NoDiscKey. pub(crate) fn aacs_disc_hash(&self) -> String { @@ -6338,7 +6355,7 @@ mod tests { }); let res = Disc::read_capacity_retrying_with(&mut drive, |d, t| { d.halt(); - crate::drive::sleep_until_halted(&d.halt_flag(), t) + d.pause(t) }); assert!(matches!(res, Err(Error::Halted)), "got {res:?}"); assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 1); diff --git a/src/disc/scan_order_tests.rs b/src/disc/scan_order_tests.rs index a3cf41e1..ca433b8d 100644 --- a/src/disc/scan_order_tests.rs +++ b/src/disc/scan_order_tests.rs @@ -800,3 +800,221 @@ fn image_unreadable_uk_ro_is_recorded_not_fatal() { d.decrypt_with(Key::Unit(vec![(0, [0x11; 16])]), &[]) .expect("a mapfile unit key is accepted on an image"); } + +// ── Stop design §5.1 "Scan" (LS1–LS6), over `test_util::FakeTransport` ── + +fn ake(mem: MemDisc, profile: u16) -> AkeMemTransport { + AkeMemTransport { + mem, + log: Arc::default(), + profile, + fail_read: None, + fault_on: None, + halt_on: None, + halt: Arc::default(), + mkb_pack: Some(mkb_gen(70)), + } +} + +fn with_halt(h: &crate::halt::Halt) -> ScanOptions { + ScanOptions { + halt: Some(h.clone()), + ..with_hc() + } +} + +// The `n`th (1-based) CDB matching `p`. +fn nth(n: usize, p: Pred) -> impl Fn(&[u8]) -> bool + Send + 'static { + let seen = std::sync::atomic::AtomicUsize::new(0); + move |c| p(c) && seen.fetch_add(1, Ordering::Relaxed) + 1 == n +} + +/// LS1 (D5): a Stop during a recovery-timeout UDF metadata READ waits for that CDB, +/// issues nothing after it, and ends the scan `Halted`. +#[test] +fn stop_during_udf_metadata_read_waits_for_inflight_cdb() { + use crate::test_util::{FakeMode, FakeTransport}; + let h = crate::halt::Halt::new(); + let (t, fake) = FakeTransport::new(); + let t = t + .with_inner(Box::new(ake(bd_disc(Some(true)), 0x0040))) + .rule_n(nth(1, is_read10), FakeMode::Stall, 1) + .scale(100) + .watch(&h); + let mut drive = Drive::from_transport(Box::new(t)); + let (f2, h2) = (fake.clone(), h.clone()); + let stopper = std::thread::spawn(move || { + assert!(f2.wait_for(1, is_read10, std::time::Duration::from_secs(5))); + h2.cancel(); + std::thread::sleep(std::time::Duration::from_millis(20)); + f2.release(); + }); + let r = Disc::scan(&mut drive, &with_halt(&h)); + stopper.join().unwrap(); + assert!( + matches!(r, Err(Error::Halted)), + "{:?}", + r.map(|d| d.titles.len()) + ); + let log = fake.log(); + let at = log.iter().position(|c| is_read10(&c.cdb)).unwrap(); + assert_eq!( + log[at].timeout_ms, + crate::scsi::READ_RECOVERY_TIMEOUT_MS, + "D5: recovery READ" + ); + assert_eq!( + at + 1, + log.len(), + "zero CDBs after the in-flight READ: {:02x?}", + fake.cdbs() + ); +} + +/// LS2 / G1 (GUARD, D5): a metadata READ slower than the fast 10 s timeout but inside +/// the 60 s recovery one (30 s, scaled) still scans. +#[test] +fn scan_survives_metadata_read_slower_than_fast_timeout() { + use crate::test_util::{FakeMode, FakeTransport}; + let (t, fake) = FakeTransport::new(); + // Scale 1000: fast READ times out at 10 ms, recovery at 60 ms; this one takes 30. + let t = t.with_inner(Box::new(ake(bd_disc(None), 0x0040))).rule_n( + nth(1, is_read10), + FakeMode::Complete(std::time::Duration::from_millis(30)), + 1, + ); + let mut drive = Drive::from_transport(Box::new(t)); + let d = Disc::scan(&mut drive, &with_hc()).expect("a slow recovery READ is not a failure"); + assert!(!d.titles.is_empty()); + assert!(fake.count(is_read10) > 1); +} + +/// LS4: AACS and CSS bus steps both observe the op token (the `ScanOptions.halt` +/// alias, not the drive's own) through `bus_step_guard`. +#[test] +fn bus_step_stop_and_transport_share_bus_step_guard() { + use crate::test_util::FakeTransport; + for (mem, profile, alloc) in [ + (bd_disc(Some(true)), 0x0040, is_aacs_agid_alloc as Pred), + (dvd_disc(), 0x0010, is_css_agid_alloc as Pred), + ] { + let h = crate::halt::Halt::new(); + let (t, fake) = FakeTransport::new(); + let t = t + .with_inner(Box::new(ake(mem, profile))) + .cancel_on(nth(1, alloc), &h) + .watch(&h); + let mut drive = Drive::from_transport(Box::new(t)); + let r = Disc::scan(&mut drive, &with_halt(&h)); + assert!( + matches!(r, Err(Error::Halted)), + "{profile:#x}: {:?}", + r.map(|d| d.titles.len()) + ); + assert!( + !drive.is_halted(), + "the drive's own token was never cancelled" + ); + // After the Stop only the §2.4 clean-up: the allocated AGID's one release. + let cdbs = fake.cdbs(); + let at = cdbs.iter().position(|c| alloc(c)).unwrap(); + let after = &cdbs[at + 1..]; + let release = |c: &Vec| crate::drive::allow::invalidated_agid(c).is_some(); + assert!( + after.len() <= 1 && after.iter().all(release), + "{profile:#x}: {after:02x?}" + ); + } + let enc = include_str!("encrypt.rs"); + let body = |src: &'static str, f: &str| { + let i = src.find(f).unwrap(); + &src[i..i + src[i..] + .find("\n}\n") + .or_else(|| src[i..].find("\n }\n")) + .unwrap()] + }; + assert!(body(enc, "pub(super) fn aacs_bus_step(").contains("bus_step_guard(")); + assert!(body(include_str!("mod.rs"), "fn css_bus_step(").contains("bus_step_guard(")); +} + +/// LS5: a Stop during the metadata prefetch ends `prefetch_ranges` with `Halted`; no +/// READ follows, and no title parse reads on from the cache. +#[test] +fn stop_between_prefetch_ranges_and_title_parses() { + use crate::test_util::FakeTransport; + let mpls = PART_START + 700; + let h = crate::halt::Halt::new(); + let (t, fake) = FakeTransport::new(); + let t = t + .with_inner(Box::new(ake(bd_disc(None), 0x0040))) + .cancel_on(move |c| touches(c, &[mpls]), &h) + .watch(&h); + let mut drive = Drive::from_transport(Box::new(t)); + let r = Disc::scan(&mut drive, &with_halt(&h)); + assert!( + matches!(r, Err(Error::Halted)), + "{:?}", + r.map(|d| d.titles.len()) + ); + let cdbs = fake.cdbs(); + let at = cdbs.iter().position(|c| touches(c, &[mpls])).unwrap(); + assert_eq!(at + 1, cdbs.len(), "no READ after the Stop: {cdbs:02x?}"); +} + +/// LS6: a Stop after the scan's last CDB, before it returns, still ends it `Halted` +/// with no `Disc`. +#[test] +fn scan_with_final_check() { + use crate::test_util::FakeTransport; + let (t, fake) = FakeTransport::new(); + let mut drive = + Drive::from_transport(Box::new(t.with_inner(Box::new(ake(bd_disc(None), 0x0040))))); + Disc::scan(&mut drive, &with_hc()).expect("dry run scans"); + let last = fake.log().len(); + let h = crate::halt::Halt::new(); + let (t, fake) = FakeTransport::new(); + let t = t + .with_inner(Box::new(ake(bd_disc(None), 0x0040))) + .cancel_on(nth(last, |_| true), &h); + let mut drive = Drive::from_transport(Box::new(t)); + let r = Disc::scan(&mut drive, &with_halt(&h)); + assert!( + matches!(r, Err(Error::Halted)), + "{:?}", + r.map(|d| d.titles.len()) + ); + assert_eq!(fake.log().len(), last); +} + +// A key source that records whether the drive's Progress was busy while it ran. +struct BusyProbe(crate::halt::Progress, Arc>>); +impl crate::KeySource for BusyProbe { + fn get_unit_keys( + &self, + _ctx: &dyn crate::keysource::ResolveCtx, + ) -> Result> { + Ok(Vec::new()) + } + fn host_certs(&self, _mkb: Option) -> Vec { + self.1.lock().unwrap().push(self.0.is_busy()); + vec![test_hc()] + } +} + +/// ST4-2: `bus_step_guard` holds `busy()` on the Drive-attached `Progress` for the +/// whole bus step, so the first keydb parse (in `host_certs`) is never idle time. +#[test] +fn bus_step_guard_holds_busy_across_host_certs() { + let p = crate::halt::Progress::new(); + let seen: Arc>> = Arc::default(); + let mut rig = Rig::new(bd_disc(Some(true)), |_| {}); + rig.drive.attach_progress(&p); + let opts = ScanOptions { + key_sources: vec![Box::new(BusyProbe(p.clone(), seen.clone()))], + ..Default::default() + }; + let _ = Disc::scan(&mut rig.drive, &opts); + let seen = seen.lock().unwrap().clone(); + assert!(!seen.is_empty() && seen.iter().all(|b| *b), "{seen:?}"); + assert!(!p.is_busy(), "released after the step"); +} diff --git a/src/drive/allow.rs b/src/drive/allow.rs new file mode 100644 index 00000000..ff0a7ce6 --- /dev/null +++ b/src/drive/allow.rs @@ -0,0 +1,136 @@ +//! The post-cancel allow-list (stop design §2.4): once a Drive's token is cancelled, +//! only these clean-up CDBs may still reach the drive. Shared by +//! [`Drive::exec_cleanup`](super::Drive) and the test `FakeTransport`, so the two can +//! never disagree about what is legal after a Stop. + +/// PREVENT ALLOW MEDIUM REMOVAL. +pub(crate) const PREVENT_ALLOW: u8 = 0x1E; +/// START STOP UNIT. +pub(crate) const START_STOP_UNIT: u8 = 0x1B; +/// REPORT KEY. +pub(crate) const REPORT_KEY: u8 = 0xA4; + +/// REPORT KEY key format 3Fh: invalidate the AGID in CDB byte 10 bits 7-6. +pub(crate) const KEY_FORMAT_INVALIDATE_AGID: u8 = 0x3F; + +/// What the Drive holds that a clean-up CDB may release (§2.2 "Ledger"). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct Ledger { + /// This Drive sent PREVENT and has not sent ALLOW since. + pub tray_locked: bool, + /// Bit `n` set: AGID `n` was allocated through this Drive and not yet invalidated. + pub agids: u8, +} + +/// Where a clean-up CDB is issued from; each widens the list by one §2.4 row. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CleanupCtx { + /// `Drop`, `unlock_tray`, an unlocker's `execute_cleanup`: the ledger rows only. + Plain, + /// Inside `DiscSession::finish(Finish::Eject)`: also START STOP UNIT with LoEj=1. + FinishEject, + /// Inside a critical section entered before the cancel: any CDB. + Critical, +} + +/// The AGID a REPORT KEY CDB names (byte 10 bits 7-6), if it is a key-format-3Fh +/// invalidate. +pub(crate) fn invalidated_agid(cdb: &[u8]) -> Option { + let b10 = *cdb.get(10)?; + (cdb.first() == Some(&REPORT_KEY) && b10 & 0x3F == KEY_FORMAT_INVALIDATE_AGID) + .then_some(b10 >> 6) +} + +/// Whether `cdb` is PREVENT ALLOW with the Prevent bit clear (an ALLOW). +fn is_allow(cdb: &[u8]) -> bool { + // SS-5 MMC-6 Table 329: Persistent 0, Prevent 0 = "Prevent State shall be cleared + // (Unlocked)"; byte 4 bit 0 is Prevent. + cdb.first() == Some(&PREVENT_ALLOW) && cdb.get(4).is_some_and(|b| b & 0x01 == 0) +} + +/// Whether `cdb` is START STOP UNIT with LoEj=1 and Start=0 (an eject). +fn is_eject(cdb: &[u8]) -> bool { + // SS-6 MMC-6 Table 633: LoEj 1, Start 0 = "Eject the disc if permitted"; byte 4 + // bit 1 is LoEj, bit 0 is Start. + cdb.first() == Some(&START_STOP_UNIT) && cdb.get(4).is_some_and(|b| b & 0x03 == 0x02) +} + +/// §2.4: whether `cdb` may still be issued after the token is cancelled. +pub(crate) fn allowed_after_cancel(cdb: &[u8], ledger: Ledger, ctx: CleanupCtx) -> bool { + if ctx == CleanupCtx::Critical { + return true; + } + if is_allow(cdb) { + return ledger.tray_locked; + } + if is_eject(cdb) { + return ctx == CleanupCtx::FinishEject; + } + match invalidated_agid(cdb) { + Some(agid) => ledger.agids & (1 << agid) != 0, + None => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ALLOW: [u8; 6] = [0x1E, 0, 0, 0, 0x00, 0]; + const PREVENT: [u8; 6] = [0x1E, 0, 0, 0, 0x01, 0]; + const EJECT: [u8; 6] = [0x1B, 0, 0, 0, 0x02, 0]; + const START: [u8; 6] = [0x1B, 0, 0, 0, 0x01, 0]; + const READ: [u8; 10] = [0x28, 0, 0, 0, 0, 0, 0, 0, 1, 0]; + + fn release(agid: u8) -> [u8; 12] { + let mut c = [0u8; 12]; + c[0] = REPORT_KEY; + c[10] = (agid << 6) | KEY_FORMAT_INVALIDATE_AGID; + c + } + + /// LD4 `exec_cleanup_allow_list`: one case per §2.4 row, and one refusal per row. + /// Per evidence SS-7 (AGID) and per the §2.4 table; do not change without a design + /// revision. + #[test] + fn exec_cleanup_allow_list() { + let none = Ledger::default(); + let locked = Ledger { + tray_locked: true, + agids: 0, + }; + let agid2 = Ledger { + tray_locked: false, + agids: 1 << 2, + }; + use CleanupCtx::*; + // Row 1: ALLOW iff tray_locked. + assert!(allowed_after_cancel(&ALLOW, locked, Plain)); + assert!(!allowed_after_cancel(&ALLOW, none, Plain)); + assert!( + !allowed_after_cancel(&PREVENT, locked, Plain), + "never PREVENT" + ); + // Row 2: LoEj only inside finish(Eject). + assert!(allowed_after_cancel(&EJECT, none, FinishEject)); + assert!(!allowed_after_cancel(&EJECT, locked, Plain)); + assert!(!allowed_after_cancel(&START, none, FinishEject), "LoEj=0"); + // Row 3: 0x3F iff that AGID's bit is set. + assert!(allowed_after_cancel(&release(2), agid2, Plain)); + assert!(!allowed_after_cancel(&release(1), agid2, Plain)); + assert!(!allowed_after_cancel(&release(2), none, Plain)); + // Row 4: any CDB inside a pre-cancel critical section. + assert!(allowed_after_cancel(&READ, none, Critical)); + assert!(!allowed_after_cancel(&READ, locked, Plain)); + assert!(!allowed_after_cancel(&READ, agid2, FinishEject)); + } + + #[test] + fn invalidated_agid_reads_byte_10() { + assert_eq!(invalidated_agid(&release(3)), Some(3)); + let mut alloc = release(0); + alloc[10] = 0x00; + assert_eq!(invalidated_agid(&alloc), None, "format 0 allocates"); + assert_eq!(invalidated_agid(&[0xA4, 0, 0]), None, "short CDB"); + } +} diff --git a/src/drive/mod.rs b/src/drive/mod.rs index 33b28415..0c71ff55 100644 --- a/src/drive/mod.rs +++ b/src/drive/mod.rs @@ -28,6 +28,10 @@ pub(crate) mod macos; #[cfg(windows)] pub(crate) mod windows; +pub(crate) mod allow; +#[cfg(test)] +mod stop_tests; + // Pick the platform module ONCE, here, so the cross-platform entry points below // dispatch through `platform::…` with no per-function `#[cfg]` in their bodies. #[cfg(target_os = "linux")] @@ -39,12 +43,15 @@ pub(crate) use windows as platform; use crate::error::{Error, Result}; use crate::event::Event; +use crate::halt::{Halt, Progress}; use crate::identity::DriveId; use crate::scsi::ScsiTransport; use crate::sector::SectorSource; use std::path::Path; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +pub(crate) use allow::CleanupCtx; /// Physical state of the drive tray and disc. #[derive(Debug, Clone, Copy, PartialEq)] @@ -114,8 +121,12 @@ pub struct Drive { matched_name_cache: std::sync::OnceLock, pub drive_id: DriveId, device_path: String, - /// Halt flag — when set, Drive::read() bails at the next check point. - halt: Arc, + /// The op token every CDB is checked against (§2.2). + slot: Slot, + /// Bumped on every `exec` completion, busy while one is in flight (T29 feed). + progress: Option, + /// AGIDs allocated through this Drive and not yet invalidated (§2.2 ledger). + agids: u8, /// Event handler — fires for read errors and library-level state changes. event_fn: Option>, /// The SINGLE AACS bus-encryption removal point. Decided ONCE from the @@ -142,41 +153,134 @@ pub struct Drive { block_dev_fd: Option, } +/// Which token a Drive checks every CDB against (stop design §2.2). +#[derive(Debug)] +enum Slot { + /// `own`: the Drive made the token itself ([`Drive::open`]); `false`: the caller's + /// op token ([`Drive::open_with`], [`Drive::attach`]). + Attached { token: Halt, own: bool }, + /// No token: `exec` panics in debug, and in release warns once and runs + /// uncancellably. + Detached, +} + +impl Slot { + fn own() -> Self { + Slot::Attached { + token: Halt::new(), + own: true, + } + } + + fn foreign(halt: &Halt) -> Self { + Slot::Attached { + token: halt.clone(), + own: false, + } + } + + fn token(&self) -> Option<&Halt> { + match self { + Slot::Attached { token, .. } => Some(token), + Slot::Detached => None, + } + } +} + +/// While alive, the Drive checks a `ScanOptions.halt` alias; dropping it (on every +/// exit, unwind included) restores the slot it replaced (§2.2). +pub(crate) struct AliasGuard<'a> { + drive: &'a mut Drive, + saved: Option, +} + +impl std::ops::Deref for AliasGuard<'_> { + type Target = Drive; + fn deref(&self) -> &Drive { + self.drive + } +} + +impl std::ops::DerefMut for AliasGuard<'_> { + fn deref_mut(&mut self) -> &mut Drive { + self.drive + } +} + +impl Drop for AliasGuard<'_> { + fn drop(&mut self) { + if let Some(slot) = self.saved.take() { + self.drive.slot = slot; + } + } +} + impl Drive { + /// Open the drive with its own token, cancelled by [`halt`](Self::halt). pub fn open(device: &Path) -> Result { + Self::open_slot(device, Slot::own()) + } + + /// Open the drive checking the caller's op token: identification and every later + /// CDB are refused once `halt` is cancelled. + pub fn open_with(device: &Path, halt: &Halt) -> Result { + Self::open_slot(device, Slot::foreign(halt)) + } + + fn open_slot(device: &Path, slot: Slot) -> Result { let t0 = std::time::Instant::now(); tracing::info!(target: "freemkv::drive", phase = "open", device = %device.display(), "begin"); - let mut transport = crate::scsi::open(device)?; - let drive_id = DriveId::from_drive(transport.as_mut())?; + let transport = match slot.token() { + Some(halt) => crate::scsi::open_with(device, halt)?, + None => crate::scsi::open(device)?, + }; + let mut drive = Self::bare(transport, device.to_string_lossy().to_string(), slot); + drive.drive_id = DriveId::identify(&mut |cdb, dir, buf, t| drive.exec(cdb, dir, buf, t))?; tracing::info!( target: "freemkv::drive", phase = "open", device = %device.display(), - vendor = %drive_id.vendor_id.trim(), - product = %drive_id.product_id.trim(), + vendor = %drive.drive_id.vendor_id.trim(), + product = %drive.drive_id.product_id.trim(), elapsed_ms = t0.elapsed().as_millis() as u64, "end" ); - #[cfg(target_os = "linux")] - let block_dev_fd = open_block_device_for_sg(device); + { + drive.block_dev_fd = open_block_device_for_sg(device); + } + Ok(drive) + } - Ok(Drive { - scsi: transport, + // A Drive over `scsi` with a blank identity (filled in by the caller). + fn bare(scsi: Box, device_path: String, slot: Slot) -> Self { + Drive { + scsi, unlocker_name: None, oem_vid: None, init_ran: false, tray_locked: false, matched_name_cache: std::sync::OnceLock::new(), - drive_id, - device_path: device.to_string_lossy().to_string(), - halt: Arc::new(AtomicBool::new(false)), + drive_id: DriveId { + vendor_id: String::new(), + product_id: String::new(), + product_revision: String::new(), + vendor_specific: String::new(), + firmware_date: String::new(), + serial_number: String::new(), + raw_inquiry: Vec::new(), + raw_gc_010c: Vec::new(), + }, + device_path, + slot, + progress: None, + agids: 0, event_fn: None, bus_stage: crate::sector::bus_removal::BusStage::Passthrough, bus_gate: None, #[cfg(target_os = "linux")] - block_dev_fd, - }) + block_dev_fd: None, + } } /// Set the SINGLE AACS bus-removal stage for this drive, decided once from @@ -227,31 +331,21 @@ impl Drive { // command-builder/response-parser logic can be exercised against a mock. #[cfg(test)] pub(crate) fn from_transport_for_test(scsi: Box) -> Self { - Drive { - scsi, - unlocker_name: None, - oem_vid: None, - init_ran: false, - tray_locked: false, - matched_name_cache: std::sync::OnceLock::new(), - drive_id: DriveId { - vendor_id: String::new(), - product_id: String::new(), - product_revision: String::new(), - vendor_specific: String::new(), - firmware_date: String::new(), - serial_number: String::new(), - raw_inquiry: Vec::new(), - raw_gc_010c: Vec::new(), - }, - device_path: "test".to_string(), - halt: Arc::new(AtomicBool::new(false)), - event_fn: None, - bus_stage: crate::sector::bus_removal::BusStage::Passthrough, - bus_gate: None, - #[cfg(target_os = "linux")] - block_dev_fd: None, - } + Self::bare(scsi, "test".to_string(), Slot::own()) + } + + /// Test fixture (feature `test-util`): a Drive over any transport (e.g. + /// [`crate::test_util::FakeTransport`]) with its own token and a blank identity. + #[cfg(any(test, feature = "test-util"))] + pub fn from_transport(scsi: Box) -> Self { + Self::bare(scsi, "test".to_string(), Slot::own()) + } + + /// Test fixture (feature `test-util`): [`from_transport`](Self::from_transport) + /// checking the caller's op token, as [`open_with`](Self::open_with) does. + #[cfg(any(test, feature = "test-util"))] + pub fn from_transport_with(scsi: Box, halt: &Halt) -> Self { + Self::bare(scsi, "test".to_string(), Slot::foreign(halt)) } /// Test-only: mark the drive as claimed by a named firmware unlocker at @@ -269,19 +363,21 @@ impl Drive { self.oem_vid = Some(vid); } - /// Get a clone of the halt flag. Set to true to interrupt Drive::read(). - pub fn halt_flag(&self) -> Arc { - self.halt.clone() + /// The attached token as a raw flag (a view over the same bit, so setting it + /// is a Stop). A detached Drive hands out a fresh, never-read flag. + pub fn halt_flag(&self) -> Arc { + match self.slot.token() { + Some(t) => t.as_arc().clone(), + None => Arc::default(), + } } - /// Halt the drive — Drive::read() will bail at the next check point. + /// Cancel the attached token: the next CDB is refused, and a READ in flight is + /// discarded when it completes. pub fn halt(&self) { - self.halt.store(true, Ordering::Relaxed); - } - - /// Clear the halt flag for the next operation. - pub fn clear_halt(&self) { - self.halt.store(false, Ordering::Relaxed); + if let Some(t) = self.slot.token() { + t.cancel(); + } } /// Set an event handler for read recovery events. @@ -289,27 +385,180 @@ impl Drive { self.event_fn = Some(Box::new(f)); } - pub(crate) fn is_halted(&self) -> bool { - self.halt.load(Ordering::Relaxed) + /// The token every CDB is checked against, or `None` once [`detach`](Self::detach)ed. + pub fn token(&self) -> Option<&Halt> { + self.slot.token() } - // Halt-aware SCSI execute: returns `Err(Halted)` if the flag is set before - // dispatch or by completion. Keeps Drive::read free of explicit halt checks. - pub(crate) fn checked_exec( + /// Check the caller's op token from now on (it replaces the Drive's own). + pub fn attach(&mut self, halt: &Halt) { + self.slot = Slot::foreign(halt); + } + + /// Stop checking any token, handing back the one that was attached. Until the + /// next [`attach`](Self::attach), `exec` panics in debug builds and in release + /// warns once and runs uncancellably. + pub fn detach(&mut self) -> Option { + match std::mem::replace(&mut self.slot, Slot::Detached) { + Slot::Attached { token, .. } => Some(token), + Slot::Detached => None, + } + } + + /// Report forward progress to `p`: bumped on every CDB completion, and + /// [`busy`](Progress::busy) while a CDB (or a scan bus step) is in flight. + pub fn attach_progress(&mut self, p: &Progress) { + self.progress = Some(p.clone()); + } + + pub(crate) fn progress(&self) -> Option<&Progress> { + self.progress.as_ref() + } + + /// The `ScanOptions.halt` alias rule (§2.2): over the Drive's own token (or none), + /// check `alias` until the guard drops, then restore; over a caller's attached + /// token, that token wins and a different alias is logged. + pub(crate) fn alias(&mut self, alias: Option<&Halt>) -> AliasGuard<'_> { + let saved = match (alias, &self.slot) { + (None, _) => None, + (Some(a), Slot::Attached { token, own: false }) => { + if !Arc::ptr_eq(a.as_arc(), token.as_arc()) { + tracing::warn!( + target: "freemkv::drive", + phase = "halt_alias", + "ScanOptions.halt differs from the op token attached to this drive; the attached token wins" + ); + } + None + } + (Some(a), _) => Some(std::mem::replace(&mut self.slot, Slot::foreign(a))), + }; + AliasGuard { drive: self, saved } + } + + pub(crate) fn is_halted(&self) -> bool { + self.slot.token().is_some_and(Halt::is_cancelled) + } + + /// `Err(Halted)` once the attached token is cancelled. + pub(crate) fn check_token(&self) -> Result<()> { + self.slot.token().map_or(Ok(()), Halt::check) + } + + /// Wait `d` on the attached token: `Err(Halted)` within one slice of a Stop. + pub(crate) fn pause(&self, d: Duration) -> Result<()> { + match self.slot.token() { + Some(t) => t.wait(d), + None => Halt::new().wait(d), + } + } + + /// The §2.2 ledger: what a post-cancel clean-up CDB may release. + pub(crate) fn ledger(&self) -> allow::Ledger { + allow::Ledger { + tray_locked: self.tray_locked, + agids: self.agids, + } + } + + // A Detached exec: a bug in debug; in release, one warning, then uncancellable. + fn on_detached_exec(&self) { + static WARNED: std::sync::Once = std::sync::Once::new(); + debug_assert!(false, "Drive::exec on a detached drive"); + WARNED.call_once(|| { + tracing::warn!(target: "freemkv::drive", phase = "exec", "CDB on a detached drive runs uncancellably"); + }); + } + + /// Every CDB this Drive issues goes through here (§2.2): the token is checked + /// before dispatch (a cancelled op issues nothing), and a READ-class CDB is + /// checked again on completion so its buffer is never returned as good. + pub(crate) fn exec( &mut self, cdb: &[u8], dir: crate::scsi::DataDirection, buf: &mut [u8], timeout_ms: u32, ) -> Result { - if self.is_halted() { + crate::halt::diag::assert_may_block("Drive::exec", Duration::MAX); + match self.slot.token() { + None => self.on_detached_exec(), + Some(t) => t.check()?, + } + let r = self.dispatch(cdb, dir, buf, timeout_ms); + if is_read_class(cdb) { + self.check_token()?; + } + r + } + + /// A CDB inside a critical section entered before any cancel (§2.4 row 4): not + /// checked against the token. + pub(crate) fn exec_uncancellable( + &mut self, + cdb: &[u8], + dir: crate::scsi::DataDirection, + buf: &mut [u8], + timeout_ms: u32, + ) -> Result { + crate::halt::diag::assert_may_block("Drive::exec", Duration::MAX); + self.dispatch(cdb, dir, buf, timeout_ms) + } + + /// A clean-up CDB (§2.4): runs as usual until a cancel, then only if the + /// allow-list admits it from `ctx` with this Drive's ledger; refused with + /// `Halted` (and not sent) otherwise. + pub(crate) fn exec_cleanup( + &mut self, + cdb: &[u8], + dir: crate::scsi::DataDirection, + buf: &mut [u8], + timeout_ms: u32, + ctx: CleanupCtx, + ) -> Result { + crate::halt::diag::assert_may_block("Drive::exec", Duration::MAX); + if self.is_halted() && !allow::allowed_after_cancel(cdb, self.ledger(), ctx) { return Err(Error::Halted); } - let r = self.scsi.as_mut().execute(cdb, dir, buf, timeout_ms)?; - if self.is_halted() { - return Err(Error::Halted); + self.dispatch(cdb, dir, buf, timeout_ms) + } + + // The one call into the transport: progress is busy while the CDB is in flight + // and bumped on its completion, and the AGID ledger follows REPORT KEY. + fn dispatch( + &mut self, + cdb: &[u8], + dir: crate::scsi::DataDirection, + buf: &mut [u8], + timeout_ms: u32, + ) -> Result { + let busy = self.progress.as_ref().map(Progress::busy); + let r = self.scsi.as_mut().execute(cdb, dir, buf, timeout_ms); + drop(busy); + if let Some(p) = &self.progress { + p.bump(); + } + self.note_ledger(cdb, buf, r.is_ok()); + r + } + + // §2.2 ledger: an ALLOW that reached the drive ends this Drive's claim on the tray + // (so it is sent once); a successful REPORT KEY key format 0 allocates the AGID in + // response byte 7 bits 7-6; key format 3Fh invalidates the AGID in byte 10 bits 7-6. + fn note_ledger(&mut self, cdb: &[u8], resp: &[u8], ok: bool) { + if cdb.first() == Some(&allow::PREVENT_ALLOW) && cdb.get(4).is_some_and(|b| b & 1 == 0) { + self.tray_locked = false; + } + // SS-7 (evidence, not spec) libaacs mmc.c: "*agid = (buf[7] & 0xff) >> 6;" on + // allocation, and "cmd[10] = (agid << 6) | (format & 0x3f);" names the AGID. + if cdb.first() != Some(&allow::REPORT_KEY) || cdb.len() < 11 { + return; + } + if let Some(agid) = allow::invalidated_agid(cdb) { + self.agids &= !(1 << agid); + } else if cdb[10] & 0x3F == 0 && ok && resp.len() > 7 { + self.agids |= 1 << (resp[7] >> 6); } - Ok(r) } /// Close the drive cleanly. Unlocks the tray and closes the fd. @@ -341,76 +590,102 @@ impl Drive { self.unlocker_name.as_deref() } - /// Access the SCSI transport for direct commands (used by CSS/AACS auth). - pub fn scsi_mut(&mut self) -> &mut dyn ScsiTransport { - self.scsi.as_mut() - } - // The OEM Volume ID a matching unlocker returned at Drive::init, if any. // AACS uses it to skip the cert handshake. None if no unlocker matched. pub(crate) fn oem_vid(&self) -> Option<[u8; 16]> { self.oem_vid } + /// Wait for the drive to become ready: TEST UNIT READY every 500 ms until it + /// answers GOOD. Fails with `DeviceNotReady` only after 60 s without progress + /// (§2.11): an answer not yet seen in this wait, or a rising progress indicator. + /// A Stop interrupts between polls; a dead bus fails after 5 s of failures. pub fn wait_ready(&mut self) -> Result<()> { + self.wait_ready_with(WaitReadyTiming::PRODUCTION) + } + + pub(crate) fn wait_ready_with(&mut self, timing: WaitReadyTiming) -> Result<()> { + // SS-4 TEST UNIT READY: "provides a means to check if the logical unit is ready". let tur = [SCSI_TEST_UNIT_READY, 0x00, 0x00, 0x00, 0x00, 0x00]; let t0 = std::time::Instant::now(); tracing::info!(target: "freemkv::drive", phase = "wait_ready", "begin"); - - // The poll can take up to 30s (60 × 500ms). Heartbeat it so a slow - // spin-up is visible as steady beats rather than a silent stall. let mut hb = crate::progress::Heartbeat::new("wait_ready"); + // T6: the answers seen so far and the highest progress indicator; either + // growing re-arms the no-progress window. + let moved = Progress::new(); + let mut stall = crate::halt::StallTimer::new(timing.window, &moved); + let mut seen: Vec> = Vec::new(); + let mut best: Option = None; // (when the first failure of the current run completed, failures in it) let mut failing: Option<(std::time::Instant, u32)> = None; let mut start_sent = false; // Consecutive 3Ah answers, and whether 04/01 (a disc being identified) was seen. let (mut empty_run, mut becoming_ready_seen) = (0u32, false); - for attempt in 0..60u64 { - hb.tick(attempt, 60); + let mut attempt = 0u64; + loop { + attempt += 1; + hb.tick(t0.elapsed().as_secs(), timing.window.as_secs()); let mut buf = [0u8; 0]; - // `checked_exec`, not bare `execute`: this poll (60 x 500 ms = ~30 s) must - // see `self.halt`, or a Stop during cold spin-up is ignored for half a - // minute. A TUR that FAILS is just not-ready-yet and keeps the loop going. - match self.checked_exec(&tur, crate::scsi::DataDirection::None, &mut buf, 5_000) { + match self.exec( + &tur, + crate::scsi::DataDirection::None, + &mut buf, + crate::scsi::TUR_TIMEOUT_MS, + ) { Ok(_) => { tracing::info!( target: "freemkv::drive", phase = "wait_ready", - attempts = attempt + 1, + attempts = attempt, elapsed_ms = t0.elapsed().as_millis() as u64, "end" ); return Ok(()); } Err(Error::Halted) => return Err(Error::Halted), - // Transport failures (DID_TIME_OUT/DID_RESET, fd<0 DeviceNotFound) may be - // a hiccup; a run of 2+ lasting the budget past the first one's - // completion is a dead bus, surfaced rather than polled for 30 s. + // T5: transport failures (DID_TIME_OUT/DID_RESET, fd<0 DeviceNotFound) may be + // a hiccup; a run of 2+ lasting the budget past the first one's completion + // is a dead bus. Any drive answer ends the run. Err(e) if e.is_scsi_transport_failure() => { empty_run = 0; let (since, n) = failing.get_or_insert((std::time::Instant::now(), 0)); *n += 1; - if *n >= 2 && since.elapsed() >= WAIT_READY_DEAD_BUS_BUDGET { + if *n >= 2 && since.elapsed() >= timing.dead_bus { return Err(e); } } Err(e) => { failing = None; let sense = e.scsi_sense().map(|s| (s.sense_key, s.asc, s.ascq)); + // §2.11: a new answer in this wait is progress; the same one, or two + // known ones alternating, is not. + if !seen.contains(&sense) { + seen.push(sense); + moved.bump(); + } + // SS-1: "The PROGRESS INDICATION field is a percent complete indication"; + // a value above the best seen so far is progress. + if let Some(p) = self.scsi.last_sense_progress() + && best.is_none_or(|b| p > b) + { + best = Some(p); + moved.bump(); + } let empty = matches!(sense, Some((crate::scsi::SENSE_KEY_NOT_READY, 0x3A, _))); empty_run = if empty { empty_run + 1 } else { 0 }; becoming_ready_seen |= sense == Some((crate::scsi::SENSE_KEY_NOT_READY, 0x04, 0x01)); - // An empty drive that never said 04/01 will not become ready. + // SS-3 MMC-6 Table F.3 "2 3A 00 MEDIUM NOT PRESENT": an empty drive that + // never said 04/01 will not become ready. if !becoming_ready_seen && empty_run >= WAIT_READY_MAX_EMPTY_POLLS { return Err(e); } match sense { - // Incompatible / unreadable medium (MMC-6 Table F.3) never - // becomes ready: surface its sense now, not after 30 s. + // SS-3 "2 30 00 INCOMPATIBLE MEDIUM INSTALLED": never becomes + // ready, so surface its sense now. Some((crate::scsi::SENSE_KEY_NOT_READY, 0x30, _)) => return Err(e), - // 04/02, initializing command required: nothing else spins the - // unit up, so send START UNIT once, then keep polling. + // SS-3 "2 04 02 LOGICAL UNIT NOT READY, INITIALIZING CMD. REQUIRED": + // nothing else spins the unit up, so START UNIT once. Some((crate::scsi::SENSE_KEY_NOT_READY, 0x04, 0x02)) if !start_sent => { start_sent = true; self.start_unit()?; @@ -419,15 +694,17 @@ impl Drive { } } } - // Halt-aware backoff: the flag can also flip DURING the 500 ms - // gap, which is where most of the 30 s is actually spent. - sleep_until_halted(&self.halt, std::time::Duration::from_millis(500))?; + if stall.poll(&moved) == crate::halt::Stall::Expired { + break; + } + self.pause(timing.poll)?; } tracing::warn!( target: "freemkv::drive", phase = "wait_ready", elapsed_ms = t0.elapsed().as_millis() as u64, - "device never became ready" + window_ms = timing.window.as_millis() as u64, + "device never became ready: no progress for the whole window" ); Err(Error::DeviceNotReady { path: self.device_path.clone(), @@ -451,7 +728,7 @@ impl Drive { 0x00, ]; let mut buf = [0u8; 8]; - let reply = self.scsi.as_mut().execute( + let reply = self.exec( &cdb, crate::scsi::DataDirection::FromDevice, &mut buf, @@ -511,12 +788,7 @@ impl Drive { // Fallback: try TUR let tur = [SCSI_TEST_UNIT_READY, 0x00, 0x00, 0x00, 0x00, 0x00]; let mut empty = [0u8; 0]; - match self.scsi.as_mut().execute( - &tur, - crate::scsi::DataDirection::None, - &mut empty, - 5_000, - ) { + match self.exec(&tur, crate::scsi::DataDirection::None, &mut empty, 5_000) { Ok(_) => DriveStatus::DiscPresent, Err(ref e) if e.scsi_sense() @@ -567,9 +839,7 @@ impl Drive { ]; let mut buf = [0u8; 8]; let r = self - .scsi - .as_mut() - .execute( + .exec( &cdb, crate::scsi::DataDirection::FromDevice, &mut buf, @@ -604,8 +874,11 @@ impl Drive { // disc-independent; AACS/CSS handshakes run LATER, gated on disc kind. A // transport fault aborts init (v1.1.0 invariant); other errors fall through. self.init_ran = true; - let (matched, unlock_res) = - crate::unlock_bridge::run_features(self.scsi.as_mut(), &self.drive_id); + let drive_id = self.drive_id.clone(); + let (matched, unlock_res) = crate::unlock_bridge::run_features(self, &drive_id); + // §2.3 reclassification: a Stop is `Halted` before any other result mapping, so + // the refused CDBs it caused never read as a dead bus. + self.check_token()?; let r: Result<()> = match unlock_res { Ok(Some(unlocked)) => { // Record which firmware unlocker ran, not the id-only lookup. @@ -684,9 +957,7 @@ impl Drive { ]; let mut buf = vec![0u8; 256]; let r = self - .scsi - .as_mut() - .execute( + .exec( &cdb, crate::scsi::DataDirection::FromDevice, &mut buf, @@ -715,9 +986,7 @@ impl Drive { ]; let mut buf = vec![0u8; 8]; let r = self - .scsi - .as_mut() - .execute( + .exec( &cdb, crate::scsi::DataDirection::FromDevice, &mut buf, @@ -748,9 +1017,7 @@ impl Drive { ]; let mut buf = vec![0u8; 252]; let r = self - .scsi - .as_mut() - .execute( + .exec( &cdb, crate::scsi::DataDirection::FromDevice, &mut buf, @@ -798,7 +1065,7 @@ impl Drive { 0x00, ]; let mut buf = payload; - match self.checked_exec(&cdb, crate::scsi::DataDirection::ToDevice, &mut buf, 5_000) { + match self.exec(&cdb, crate::scsi::DataDirection::ToDevice, &mut buf, 5_000) { Ok(_) => { tracing::info!(target: "freemkv::drive", phase = "error_recovery", "recovered-error reporting enabled (PER=1) — marginal reads will surface instead of committing silently"); true @@ -815,9 +1082,7 @@ impl Drive { let cdb = crate::scsi::build_read_buffer(mode, buffer_id, 0, length as u32); let mut buf = vec![0u8; length as usize]; let r = self - .scsi - .as_mut() - .execute( + .exec( &cdb, crate::scsi::DataDirection::FromDevice, &mut buf, @@ -971,7 +1236,7 @@ impl Drive { 0x00, ]; - match self.checked_exec( + match self.exec( &cdb, crate::scsi::DataDirection::FromDevice, buf, @@ -1074,7 +1339,7 @@ impl Drive { 0x00, ]; let mut buf = [0u8; 8]; - let result = self.scsi.as_mut().execute( + let result = self.exec( &cdb, crate::scsi::DataDirection::FromDevice, &mut buf, @@ -1095,8 +1360,10 @@ impl Drive { } } - /// Lock the tray so the disc cannot be ejected during a rip. + /// Lock the tray so the disc cannot be ejected during a rip. After a Stop it + /// sends nothing and leaves the tray unlocked. pub fn lock_tray(&mut self) { + // SS-5 MMC-6 Table 329: Persistent 0, Prevent 1 = "Prevent State shall be set (Locked)". let prevent = [ SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL, 0x00, @@ -1106,18 +1373,24 @@ impl Drive { 0x00, ]; let mut buf = [0u8; 0]; + // Pre-check: a stopped op must not lock a tray it will never unlock. + if self.is_halted() { + return; + } self.tray_locked = true; - if let Err(e) = - self.scsi - .as_mut() - .execute(&prevent, crate::scsi::DataDirection::None, &mut buf, 5_000) - { - tracing::warn!(target: "freemkv::drive", error = %e, "PREVENT MEDIUM REMOVAL failed"); + match self.exec(&prevent, crate::scsi::DataDirection::None, &mut buf, 5_000) { + Err(Error::Halted) => self.tray_locked = false, + Err(e) => { + tracing::warn!(target: "freemkv::drive", error = %e, "PREVENT MEDIUM REMOVAL failed") + } + Ok(_) => {} } } - /// Unlock the tray so the user can manually eject the disc. + /// Unlock the tray so the user can manually eject the disc. A clean-up CDB: after + /// a Stop it is still sent if this Drive locked the tray (§2.4). pub fn unlock_tray(&mut self) { + // SS-5 MMC-6 Table 329: Persistent 0, Prevent 0 = "Prevent State shall be cleared (Unlocked)". let allow = [ SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL, 0x00, @@ -1127,30 +1400,36 @@ impl Drive { 0x00, ]; let mut buf = [0u8; 0]; + // Checked against the ledger as it stands, then cleared: exactly one ALLOW. + let r = self.exec_cleanup( + &allow, + crate::scsi::DataDirection::None, + &mut buf, + 5_000, + CleanupCtx::Plain, + ); self.tray_locked = false; // Best-effort (the tray-unlock is advisory), but a failure is worth a warn! // rather than a silent `let _`: a stuck PREVENT lock is a real symptom the // operator otherwise never sees until the tray won't open. - if let Err(e) = - self.scsi - .as_mut() - .execute(&allow, crate::scsi::DataDirection::None, &mut buf, 5_000) - { - tracing::warn!( + match r { + Ok(_) | Err(Error::Halted) => {} + Err(e) => tracing::warn!( target: "freemkv::drive", phase = "unlock_tray", error_code = e.code(), "Failed to clear the medium-removal PREVENT lock; the tray may stay locked until the drive is power-cycled." - ); + ), } } /// Eject the disc tray. Unlocks first, then ejects. pub fn eject(&mut self) -> Result<()> { self.unlock_tray(); + // SS-6 MMC-6 Table 633: LoEj 1, Start 0 = "Eject the disc if permitted". let eject_cdb = [SCSI_START_STOP_UNIT, 0, 0, 0, 0x02, 0]; let mut buf = [0u8; 0]; - self.scsi.as_mut().execute( + self.exec( &eject_cdb, crate::scsi::DataDirection::None, &mut buf, @@ -1162,9 +1441,10 @@ impl Drive { // START STOP UNIT with START=1, LoEj=0 (never ejects). Only Halted propagates; // a rejected START just leaves wait_ready polling. fn start_unit(&mut self) -> Result<()> { + // SS-6 MMC-6 Table 633: LoEj 0, Start 1 = "Start the disc and make ready for access". let start = [SCSI_START_STOP_UNIT, 0, 0, 0, 0x01, 0]; let mut buf = [0u8; 0]; - match self.checked_exec(&start, crate::scsi::DataDirection::None, &mut buf, 30_000) { + match self.exec(&start, crate::scsi::DataDirection::None, &mut buf, 30_000) { Err(Error::Halted) => Err(Error::Halted), Err(e) => { tracing::warn!(target: "freemkv::drive", phase = "wait_ready", error_code = e.code(), "START UNIT rejected"); @@ -1186,19 +1466,13 @@ impl Drive { let stop = [SCSI_START_STOP_UNIT, 0, 0, 0, 0x00, 0]; // START=0, LOEJ=0 → spin down let start = [SCSI_START_STOP_UNIT, 0, 0, 0, 0x01, 0]; // START=1, LOEJ=0 → spin up let mut buf = [0u8; 0]; - // `checked_exec` + `sleep_until_halted`, not blind `thread::sleep`: this ~15 s - // wait runs from the recovery path, exactly when Stop is likely pressed. A - // half-finished spin cycle is fine — the next command spins the drive back up. - self.checked_exec(&stop, crate::scsi::DataDirection::None, &mut buf, 30_000)?; - sleep_until_halted( - &self.halt, - std::time::Duration::from_secs(SPIN_DOWN_IDLE_SECS), - )?; - self.checked_exec(&start, crate::scsi::DataDirection::None, &mut buf, 30_000)?; - sleep_until_halted( - &self.halt, - std::time::Duration::from_secs(SPIN_UP_SETTLE_SECS), - )?; + // `exec` + `pause`, not blind `thread::sleep`: this ~15 s wait runs from the + // recovery path, exactly when Stop is likely pressed. A half-finished spin + // cycle is fine — the next command spins the drive back up. + self.exec(&stop, crate::scsi::DataDirection::None, &mut buf, 30_000)?; + self.pause(Duration::from_secs(SPIN_DOWN_IDLE_SECS))?; + self.exec(&start, crate::scsi::DataDirection::None, &mut buf, 30_000)?; + self.pause(Duration::from_secs(SPIN_UP_SETTLE_SECS))?; Ok(()) } @@ -1209,7 +1483,7 @@ impl Drive { buf: &mut [u8], timeout_ms: u32, ) -> Result { - self.scsi.as_mut().execute(cdb, direction, buf, timeout_ms) + self.exec(cdb, direction, buf, timeout_ms) } } @@ -1429,24 +1703,35 @@ const WAIT_READY_MAX_EMPTY_POLLS: u32 = 10; // How long an unbroken run of transport-class TUR failures may last, from the // first one's completion, before wait_ready calls the bus dead. -const WAIT_READY_DEAD_BUS_BUDGET: std::time::Duration = std::time::Duration::from_secs(5); +const WAIT_READY_DEAD_BUS_BUDGET: Duration = Duration::from_secs(5); -// Halt-aware sleep primitive — wakes within ~100 ms of `halt` flipping true, returning -// Error::Halted. Used by wait_ready's poll backoff and spin_cycle's spin-down/settle pauses. -pub(crate) fn sleep_until_halted(halt: &AtomicBool, total: std::time::Duration) -> Result<()> { - const SLICE: std::time::Duration = std::time::Duration::from_millis(100); - let deadline = std::time::Instant::now() + total; - loop { - if halt.load(Ordering::Relaxed) { - return Err(Error::Halted); - } - let now = std::time::Instant::now(); - if now >= deadline { - return Ok(()); - } - let remaining = deadline - now; - std::thread::sleep(remaining.min(SLICE)); - } +/// `wait_ready`'s durations (§2.11, T3/T5/T6), parameters so tests run in ms. +#[derive(Debug, Clone, Copy)] +pub(crate) struct WaitReadyTiming { + /// The wait between two TEST UNIT READYs. + pub poll: Duration, + /// T6: give up after this long with no progress. + pub window: Duration, + /// T5: an unbroken run of transport failures this long is a dead bus. + pub dead_bus: Duration, +} + +impl WaitReadyTiming { + /// 500 ms polls, 60 s without progress (user: "60s"), the 5 s dead-bus budget. + pub(crate) const PRODUCTION: Self = Self { + poll: Duration::from_millis(500), + window: Duration::from_secs(60), + dead_bus: WAIT_READY_DEAD_BUS_BUDGET, + }; +} + +// READ-class opcodes: their data is checked against the token again on completion +// (§2.2), so a READ that finishes after a Stop is discarded, never returned as good. +fn is_read_class(cdb: &[u8]) -> bool { + matches!( + cdb.first(), + Some(&(crate::scsi::SCSI_READ_10 | 0xA8 | 0x88 | 0xBE | 0xB9)) + ) } /// Structured outcome of [`resolve_device`] — a machine-readable signal @@ -1475,50 +1760,63 @@ mod halt_tests { use super::*; use std::time::{Duration, Instant}; + struct Idle; + impl ScsiTransport for Idle { + fn execute( + &mut self, + _: &[u8], + _: crate::scsi::DataDirection, + _: &mut [u8], + _: u32, + ) -> Result { + unreachable!("pause issues no CDB") + } + } + + fn idle() -> Drive { + Drive::from_transport_for_test(Box::new(Idle)) + } + + // `Drive::pause` (was `sleep_until_halted`) is `Halt::wait` on the attached token. #[test] - fn sleep_until_halted_completes_when_not_halted() { - let flag = AtomicBool::new(false); + fn pause_completes_when_not_halted() { let t0 = Instant::now(); - let r = sleep_until_halted(&flag, Duration::from_millis(150)); - assert!(r.is_ok()); + assert!(idle().pause(Duration::from_millis(150)).is_ok()); assert!(t0.elapsed() >= Duration::from_millis(140)); } #[test] - fn sleep_until_halted_returns_immediately_if_preflagged() { - let flag = AtomicBool::new(true); + fn pause_returns_immediately_if_prehalted() { + let d = idle(); + d.halt(); let t0 = Instant::now(); - let r = sleep_until_halted(&flag, Duration::from_secs(10)); - assert!(matches!(r, Err(Error::Halted))); - // Must wake within one slice (100 ms) — the whole point of the - // primitive is that a 30 s sleep doesn't block Stop. + assert!(matches!( + d.pause(Duration::from_secs(10)), + Err(Error::Halted) + )); assert!(t0.elapsed() < Duration::from_millis(200)); } #[test] - fn sleep_until_halted_wakes_mid_sleep() { - let flag = Arc::new(AtomicBool::new(false)); - let f2 = flag.clone(); + fn pause_wakes_mid_sleep() { + let d = idle(); + let flag = d.halt_flag(); let t0 = Instant::now(); - std::thread::spawn(move || { + let stopper = std::thread::spawn(move || { std::thread::sleep(Duration::from_millis(150)); - f2.store(true, Ordering::Relaxed); + flag.store(true, std::sync::atomic::Ordering::Release); }); - let r = sleep_until_halted(&flag, Duration::from_secs(10)); + let r = d.pause(Duration::from_secs(10)); + stopper.join().unwrap(); assert!(matches!(r, Err(Error::Halted))); let waited = t0.elapsed(); - // The sleep must end because the flag flipped, not the 10 s timeout; the lower - // bound rules out returning early otherwise. Upper bound was 350 ms but that - // measures the scheduler: a loaded CI runner hit 377 ms and failed for no reason. assert!(waited < Duration::from_secs(2), "waited {waited:?}"); assert!(waited >= Duration::from_millis(140), "waited {waited:?}"); } #[test] - fn sleep_until_halted_zero_duration_is_noop_when_not_halted() { - let flag = AtomicBool::new(false); - let r = sleep_until_halted(&flag, Duration::ZERO); - assert!(r.is_ok()); + fn pause_zero_duration_is_noop_when_not_halted() { + assert!(idle().pause(Duration::ZERO).is_ok()); } #[test] @@ -1560,6 +1858,7 @@ mod halt_tests { mod command_tests { use super::*; use crate::scsi::{DataDirection, ScsiResult, ScsiTransport}; + use std::sync::atomic::{AtomicBool, Ordering}; // Minimal MODE SENSE(10) reply carrying the Error Recovery page (0x01) // with the given flags/retry, no block descriptors. `ps` sets the page's @@ -2365,16 +2664,16 @@ mod command_tests { } #[test] - fn clear_halt_reenables_reads() { - // halt() then clear_halt() must allow reads again — the flag is - // not sticky. + fn a_fresh_token_reenables_reads_after_a_stop() { + // A cancel is one-way (`clear_halt` is gone, §2.2): the next op attaches a + // fresh token, and reads work again under it. let RecordingHarness { drive: mut d, cdb: _cdb, timeouts: _to, } = recording(TransportOutcome::Ok(2048)); d.halt(); - d.clear_halt(); + d.attach(&Halt::new()); let mut buf = vec![0u8; 2048]; assert!(d.read(0, 1, &mut buf, false).is_ok()); } @@ -3001,7 +3300,12 @@ mod command_tests { } } let mut d = Drive::from_transport_for_test(Box::new(NeverReady)); - let r = d.wait_ready(); + // T6 scaled: 60 s → 300 ms, polls 500 ms → 10 ms. + let r = d.wait_ready_with(WaitReadyTiming { + poll: std::time::Duration::from_millis(10), + window: std::time::Duration::from_millis(300), + dead_bus: WAIT_READY_DEAD_BUS_BUDGET, + }); assert!( matches!(r, Err(Error::DeviceNotReady { .. })), "a drive that never answers TUR successfully must be DeviceNotReady, got {r:?}" diff --git a/src/drive/stop_tests.rs b/src/drive/stop_tests.rs new file mode 100644 index 00000000..d5670a9e --- /dev/null +++ b/src/drive/stop_tests.rs @@ -0,0 +1,736 @@ +//! Stop design §5.1 "Drive" tests (LD1–LD16) and the `wait_ready` guards (G4, G5), +//! run against `test_util::FakeTransport` with scaled durations. + +use super::*; +use crate::scsi::{DataDirection, ScsiResult, ScsiSense}; +use crate::test_util::{FakeMode, FakeTransport}; +use std::sync::Mutex; +use std::time::Instant; + +type Answer = Box Result + Send>; + +// A transport answering every CDB from a closure (the drive "behind" the fake). +struct Script(Answer, Option, Arc>>); + +impl Script { + fn new(f: impl FnMut(&[u8], &mut [u8]) -> Result + Send + 'static) -> Self { + Script(Box::new(f), None, Arc::default()) + } + // A script that also reports a progress indication set through the returned cell. + fn with_progress( + f: impl FnMut(&[u8], &mut [u8]) -> Result + Send + 'static, + ) -> (Self, Arc>>) { + let cell: Arc>> = Arc::default(); + (Script(Box::new(f), None, cell.clone()), cell) + } +} + +impl ScsiTransport for Script { + fn execute( + &mut self, + cdb: &[u8], + _: DataDirection, + d: &mut [u8], + _: u32, + ) -> Result { + let r = (self.0)(cdb, d); + self.1 = *self.2.lock().unwrap(); + r + } + fn last_sense_progress(&self) -> Option { + self.1 + } +} + +fn good(d: &[u8]) -> Result { + Ok(ScsiResult { + status: 0, + bytes_transferred: d.len(), + sense: [0u8; 32], + }) +} + +fn not_ready(asc: u8, ascq: u8) -> Error { + Error::ScsiError { + opcode: SCSI_TEST_UNIT_READY, + status: 2, + sense: Some(ScsiSense { + sense_key: crate::scsi::SENSE_KEY_NOT_READY, + asc, + ascq, + }), + } +} + +fn fault() -> Error { + Error::ScsiError { + opcode: SCSI_TEST_UNIT_READY, + status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE, + sense: None, + } +} + +const TUR: [u8; 6] = [0, 0, 0, 0, 0, 0]; +const MS: fn(u64) -> Duration = Duration::from_millis; + +fn is_read(c: &[u8]) -> bool { + c[0] == crate::scsi::SCSI_READ_10 +} + +// T6 scaled, keeping production's poll:window ratio (500 ms : 60 s = 1 : 120) so the +// old 60-poll cap (~30 s, half the window) shows up; polls never exceed 10 ms. +fn timing(window: Duration, dead_bus: Duration) -> WaitReadyTiming { + WaitReadyTiming { + poll: (window / 120).min(MS(10)), + window, + dead_bus, + } +} + +/// LD1: a cancelled op issues no CDB at all. +#[test] +fn exec_precheck_refuses_after_cancel() { + let h = Halt::new(); + let (t, fake) = FakeTransport::new(); + let mut d = Drive::from_transport_with(Box::new(t.watch(&h)), &h); + h.cancel(); + let r = d.exec(&TUR, DataDirection::None, &mut [], 5_000); + assert!(matches!(r, Err(Error::Halted)), "{r:?}"); + assert!(fake.log().is_empty(), "zero CDBs reach the transport"); +} + +/// LD2: every Drive CDB goes through `exec`: after a cancel none of these issue one. +#[test] +fn every_drive_cdb_goes_through_exec() { + let h = Halt::new(); + let (t, fake) = FakeTransport::new(); + let mut d = Drive::from_transport_with(Box::new(t.watch(&h)), &h); + h.cancel(); + let identify = crate::identity::DriveId::identify(&mut |c, dir, b, t| d.exec(c, dir, b, t)); + assert!(matches!(identify, Err(Error::Halted)), "inquiry"); + assert!(matches!( + crate::disc::Disc::identify(&mut d), + Err(Error::Halted) + )); + d.set_speed(0xFFFF); + assert!(matches!(d.read_capacity(), Err(Error::Halted))); + d.lock_tray(); + assert!(matches!(d.spin_cycle(), Err(Error::Halted))); + let _ = d.drive_status(); + assert!(d.get_config_feature(0x010C).is_none()); + assert!(d.mode_sense_page(0x2A).is_none()); + assert!(d.read_buffer(2, 0xF1, 16).is_none()); + assert!(d.report_key_rpc_state().is_none()); + assert!(!d.enable_recovered_error_reporting()); + assert!(matches!(d.eject(), Err(Error::Halted))); + assert!(matches!( + d.scsi_execute(&TUR, DataDirection::None, &mut [], 5_000), + Err(Error::Halted) + )); + assert!(d.init().is_err()); + assert!(fake.log().is_empty(), "{:02x?}", fake.cdbs()); +} + +/// LD2 (structural): no raw transport call outside `Drive::dispatch` in drive/mod.rs, +/// and none in identity.rs outside `DriveId::from_drive`. +#[test] +fn no_raw_execute_outside_dispatch() { + let src = include_str!("mod.rs"); + let body = &src[..src.find("#[cfg(test)]\nmod halt_tests").unwrap()]; + let raw = body.matches(".execute(").count(); + assert_eq!(raw, 1, "only `dispatch` calls the transport's execute"); + let id = include_str!("../identity.rs"); + let id = &id[..id.find("#[cfg(test)]").unwrap()]; + assert_eq!( + id.matches("transport.execute(").count(), + 1, + "from_drive only" + ); +} + +/// LD3: a READ that completes after a cancel is discarded: `Halted`, not good data. +#[test] +fn read_postcheck_discards_after_cancel() { + let h = Halt::new(); + let (t, fake) = FakeTransport::new(); + let t = t.rule(is_read, FakeMode::Stall).scale(1); + let mut d = Drive::from_transport_with(Box::new(t), &h); + let f2 = fake.clone(); + let h2 = h.clone(); + let stopper = std::thread::spawn(move || { + assert!(f2.wait_for(1, is_read, Duration::from_secs(5))); + h2.cancel(); + f2.release(); + }); + let mut buf = vec![0u8; 2048]; + let r = d.read(0, 1, &mut buf, false); + stopper.join().unwrap(); + assert!(matches!(r, Err(Error::Halted)), "{r:?}"); + assert_eq!( + fake.count(is_read), + 1, + "the in-flight READ ran to completion" + ); +} + +/// LD4 (Drive half): `exec_cleanup` admits the ALLOW for a tray this Drive locked +/// and refuses anything else after a cancel. +#[test] +fn exec_cleanup_follows_the_drive_ledger() { + let allow = [0x1E, 0, 0, 0, 0, 0]; + let prevent = [0x1E, 0, 0, 0, 1, 0]; + let h = Halt::new(); + let (t, fake) = FakeTransport::new(); + let mut d = Drive::from_transport_with(Box::new(t.watch(&h)), &h); + h.cancel(); + let r = d.exec_cleanup( + &allow, + DataDirection::None, + &mut [], + 5_000, + CleanupCtx::Plain, + ); + assert!(matches!(r, Err(Error::Halted)), "never locked: refused"); + assert!(fake.log().is_empty()); + + let h = Halt::new(); + let (t, fake) = FakeTransport::new(); + let mut d = Drive::from_transport_with(Box::new(t.watch(&h)), &h); + d.lock_tray(); + h.cancel(); + let r = d.exec_cleanup( + &prevent, + DataDirection::None, + &mut [], + 5_000, + CleanupCtx::Plain, + ); + assert!(matches!(r, Err(Error::Halted)), "never a PREVENT"); + let r = d.exec_cleanup( + &allow, + DataDirection::None, + &mut [], + 5_000, + CleanupCtx::Plain, + ); + assert!(r.is_ok(), "locked: the ALLOW is sent"); + assert_eq!(fake.cdbs(), vec![prevent.to_vec(), allow.to_vec()]); +} + +/// LD5 (per evidence SS-7): a successful REPORT KEY format 0 sets bit `resp[7] >> 6`; +/// format 3Fh clears bit `cdb[10] >> 6`; a failed format 0 sets nothing. +#[test] +fn agid_ledger_set_and_clear() { + assert!( + crate::spec::stop::SS_7_AGID_INVALIDATE + .text + .contains("*agid = (buf[7] & 0xff) >> 6;") + ); + let fail = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let f2 = fail.clone(); + let t = Script::new(move |c, d| { + if c[0] == 0xA4 && c[10] & 0x3F == 0 { + if f2.load(std::sync::atomic::Ordering::Relaxed) { + return Err(not_ready(0x04, 0x01)); + } + d[7] = 2 << 6; + } + good(d) + }); + let mut d = Drive::from_transport(Box::new(t)); + let mut alloc = [0u8; 12]; + alloc[0] = 0xA4; + alloc[7] = 0x02; + let mut buf = [0u8; 8]; + d.exec(&alloc, DataDirection::FromDevice, &mut buf, 5_000) + .unwrap(); + assert_eq!(d.ledger().agids, 1 << 2); + let mut release = alloc; + release[10] = (2 << 6) | 0x3F; + d.exec(&release, DataDirection::FromDevice, &mut buf, 5_000) + .unwrap(); + assert_eq!(d.ledger().agids, 0); + fail.store(true, std::sync::atomic::Ordering::Relaxed); + let mut buf = [0u8; 8]; + assert!( + d.exec(&alloc, DataDirection::FromDevice, &mut buf, 5_000) + .is_err() + ); + assert_eq!(d.ledger().agids, 0, "a failed allocation holds nothing"); +} + +/// LD6: `lock_tray` after a cancel sends no PREVENT and leaves the tray unlocked. +#[test] +fn lock_tray_after_cancel_sends_nothing() { + let h = Halt::new(); + let (t, fake) = FakeTransport::new(); + let mut d = Drive::from_transport_with(Box::new(t.watch(&h)), &h); + h.cancel(); + d.lock_tray(); + assert!(!d.ledger().tray_locked); + drop(d); + assert!(fake.log().is_empty(), "no PREVENT, and so no ALLOW on drop"); +} + +/// LD7 (GUARD): a locked Drive dropped after a cancel issues exactly one ALLOW. +#[test] +fn drop_after_cancel_releases_prevent() { + let h = Halt::new(); + let (t, fake) = FakeTransport::new(); + let mut d = Drive::from_transport_with(Box::new(t.watch(&h)), &h); + d.lock_tray(); + assert!(fake.tray_locked()); + h.cancel(); + drop(d); + let after: Vec<_> = fake.log().into_iter().filter(|c| c.after_cancel).collect(); + assert_eq!(after.len(), 1, "{after:02x?}"); + assert_eq!(after[0].cdb, vec![0x1E, 0, 0, 0, 0, 0]); + assert!(!fake.tray_locked()); + assert_eq!(fake.live_handles(), 0); +} + +/// LD8: `open_with` shares the token; attach/detach; a detached `exec` is a debug +/// panic; the own token comes back after the `ScanOptions.halt` alias, unwind included. +#[test] +fn slot_open_with_attach_detach() { + let h = Halt::new(); + let (t, _fake) = FakeTransport::new(); + let mut d = Drive::from_transport_with(Box::new(t), &h); + assert!(Arc::ptr_eq(d.token().unwrap().as_arc(), h.as_arc())); + h.cancel(); + assert!(matches!( + d.exec(&TUR, DataDirection::None, &mut [], 5_000), + Err(Error::Halted) + )); + let h2 = Halt::new(); + d.attach(&h2); + assert!(d.exec(&TUR, DataDirection::None, &mut [], 5_000).is_ok()); + let back = d.detach().expect("attached"); + assert!(Arc::ptr_eq(back.as_arc(), h2.as_arc())); + assert!(d.token().is_none()); + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ = d.exec(&TUR, DataDirection::None, &mut [], 5_000); + })); + assert_eq!( + r.is_err(), + cfg!(debug_assertions), + "a detached exec panics in debug" + ); + + let (t, _fake) = FakeTransport::new(); + let mut d = Drive::from_transport(Box::new(t)); + let own = d.token().unwrap().clone(); + let alias = Halt::new(); + { + let g = d.alias(Some(&alias)); + assert!(Arc::ptr_eq(g.token().unwrap().as_arc(), alias.as_arc())); + } + assert!( + Arc::ptr_eq(d.token().unwrap().as_arc(), own.as_arc()), + "restored" + ); + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _g = d.alias(Some(&alias)); + panic!("unwind through the alias"); + })); + assert!(r.is_err()); + assert!( + Arc::ptr_eq(d.token().unwrap().as_arc(), own.as_arc()), + "restored on unwind" + ); +} + +/// LD9: over a caller-attached token the alias does not replace it, and is logged. +#[test] +fn foreign_attached_token_wins_over_alias() { + let h = Halt::new(); + let (t, _fake) = FakeTransport::new(); + let mut d = Drive::from_transport_with(Box::new(t), &h); + let other = Halt::new(); + let (same, events) = crate::testlog::capture(|| { + let g = d.alias(Some(&other)); + Arc::ptr_eq(g.token().unwrap().as_arc(), h.as_arc()) + }); + assert!(same, "the attached token wins"); + assert!( + events + .iter() + .any(|e| e.level == tracing::Level::WARN && e.field("phase") == Some("halt_alias")), + "{events:?}" + ); + let (_, quiet) = crate::testlog::capture(|| drop(d.alias(Some(&h)))); + assert!( + quiet.iter().all(|e| e.field("phase") != Some("halt_alias")), + "same token: no warn" + ); +} + +// A drive whose TUR answers 04/01 (`ascq`) until `start` START UNITs, then GOOD. +fn becoming_ready(ascq: u8) -> Script { + Script::new(move |c, d| match c[0] { + SCSI_TEST_UNIT_READY => Err(not_ready(0x04, ascq)), + _ => good(d), + }) +} + +/// LD10 (GUARD): a Stop during an in-flight TUR, or START UNIT, returns `Halted` +/// within 1 s of that CDB completing. +#[test] +fn wait_ready_stop_during_poll_and_start_unit() { + for (stalled, ascq) in [(SCSI_TEST_UNIT_READY, 0x01u8), (SCSI_START_STOP_UNIT, 0x02)] { + let h = Halt::new(); + let (t, fake) = FakeTransport::new(); + let t = t + .with_inner(Box::new(becoming_ready(ascq))) + .rule_n(move |c| c[0] == stalled, FakeMode::Stall, 1) + .scale(1); + let mut d = Drive::from_transport_with(Box::new(t), &h); + let (f2, h2) = (fake.clone(), h.clone()); + let stopper = std::thread::spawn(move || { + assert!(f2.wait_for(1, |c| c[0] == stalled, Duration::from_secs(5))); + h2.cancel(); + std::thread::sleep(MS(100)); + f2.release(); + Instant::now() + }); + let r = d.wait_ready_with(timing(Duration::from_secs(60), MS(5_000))); + let released = stopper.join().unwrap(); + assert!(matches!(r, Err(Error::Halted)), "{stalled:#x}: {r:?}"); + assert!(released.elapsed() < Duration::from_secs(1), "{stalled:#x}"); + } +} + +/// LD11a (GUARD, T5): transport failures broken by a drive answer every 0.5 × the +/// dead-bus budget, for 4 budgets, never count as a dead bus. +#[test] +fn dead_bus_budget_resets_on_any_answer() { + let budget = MS(100); + let t0 = Instant::now(); + let mut last = Instant::now(); + let t = Script::new(move |_, d| { + if t0.elapsed() >= budget * 4 { + return good(d); + } + if last.elapsed() >= budget / 2 { + last = Instant::now(); + return Err(not_ready(0x04, 0x01)); + } + Err(fault()) + }); + let mut d = Drive::from_transport(Box::new(t)); + let r = d.wait_ready_with(timing(Duration::from_secs(60), budget)); + assert!(r.is_ok(), "{r:?}"); +} + +/// LD11b / G5 (GUARD): an unbroken run of transport failures for the budget is a +/// dead bus, surfaced as the transport failure. +#[test] +fn dead_bus_unbroken_run_fails() { + let t = Script::new(|_, _| Err(fault())); + let mut d = Drive::from_transport(Box::new(t)); + let t0 = Instant::now(); + let r = d.wait_ready_with(timing(Duration::from_secs(60), MS(100))); + assert!( + matches!(&r, Err(e) if e.is_scsi_transport_failure()), + "{r:?}" + ); + assert!(t0.elapsed() < Duration::from_secs(1)); +} + +/// LD12a: 04/01 with a progress indication rising every 0.5 × window for 4 windows, +/// then ready → `Ok`. Corroborated by SS-1: the field is "a percent complete indication". +#[test] +fn wait_ready_rides_out_progressing_spin_up() { + let window = MS(200); + let t0 = Instant::now(); + let (t, cell) = Script::with_progress(move |_, d| { + if t0.elapsed() >= window * 4 { + return good(d); + } + Err(not_ready(0x04, 0x01)) + }); + let t0b = Instant::now(); + let mover = std::thread::spawn(move || { + while t0b.elapsed() < window * 4 { + let step = (t0b.elapsed().as_millis() / (window.as_millis() / 2)) as u16; + *cell.lock().unwrap() = Some(1000 * (step + 1)); + std::thread::sleep(MS(5)); + } + }); + let mut d = Drive::from_transport(Box::new(t)); + let r = d.wait_ready_with(timing(window, MS(5_000))); + mover.join().unwrap(); + assert!(r.is_ok(), "a progressing spin-up is not a stall: {r:?}"); + assert!(t0.elapsed() >= window * 2, "ran past two windows"); +} + +/// LD12b: the same 04/01 with no (or stuck) progress gives up at the window, not before. +#[test] +fn wait_ready_gives_up_after_60s_without_progress() { + for stuck in [None, Some(4096u16)] { + let window = MS(300); + let (t, cell) = Script::with_progress(|_, _| Err(not_ready(0x04, 0x01))); + *cell.lock().unwrap() = stuck; + let mut d = Drive::from_transport(Box::new(t)); + let t0 = Instant::now(); + let r = d.wait_ready_with(timing(window, MS(5_000))); + let took = t0.elapsed(); + assert!(matches!(r, Err(Error::DeviceNotReady { .. })), "{r:?}"); + assert!(took >= window, "not before the window: {took:?}"); + assert!( + took < window + Duration::from_secs(1), + "at the window: {took:?}" + ); + } +} + +/// LD12c: a not-yet-seen answer is progress; two known answers alternating is not. +/// (The stricter reading of "the answer changes", §2.11; the user may overrule it.) +#[test] +fn wait_ready_new_answer_is_progress_flapping_is_not() { + let window = MS(200); + let t0 = Instant::now(); + let fresh = [0x00u8, 0x01, 0x04, 0x07, 0x08, 0x09, 0x0A, 0x11, 0x22]; + let t = Script::new(move |_, d| { + let step = (t0.elapsed().as_millis() / (window.as_millis() / 2)) as usize; + match fresh.get(step) { + Some(&q) if step < 8 => Err(not_ready(0x04, q)), + _ => good(d), + } + }); + let mut d = Drive::from_transport(Box::new(t)); + let r = d.wait_ready_with(timing(window, MS(5_000))); + assert!(r.is_ok(), "new answers re-arm the window: {r:?}"); + + let mut n = 0u32; + let t = Script::new(move |_, _| { + n += 1; + Err(not_ready( + 0x04, + if n.is_multiple_of(2) { 0x00 } else { 0x01 }, + )) + }); + let mut d = Drive::from_transport(Box::new(t)); + let t0 = Instant::now(); + let r = d.wait_ready_with(timing(window, MS(5_000))); + assert!(matches!(r, Err(Error::DeviceNotReady { .. })), "{r:?}"); + assert!(t0.elapsed() < window + Duration::from_secs(1)); +} + +/// LD12d (GUARD): a Stop at 0.75 × window, and mid-progress, ends the wait at once. +#[test] +fn wait_ready_stop_always_interrupts() { + let window = MS(400); + for progressing in [false, true] { + let h = Halt::new(); + let mut q = 0u8; + let t = Script::new(move |_, _| { + if progressing { + q = q.wrapping_add(1) % 0x30; + } + Err(not_ready(0x04, if q == 2 { 0x03 } else { q })) + }); + let mut d = Drive::from_transport_with(Box::new(t), &h); + let h2 = h.clone(); + let stopper = std::thread::spawn(move || { + std::thread::sleep(window * 3 / 4); + h2.cancel(); + Instant::now() + }); + let r = d.wait_ready_with(timing(window, MS(5_000))); + let at = stopper.join().unwrap(); + assert!(matches!(r, Err(Error::Halted)), "{r:?}"); + assert!(at.elapsed() < Duration::from_secs(1)); + } +} + +/// G4 (GUARD): 10 × 3A with no 04/01 is the empty drive; 30/xx fails at once; 04/02 +/// sends exactly one START UNIT. Per spec SS-3 MMC-6 Table F.3. +#[test] +fn wait_ready_terminal_answers_unchanged() { + let f3 = crate::spec::stop::SS_3_READINESS_ERRORS.text; + assert!(f3.contains("2 3A 00 MEDIUM NOT PRESENT")); + assert!(f3.contains("2 30 00 INCOMPATIBLE MEDIUM INSTALLED")); + assert!(f3.contains("2 04 02 LOGICAL UNIT NOT READY, INITIALIZING CMD. REQUIRED")); + let slow = timing(Duration::from_secs(60), MS(5_000)); + let polls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let p2 = polls.clone(); + let t = Script::new(move |_, _| { + p2.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + Err(not_ready(0x3A, 0x00)) + }); + let r = Drive::from_transport(Box::new(t)).wait_ready_with(slow); + assert!( + matches!(&r, Err(e) if e.scsi_sense().is_some_and(|s| s.asc == 0x3A)), + "{r:?}" + ); + assert_eq!(polls.load(std::sync::atomic::Ordering::Relaxed), 10); + + let t = Script::new(|_, _| Err(not_ready(0x30, 0x00))); + let r = Drive::from_transport(Box::new(t)).wait_ready_with(slow); + assert!( + matches!(&r, Err(e) if e.scsi_sense().is_some_and(|s| s.asc == 0x30)), + "{r:?}" + ); + + let (t, fake) = FakeTransport::new(); + let mut tur = 0; + let t = t.with_inner(Box::new(Script::new(move |c, d| match c[0] { + SCSI_TEST_UNIT_READY if tur < 3 => { + tur += 1; + Err(not_ready(0x04, 0x02)) + } + _ => good(d), + }))); + assert!( + Drive::from_transport(Box::new(t)) + .wait_ready_with(slow) + .is_ok() + ); + assert_eq!(fake.count(|c| c[0] == SCSI_START_STOP_UNIT), 1); +} + +/// LD13: a Stop during the drive-prep unlock is `Halted`, not the unlock's dead bus. +#[test] +fn drive_init_stop_is_halted_not_unlock_error() { + let h = Halt::new(); + let (t, fake) = FakeTransport::new(); + // Cancel on the first unlock CDB (the freemkv IDENTITY knock); every later one is + // refused, so the unlockers see a dead bus. + let t = t.cancel_on(|_| true, &h).watch(&h); + let mut d = Drive::from_transport_with(Box::new(t), &h); + let r = d.init(); + assert!(matches!(r, Err(Error::Halted)), "{r:?}"); + assert_eq!(fake.log().len(), 1, "nothing after the cancel"); +} + +/// LD16a (GUARD, T1): 200 READs each at 0.8 × the fast timeout all succeed, however +/// long they take together. +#[test] +fn slow_reads_within_cdb_timeout_never_fail_op() { + let (t, fake) = FakeTransport::new(); + // Scale 1000: the 10 s fast READ times out at 10 ms; each takes 8 ms. + let t = t + .with_image(vec![0u8; 2048 * 4]) + .rule(is_read, FakeMode::Complete(MS(8))); + let mut d = Drive::from_transport(Box::new(t)); + let mut buf = vec![0u8; 2048]; + for _ in 0..200 { + d.read(1, 1, &mut buf, false) + .expect("a slow READ inside its timeout is good"); + } + assert_eq!(fake.count(is_read), 200); +} + +/// LD16b (GUARD, T1): a READ stalled past its timeout fails that CDB only; the next +/// READ is still issued. +#[test] +fn stalled_read_fails_only_that_cdb() { + let (t, fake) = FakeTransport::new(); + let t = t + .with_image(vec![0u8; 2048 * 4]) + .rule_n(is_read, FakeMode::Stall, 1); + let mut d = Drive::from_transport(Box::new(t)); + let mut buf = vec![0u8; 2048]; + assert!(matches!( + d.read(1, 1, &mut buf, false), + Err(Error::DiscRead { .. }) + )); + assert!(d.read(2, 1, &mut buf, false).is_ok()); + assert_eq!(fake.count(is_read), 2); +} + +/// G21 (GUARD): `ScsiSense` keeps exactly its three public fields (consumers build it +/// with a struct literal); the progress indication rides a side channel instead. +#[test] +fn scsi_sense_keeps_three_fields() { + let s = ScsiSense { + sense_key: 2, + asc: 4, + ascq: 1, + }; + let ScsiSense { + sense_key, + asc, + ascq, + } = s; + assert_eq!((sense_key, asc, ascq), (2, 4, 1)); +} + +/// §2.2 T29 feed: `attach_progress` bumps on every CDB completion and is busy while +/// one is in flight. +#[test] +fn attached_progress_bumps_per_cdb_and_is_busy_in_flight() { + let p = crate::halt::Progress::new(); + let p2 = p.clone(); + let t = Script::new(move |_, d| { + assert!(p2.is_busy(), "busy mid-CDB"); + good(d) + }); + let mut d = Drive::from_transport(Box::new(t)); + d.attach_progress(&p); + d.exec(&TUR, DataDirection::None, &mut [], 5_000).unwrap(); + d.exec(&TUR, DataDirection::None, &mut [], 5_000).unwrap(); + assert_eq!(p.get(), 2); +} + +/// SP5: per spec SS-3 (MMC-6 Table F.3), each named readiness answer maps to its +/// `wait_ready` action: 04/01 keep polling, 04/02 one START UNIT, 30/00 fail at once, +/// 3A/00 count toward the empty drive. +#[test] +fn asc_ascq_classification_table() { + #[derive(Debug, PartialEq)] + enum Action { + Continue, + StartUnitOnce, + FailFast, + EmptyCount, + } + let f3 = crate::spec::stop::SS_3_READINESS_ERRORS.text; + let table = [ + ( + "2 04 01 LOGICAL UNIT IS IN PROCESS OF BECOMING READY", + 0x04, + 0x01, + Action::Continue, + ), + ( + "2 04 02 LOGICAL UNIT NOT READY, INITIALIZING CMD. REQUIRED", + 0x04, + 0x02, + Action::StartUnitOnce, + ), + ( + "2 30 00 INCOMPATIBLE MEDIUM INSTALLED", + 0x30, + 0x00, + Action::FailFast, + ), + ("2 3A 00 MEDIUM NOT PRESENT", 0x3A, 0x00, Action::EmptyCount), + ]; + for (row, asc, ascq, want) in table { + assert!(f3.contains(row), "SS-3 quotes {row:?}"); + // Answer (asc, ascq) for 12 polls, then GOOD. + let (t, fake) = FakeTransport::new(); + let mut n = 0; + let t = t.with_inner(Box::new(Script::new(move |c, d| match c[0] { + SCSI_TEST_UNIT_READY if n < 12 => { + n += 1; + Err(not_ready(asc, ascq)) + } + _ => good(d), + }))); + let r = Drive::from_transport(Box::new(t)) + .wait_ready_with(timing(Duration::from_secs(60), MS(5_000))); + let turs = fake.count(|c| c[0] == SCSI_TEST_UNIT_READY); + let starts = fake.count(|c| c[0] == SCSI_START_STOP_UNIT); + let got = match (&r, turs, starts) { + (Ok(()), 13, 0) => Action::Continue, + (Ok(()), 13, 1) => Action::StartUnitOnce, + (Err(_), 1, 0) => Action::FailFast, + (Err(_), 10, 0) => Action::EmptyCount, + other => panic!("{row}: unexpected {other:?}"), + }; + assert_eq!(got, want, "{row}"); + } +} diff --git a/src/error.rs b/src/error.rs index d83744ab..f1e81f8e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -300,6 +300,9 @@ pub const E_EMPTY_IMAGE: u16 = 9060; /// "artifact_lock", "verify"). "An expired `StallTimer` produces /// `Error::TimedOut { op }`" (stop-design-v5 §2.1). pub const E_TIMED_OUT: u16 = 9073; +/// `mpg://` output but the title has no video track a program stream can carry +/// (MPEG-1/2, H.264, HEVC or VC-1). The `mpg://` twin of [`E_MP4_NO_VIDEO_TRACK`]. +pub const E_MPG_NO_VIDEO_TRACK: u16 = 9074; // ── Error enum ────────────────────────────────────────────────────────────── @@ -913,6 +916,9 @@ pub enum Error { TimedOut { op: &'static str, }, + /// `mpg://` target title has no video track a program stream can carry. See + /// [`E_MPG_NO_VIDEO_TRACK`]. Declared ahead of the `mpg://` sink that raises it. + MpgNoVideoTrack, } impl Error { @@ -1053,6 +1059,7 @@ impl Error { Error::StreamClosed => E_STREAM_CLOSED, Error::StreamHeaderWritten => E_STREAM_HEADER_WRITTEN, Error::TimedOut { .. } => E_TIMED_OUT, + Error::MpgNoVideoTrack => E_MPG_NO_VIDEO_TRACK, Error::DirImageFileChanged { .. } => E_DIR_IMAGE_FILE_CHANGED, Error::DirImageTooLarge => E_DIR_IMAGE_TOO_LARGE, } @@ -1330,6 +1337,7 @@ impl From for std::io::Error { // (E_MP4_INVALID), or a source whose tracks the mux can't use — no // video track / missing codec-private config. All are invalid data. E_MP4_NO_VIDEO_TRACK + | E_MPG_NO_VIDEO_TRACK | E_MP4_INVALID | E_MP4_MISSING_CODEC_PRIVATE | E_MP4_UNKNOWN_RESOLUTION => std::io::ErrorKind::InvalidData, @@ -1667,6 +1675,7 @@ mod tests { Error::WholeDiscKeyMissing.code(), Error::AacsVidNeedsDisc.code(), Error::TimedOut { op: "verify" }.code(), + Error::MpgNoVideoTrack.code(), Error::ShortImageRead { lba: 0, expected: 1, @@ -1757,6 +1766,7 @@ mod tests { (Error::WholeDiscKeyMissing, E_WHOLE_DISC_KEY_MISSING), (Error::AacsVidNeedsDisc, E_AACS_VID_NEEDS_DISC), (Error::TimedOut { op: "verify" }, E_TIMED_OUT), + (Error::MpgNoVideoTrack, E_MPG_NO_VIDEO_TRACK), ( Error::BusStreamUnmapped { files: "/BDMV/STREAM/00002.m2ts".into(), @@ -2127,6 +2137,7 @@ mod tests { (Error::StreamClosed, E_STREAM_CLOSED), (Error::StreamHeaderWritten, E_STREAM_HEADER_WRITTEN), (Error::Mp4NoVideoTrack, E_MP4_NO_VIDEO_TRACK), + (Error::MpgNoVideoTrack, E_MPG_NO_VIDEO_TRACK), (Error::Mp4Invalid, E_MP4_INVALID), (Error::Mp4MissingCodecPrivate, E_MP4_MISSING_CODEC_PRIVATE), (Error::Mp4UnknownResolution, E_MP4_UNKNOWN_RESOLUTION), @@ -2224,6 +2235,25 @@ mod tests { assert!(declared_error_codes().contains(&("E_TIMED_OUT", E_TIMED_OUT))); } + /// MPG-L0: E9074's code, Display and `ErrorKind` (the E9048 bucket). "No carriable + /// video → `MpgNoVideoTrack` (E9074), as `mp4://` does with E9048" (mpg design v5 §0). + /// Declared only; `mpg://` (L2) raises it. Per spec; do not change without a design citation. + #[test] + fn mpg_no_video_track_code_display_kind() { + use std::io::ErrorKind; + let e = Error::MpgNoVideoTrack; + assert_eq!(e.code(), 9074); + assert_eq!(e.code(), E_MPG_NO_VIDEO_TRACK); + assert_eq!(e.to_string(), "E9074"); + let io: std::io::Error = e.into(); + assert_eq!(io.kind(), ErrorKind::InvalidData); + assert_eq!( + io.kind(), + std::io::Error::from(Error::Mp4NoVideoTrack).kind() + ); + assert!(declared_error_codes().contains(&("E_MPG_NO_VIDEO_TRACK", E_MPG_NO_VIDEO_TRACK))); + } + /// LT8 companion (KU v3.4 J11): E7034's code, Display and its `ErrorKind` /// (7xxx maps to `PermissionDenied`, same bucket as the sibling AACS codes). #[test] diff --git a/src/halt.rs b/src/halt.rs index 5a92ac4d..cd1e375b 100644 --- a/src/halt.rs +++ b/src/halt.rs @@ -1,22 +1,69 @@ -//! One-bit cooperative cancellation flag. +//! Cooperative cancellation and forward-progress primitives (stop design §2.1). //! -//! `Halt` is a clonable token wrapping `Arc`. Pass clones into -//! every long-running loop; the loop polls `is_cancelled()` and bails out -//! cleanly. Calling `cancel()` from any clone flips the shared flag, and -//! every other clone observes it on its next poll. +//! [`Halt`] is a clonable one-bit token over `Arc`: `cancel()` on any +//! clone is observed by every other clone. Every wait on an op path goes through a +//! halt-aware primitive ([`Halt::wait`], [`Halt::recv_timeout`], [`Halt::send_timeout`], +//! [`join_within`]) that re-checks the token at least every [`WAIT_SLICE`]. +//! +//! Timeouts are stall-only (HR1): [`StallTimer`] fires only when a [`Progress`] +//! counter has not moved for its window, never on total elapsed time. Every +//! duration is a parameter at its use site, so tests run in milliseconds. use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use crate::error::{Error, Result}; + +/// The longest a halt-aware wait sleeps between token checks (§2.1). +pub const WAIT_SLICE: Duration = Duration::from_millis(20); + +/// Alias of [`WAIT_SLICE`], kept until ST-X1b removes it. +pub const POLL_INTERVAL: Duration = WAIT_SLICE; + +// The one place the token's orderings live, shared with the loom model (LT3). +trait AtomicFlag { + fn store_flag(&self, v: bool, o: Ordering); + fn load_flag(&self, o: Ordering) -> bool; +} + +impl AtomicFlag for AtomicBool { + fn store_flag(&self, v: bool, o: Ordering) { + self.store(v, o) + } + fn load_flag(&self, o: Ordering) -> bool { + self.load(o) + } +} + +#[cfg(all(loom, test))] +impl AtomicFlag for loom::sync::atomic::AtomicBool { + fn store_flag(&self, v: bool, o: Ordering) { + self.store(v, o) + } + fn load_flag(&self, o: Ordering) -> bool { + self.load(o) + } +} + +// SS-23 Ordering::Release: "all previous writes become visible to all threads that perform +// an Acquire (or stronger) load of this value" — cancel publishes everything before it. +fn raise(flag: &F) { + flag.store_flag(true, Ordering::Release) +} + +// SS-23 Ordering::Acquire: "all subsequent loads will see data written before the store". +fn observed(flag: &F) -> bool { + flag.load_flag(Ordering::Acquire) +} /// Clonable, infallible cooperative-cancellation token. /// -/// Clones share the same underlying flag. `cancel()` is one-way; there is -/// no `reset()` by design — construct a fresh `Halt` for a fresh -/// operation. -/// -/// Construct with [`Halt::new`] (or [`Halt::default`]); both produce a -/// fresh, uncancelled token. `Default` forwards to `new()` — the pair -/// exists because clippy's `new_without_default` lint requires it. +/// Clones share one flag. `cancel()` is one-way; there is no `reset()` by design — +/// construct a fresh `Halt` for a fresh operation. `cancel` stores with `Release` +/// and every load uses `Acquire` (SS-23), so writes made before a cancel are +/// visible to whoever observes it. #[derive(Clone, Debug)] pub struct Halt(Arc); @@ -26,31 +73,97 @@ impl Halt { Self(Arc::new(AtomicBool::new(false))) } - /// Wrap an existing `Arc` as a `Halt`. A bridge for - /// callers that already hold an `Arc` cancellation flag - /// and want to adopt the token API without allocating a new flag. - /// - /// Cancelling either side flips the same bit — the wrapping `Halt` - /// and the original `Arc` are two views over one shared flag. + /// Wrap an existing `Arc` as a `Halt`: both are views over one + /// flag, so cancelling either side flips the same bit. pub fn from_arc(flag: Arc) -> Self { Self(flag) } - /// Borrow the underlying `Arc`. The inverse of - /// [`from_arc`](Self::from_arc): hand the shared flag to an API that - /// still takes a raw `Arc` rather than a `Halt`. + /// Borrow the underlying `Arc`, the inverse of + /// [`from_arc`](Self::from_arc). pub fn as_arc(&self) -> &Arc { &self.0 } /// Flip the shared flag to cancelled. Idempotent. pub fn cancel(&self) { - self.0.store(true, Ordering::Relaxed); + raise(&*self.0) } /// Read the shared flag. pub fn is_cancelled(&self) -> bool { - self.0.load(Ordering::Relaxed) + observed(&*self.0) + } + + /// `Err(Error::Halted)` once cancelled, else `Ok(())`. + pub fn check(&self) -> Result<()> { + if self.is_cancelled() { + Err(Error::Halted) + } else { + Ok(()) + } + } + + /// Sleep for `d` in [`WAIT_SLICE`] slices: `Ok(())` once `d` has passed, + /// `Err(Error::Halted)` within one slice of a cancel. `Duration::MAX` waits + /// until cancelled. + pub fn wait(&self, d: Duration) -> Result<()> { + diag::assert_may_block("Halt::wait (sleep)", d); + let end = Instant::now().checked_add(d); + loop { + self.check()?; + let Some(left) = remaining(end) else { + return Ok(()); + }; + thread::sleep(left); + } + } + + /// Receive from `rx` for up to `d`, checking the token every [`WAIT_SLICE`]. + /// + /// `Recv::Item` on receipt; `Recv::TimedOut` once `d` passes; `Recv::Disconnected` + /// at once when the sending side is gone, so a caller looping on it cannot spin; + /// `Err(Error::Halted)` on cancel, checked before each slice. At least one + /// attempt is always made, so a zero `d` still takes a queued item. + pub fn recv_timeout(&self, rx: &C, d: Duration) -> Result> { + let end = Instant::now().checked_add(d); + loop { + self.check()?; + let slice = remaining(end).unwrap_or(Duration::ZERO); + match rx.recv_slice(slice) { + Recv::TimedOut if remaining(end).is_some() => {} + r => return Ok(r), + } + } + } + + /// Send `v` on `tx` within `d`, checking the token every [`WAIT_SLICE`]. + /// + /// `SendOutcome::Sent`; `TimedOut(v)` once `d` passes; `Disconnected(v)` at once + /// when the receiver is gone, so a caller looping on it cannot spin; + /// `Err(Halted(v))` on cancel, checked before each slice (`?` turns it into + /// `Error::Halted`). The item always comes back. At least one attempt is always + /// made, so a zero `d` still sends into room. + pub fn send_timeout( + &self, + tx: &C, + v: C::Item, + d: Duration, + ) -> std::result::Result, Halted> { + let end = Instant::now().checked_add(d); + let mut pending = v; + loop { + if self.is_cancelled() { + return Err(Halted(pending)); + } + let slice = remaining(end).unwrap_or(Duration::ZERO); + match tx.send_slice(pending, slice) { + Ok(()) => return Ok(SendOutcome::Sent), + Err(SendFail::Timeout(back)) if remaining(end).is_some() => pending = back, + Err(SendFail::Timeout(back)) => return Ok(SendOutcome::TimedOut(back)), + Err(SendFail::Disconnected(back)) => return Ok(SendOutcome::Disconnected(back)), + } + } } } @@ -60,117 +173,528 @@ impl Default for Halt { } } -/// Shared poll interval for halt-aware loops. +// The next slice before `end` (`None` = unbounded), or `None` once `end` has passed. +fn remaining(end: Option) -> Option { + match end { + None => Some(WAIT_SLICE), + Some(end) => { + let left = end.saturating_duration_since(Instant::now()); + (!left.is_zero()).then(|| left.min(WAIT_SLICE)) + } + } +} + +/// How a receive ended ([`Halt::recv_timeout`], [`TimedRecv::recv_slice`]). +#[derive(Debug, PartialEq, Eq)] +pub enum Recv { + Item(T), + /// Nothing arrived within the budget. + TimedOut, + /// Every sender is gone: nothing can ever arrive. + Disconnected, +} + +/// How [`Halt::send_timeout`] ended short of a stop; unsent items come back. +/// (Not named `Send`: that would shadow the marker trait for glob importers.) +#[derive(Debug, PartialEq, Eq)] +pub enum SendOutcome { + Sent, + /// No room within the budget. + TimedOut(T), + /// The receiver is gone: the item can never be delivered. + Disconnected(T), +} + +/// A send stopped by a cancel, carrying the unsent item back. +#[derive(Debug, PartialEq, Eq)] +pub struct Halted(pub T); + +impl From> for Error { + fn from(_: Halted) -> Self { + Error::Halted + } +} + +/// A failed bounded send attempt ([`TimedSend::send_slice`]); the item comes back. +#[derive(Debug)] +pub enum SendFail { + Timeout(T), + Disconnected(T), +} + +/// A channel receiver [`Halt::recv_timeout`] can wait on in slices. +pub trait TimedRecv { + type Item; + /// Block for at most `d` waiting for one item; a zero `d` is one non-blocking try. + fn recv_slice(&self, d: Duration) -> Recv; +} + +/// A channel sender [`Halt::send_timeout`] can wait on in slices. +pub trait TimedSend { + type Item; + /// Block for at most `d` waiting for room for `v`; a zero `d` is one non-blocking try. + fn send_slice( + &self, + v: Self::Item, + d: Duration, + ) -> std::result::Result<(), SendFail>; +} + +impl TimedRecv for std::sync::mpsc::Receiver { + type Item = T; + fn recv_slice(&self, d: Duration) -> Recv { + use std::sync::mpsc::{RecvTimeoutError, TryRecvError}; + if d.is_zero() { + return match self.try_recv() { + Ok(v) => Recv::Item(v), + Err(TryRecvError::Empty) => Recv::TimedOut, + Err(TryRecvError::Disconnected) => Recv::Disconnected, + }; + } + match self.recv_timeout(d) { + Ok(v) => Recv::Item(v), + Err(RecvTimeoutError::Timeout) => Recv::TimedOut, + Err(RecvTimeoutError::Disconnected) => Recv::Disconnected, + } + } +} + +// std's `SyncSender` has no timed send: poll `try_send` via `backoff_poll`. +impl TimedSend for std::sync::mpsc::SyncSender { + type Item = T; + fn send_slice(&self, v: T, d: Duration) -> std::result::Result<(), SendFail> { + use std::sync::mpsc::TrySendError; + backoff_poll(v, d, |v| match self.try_send(v) { + Ok(()) => Ok(()), + Err(TrySendError::Full(back)) => Err(SendFail::Timeout(back)), + Err(TrySendError::Disconnected(back)) => Err(SendFail::Disconnected(back)), + }) + } +} + +// Retry `attempt` until it stops reporting `Timeout` or `d` passes, sleeping 1 ms and +// doubling up to WAIT_SLICE: a long-full channel costs ~50 wakeups/s, and freed room is +// seen within one backoff step. The first attempt is immediate. +fn backoff_poll( + v: T, + d: Duration, + mut attempt: impl FnMut(T) -> std::result::Result<(), SendFail>, +) -> std::result::Result<(), SendFail> { + let end = Instant::now() + d; + let mut pending = v; + let mut backoff = Duration::from_millis(1); + loop { + match attempt(pending) { + Err(SendFail::Timeout(back)) => pending = back, + done => return done, + } + let left = end.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Err(SendFail::Timeout(pending)); + } + thread::sleep(left.min(backoff)); + backoff = (backoff * 2).min(WAIT_SLICE); + } +} + +#[cfg(feature = "rip")] +impl TimedRecv for crossbeam_channel::Receiver { + type Item = T; + fn recv_slice(&self, d: Duration) -> Recv { + use crossbeam_channel::{RecvTimeoutError, TryRecvError}; + if d.is_zero() { + return match self.try_recv() { + Ok(v) => Recv::Item(v), + Err(TryRecvError::Empty) => Recv::TimedOut, + Err(TryRecvError::Disconnected) => Recv::Disconnected, + }; + } + match self.recv_timeout(d) { + Ok(v) => Recv::Item(v), + Err(RecvTimeoutError::Timeout) => Recv::TimedOut, + Err(RecvTimeoutError::Disconnected) => Recv::Disconnected, + } + } +} + +#[cfg(feature = "rip")] +impl TimedSend for crossbeam_channel::Sender { + type Item = T; + fn send_slice(&self, v: T, d: Duration) -> std::result::Result<(), SendFail> { + use crossbeam_channel::{SendTimeoutError, TrySendError}; + if d.is_zero() { + return match self.try_send(v) { + Ok(()) => Ok(()), + Err(TrySendError::Full(back)) => Err(SendFail::Timeout(back)), + Err(TrySendError::Disconnected(back)) => Err(SendFail::Disconnected(back)), + }; + } + match self.send_timeout(v, d) { + Ok(()) => Ok(()), + Err(SendTimeoutError::Timeout(back)) => Err(SendFail::Timeout(back)), + Err(SendTimeoutError::Disconnected(back)) => Err(SendFail::Disconnected(back)), + } + } +} + +/// Forward-progress counter (§2.1). Bumped on bytes read or written, a sector +/// advanced, an item applied, or a response received. Cheap to clone; clones share +/// the count. [`busy`](Self::busy) only marks in-flight work: it never bumps. +#[derive(Clone, Debug, Default)] +pub struct Progress(Arc); + +#[derive(Debug, Default)] +struct ProgressInner { + count: AtomicU64, + // Live `BusyGuard`s, and every guard start or end (so a poll sees a span that + // began and ended between two polls). + busy: AtomicUsize, + busy_epoch: AtomicU64, +} + +impl Progress { + pub fn new() -> Self { + Self::default() + } + + /// Record one unit of forward progress. + pub fn bump(&self) { + self.0.count.fetch_add(1, Ordering::Relaxed); + } + + /// The progress count so far. + pub fn get(&self) -> u64 { + self.0.count.load(Ordering::Relaxed) + } + + /// Mark legitimate work with no progress signal (a CDB in flight, a key-service + /// call, a keydb parse) until the guard drops. Only [`StallTimer::idle_only`] + /// reads it; a plain [`StallTimer`] ignores it (LT5c). + pub fn busy(&self) -> BusyGuard { + self.0.busy.fetch_add(1, Ordering::Relaxed); + self.0.busy_epoch.fetch_add(1, Ordering::Relaxed); + BusyGuard(self.clone()) + } + + /// Whether a [`BusyGuard`] is live (crate tests of busy spans). + #[cfg(all(test, feature = "rip"))] + pub(crate) fn is_busy(&self) -> bool { + self.busy_state().0 + } + + fn busy_state(&self) -> (bool, u64) { + let epoch = self.0.busy_epoch.load(Ordering::Relaxed); + (self.0.busy.load(Ordering::Relaxed) > 0, epoch) + } +} + +/// Holds a [`Progress`] busy until dropped. +#[must_use = "the span is busy only while the guard lives"] +#[derive(Debug)] +pub struct BusyGuard(Progress); + +impl Drop for BusyGuard { + fn drop(&mut self) { + self.0.0.busy.fetch_sub(1, Ordering::Relaxed); + self.0.0.busy_epoch.fetch_add(1, Ordering::Relaxed); + } +} + +/// What a [`StallTimer::poll`] saw. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Stall { + /// The counter moved since the last poll; the window restarts. + Progressing, + /// No progress for this long, still inside the window. + StalledFor(Duration), + /// No progress for the whole window: the guarded op has stalled (HR1). + Expired, +} + +/// The HR1 primitive: fires only when a [`Progress`] has not moved for `window`. /// -/// `bounded_syscall` checks the cancellation flag and the deadline -/// every [`POLL_INTERVAL`] while blocked on a worker; the same -/// cadence governs `Pipeline::send_with_halt`'s `try_send` retry. -pub const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(250); +/// A plain timer ([`new`](Self::new)) counts all time without progress and ignores +/// [`Progress::busy`]; every HR1 failure timer is plain. [`idle_only`](Self::idle_only) +/// is the opt-in T29 mode: time is not counted while the progress is busy. +#[derive(Debug)] +pub struct StallTimer { + window: Duration, + last: u64, + since: Instant, + idle: Option, +} + +// idle_only: idle time accrued so far, the instant it was last accrued to, and the +// busy epoch then. An interval with any busy span in it accrues nothing. +#[derive(Debug)] +struct IdleClock { + idle: Duration, + mark: Instant, + epoch: u64, +} + +impl StallTimer { + /// A plain stall timer: every HR1 failure timer is one of these. + pub fn new(window: Duration, p: &Progress) -> Self { + Self { + window, + last: p.get(), + since: Instant::now(), + idle: None, + } + } + + /// Opt-in (v5.5, ST3-5): paused while `p` is [`busy`](Progress::busy). The T29 + /// launch probe is its only user. + pub fn idle_only(window: Duration, p: &Progress) -> Self { + let mut t = Self::new(window, p); + t.idle = Some(IdleClock { + idle: Duration::ZERO, + mark: t.since, + epoch: p.busy_state().1, + }); + t + } + + /// Sample `p`. Any movement re-arms the whole window. + pub fn poll(&mut self, p: &Progress) -> Stall { + let now = Instant::now(); + let count = p.get(); + if count != self.last { + self.last = count; + self.since = now; + if let Some(c) = &mut self.idle { + c.idle = Duration::ZERO; + c.mark = now; + c.epoch = p.busy_state().1; + } + return Stall::Progressing; + } + let stalled = match &mut self.idle { + None => now.saturating_duration_since(self.since), + Some(c) => { + let (busy, epoch) = p.busy_state(); + if !busy && epoch == c.epoch { + c.idle += now.saturating_duration_since(c.mark); + } + c.mark = now; + c.epoch = epoch; + c.idle + } + }; + if stalled >= self.window { + Stall::Expired + } else { + Stall::StalledFor(stalled) + } + } +} + +/// How [`join_within`] ended. `Halted` and `Pending` hand the handle back. +#[derive(Debug)] +pub enum Joined { + /// The thread finished; its result, or its panic payload. + Done(thread::Result), + /// The token was cancelled while the thread still ran. + Halted(JoinHandle), + /// `d` passed with the thread still running. + Pending(JoinHandle), +} + +/// Wait up to `d` for `h` to finish, checking `halt` every [`WAIT_SLICE`]; never +/// blocks past `d` plus one slice. Replaces `is_finished` spins. +pub fn join_within(h: JoinHandle, d: Duration, halt: Option<&Halt>) -> Joined { + if h.is_finished() { + return Joined::Done(join_finished(h)); + } + diag::assert_may_block("join_within (join)", d); + let end = Instant::now().checked_add(d); + loop { + if h.is_finished() { + return Joined::Done(join_finished(h)); + } + if halt.is_some_and(Halt::is_cancelled) { + return Joined::Halted(h); + } + let Some(slice) = remaining(end) else { + return Joined::Pending(h); + }; + thread::sleep(slice); + } +} + +/// Join a thread that has already finished: never blocks, so it is allowed under +/// [`diag::NoBlockingScope`]. +pub fn join_finished(h: JoinHandle) -> thread::Result { + debug_assert!(h.is_finished(), "join_finished on a running thread"); + h.join() +} + +static LIVE_DRIVE_HOLDERS: AtomicUsize = AtomicUsize::new(0); + +/// Threads started by [`spawn_drive_holder`] whose body is still running. +pub fn live_drive_holders() -> usize { + LIVE_DRIVE_HOLDERS.load(Ordering::SeqCst) +} + +// Every test that spawns a Drive holder or reads the process-wide count holds this. +#[cfg(all(test, not(loom)))] +pub(crate) static DRIVE_HOLDER_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); -#[cfg(test)] -mod tests { - use super::*; +/// A thread that holds the Drive (§2.5): the op joins it before returning. In debug +/// builds, dropping it unjoined panics. +#[derive(Debug)] +pub struct DriveHolder { + role: &'static str, + handle: Option>, +} - #[test] - fn fresh_is_not_cancelled() { - let h = Halt::new(); - assert!(!h.is_cancelled()); +/// Spawn `f` on a thread named `freemkv-` as a [`DriveHolder`]. +pub fn spawn_drive_holder(role: &'static str, f: F) -> std::io::Result> +where + F: FnOnce() -> R + Send + 'static, + R: Send + 'static, +{ + struct Live; + impl Drop for Live { + fn drop(&mut self) { + LIVE_DRIVE_HOLDERS.fetch_sub(1, Ordering::SeqCst); + } + } + LIVE_DRIVE_HOLDERS.fetch_add(1, Ordering::SeqCst); + let live = Live; + let handle = thread::Builder::new() + .name(format!("freemkv-{role}")) + .spawn(move || { + let _live = live; + f() + })?; + Ok(DriveHolder { + role, + handle: Some(handle), + }) +} + +impl DriveHolder { + /// The role it was spawned with. + pub fn role(&self) -> &'static str { + self.role + } + + /// Whether the thread has finished. + pub fn is_finished(&self) -> bool { + self.handle.as_ref().is_none_or(JoinHandle::is_finished) } - #[test] - fn cancel_flips_state() { - let h = Halt::new(); - assert!(!h.is_cancelled()); - h.cancel(); - assert!(h.is_cancelled()); + /// Join the thread: its result, or its panic payload. + pub fn join(mut self) -> thread::Result { + let h = self.handle.take().expect("DriveHolder joined twice"); + if h.is_finished() { + return join_finished(h); + } + diag::assert_may_block("DriveHolder::join (join)", Duration::MAX); + h.join() } +} - #[test] - fn cancel_is_idempotent() { - let h = Halt::new(); - h.cancel(); - h.cancel(); - assert!(h.is_cancelled()); +impl Drop for DriveHolder { + fn drop(&mut self) { + if self.handle.is_some() && !thread::panicking() { + if cfg!(debug_assertions) { + panic!("drive holder `{}` dropped unjoined", self.role); + } + #[cfg(feature = "scsi")] + tracing::warn!(target: "freemkv::halt", role = self.role, "drive holder dropped unjoined"); + } } +} - #[test] - fn clone_shares_state() { - let original = Halt::new(); - let cloned = original.clone(); - assert!(!original.is_cancelled()); - assert!(!cloned.is_cancelled()); +/// Debug checks for code that must never block (a GUI frame, a Stop request). +pub mod diag { + use std::cell::Cell; + use std::marker::PhantomData; + use std::time::Duration; + + thread_local! { + static DEPTH: Cell = const { Cell::new(0) }; + } - // Cancel via the clone; the original observes it. - cloned.cancel(); - assert!(original.is_cancelled()); - assert!(cloned.is_cancelled()); + /// While one lives on this thread, a blocking `exec`, sleep ([`Halt::wait`]) or + /// join ([`join_within`], [`DriveHolder::join`]) panics in debug builds. + /// [`join_finished`] is exempt. Scopes nest; the guard is not `Send`. + /// + /// [`Halt::wait`]: super::Halt::wait + /// [`join_within`]: super::join_within + /// [`DriveHolder::join`]: super::DriveHolder::join + /// [`join_finished`]: super::join_finished + #[must_use = "the scope ends when the guard drops"] + #[derive(Debug)] + pub struct NoBlockingScope { + _not_send: PhantomData<*const ()>, } - #[test] - fn clone_shares_state_reverse_direction() { - let original = Halt::new(); - let cloned = original.clone(); + impl NoBlockingScope { + /// Enter a scope on this thread. + pub fn enter() -> Self { + DEPTH.with(|d| d.set(d.get() + 1)); + Self { + _not_send: PhantomData, + } + } - // Cancel via the original; the clone observes it. - original.cancel(); - assert!(cloned.is_cancelled()); + /// Whether this thread is inside a scope. + pub fn active() -> bool { + DEPTH.with(Cell::get) > 0 + } } - #[test] - fn clone_shares_state_across_threads() { - let h = Halt::new(); - let h2 = h.clone(); - let handle = std::thread::spawn(move || { - h2.cancel(); - }); - handle.join().unwrap(); - assert!(h.is_cancelled()); + impl Drop for NoBlockingScope { + fn drop(&mut self) { + DEPTH.with(|d| d.set(d.get() - 1)); + } } - #[test] - fn from_arc_shares_state() { - // The 0.18 deprecation-window bridge: a Halt built from an - // existing Arc must be a *view* over the same bit, - // not a fresh copy. Cancelling either side flips both. - let arc = Arc::new(AtomicBool::new(false)); - let halt = Halt::from_arc(arc.clone()); - assert!(!halt.is_cancelled()); - assert!(!arc.load(Ordering::Relaxed)); - - // Cancel via the wrapping Halt; the original Arc observes it. - halt.cancel(); - assert!(arc.load(Ordering::Relaxed)); - - // Conversely: flip the Arc directly; the Halt view observes it. - let arc2 = Arc::new(AtomicBool::new(false)); - let halt2 = Halt::from_arc(arc2.clone()); - arc2.store(true, Ordering::Relaxed); - assert!(halt2.is_cancelled()); + // Debug panic when `what` could block (`d > 0`) inside a scope. + pub(crate) fn assert_may_block(what: &str, d: Duration) { + if cfg!(debug_assertions) && !d.is_zero() && NoBlockingScope::active() { + panic!("{what} under NoBlockingScope"); + } } +} +#[cfg(all(test, not(loom)))] +mod tests; + +/// LT3: the token's orderings, model-checked. Run with +/// `RUSTFLAGS="--cfg loom" cargo test --lib --no-default-features --features scsi loom_halt`. +#[cfg(all(test, loom))] +mod loom_tests { + use super::{observed, raise}; + use loom::cell::UnsafeCell; + use loom::sync::Arc; + use loom::sync::atomic::AtomicBool; + + /// per spec; do not change without a spec citation proving otherwise — SS-23: + /// Release "all previous writes become visible to all threads that perform an + /// Acquire (or stronger) load of this value". A write before `cancel` is visible + /// to every thread that observes the cancel. #[test] - fn as_arc_returns_backing_flag() { - // `as_arc()` must hand back the *same* Arc, not a clone of a - // different bit. Verified by writing through the borrowed Arc - // and observing through the Halt. - let halt = Halt::new(); - let arc = halt.as_arc().clone(); - assert!(!halt.is_cancelled()); - arc.store(true, Ordering::Relaxed); - assert!(halt.is_cancelled()); - } - - // ── New comprehensive tests ──────────────────────────────────────────────── - - /// POLL_INTERVAL is 250ms — a specific value that the multi-thread halt - /// loops depend on for responsiveness guarantees. - /// Mutation: setting POLL_INTERVAL to 5s makes stop requests take 5s to notice. - #[test] - fn poll_interval_is_250ms() { - assert_eq!( - POLL_INTERVAL, - std::time::Duration::from_millis(250), - "POLL_INTERVAL must be 250ms for the guaranteed ~quarter-second cancel latency" - ); + fn loom_halt_cancel_is_release_acquire() { + loom::model(|| { + let flag = Arc::new(AtomicBool::new(false)); + let data = Arc::new(UnsafeCell::new(0u32)); + let (f2, d2) = (flag.clone(), data.clone()); + let canceller = loom::thread::spawn(move || { + // SAFETY: the only write; the reader touches it only after observing + // the cancel, which loom checks is ordered after this write. + d2.with_mut(|p| unsafe { *p = 7 }); + raise(&*f2); + }); + if observed(&*flag) { + // SAFETY: ordered after the write by the Release/Acquire pair. + assert_eq!(data.with(|p| unsafe { *p }), 7); + } + canceller.join().unwrap(); + }); } } diff --git a/src/halt/tests.rs b/src/halt/tests.rs new file mode 100644 index 00000000..2d1b31df --- /dev/null +++ b/src/halt/tests.rs @@ -0,0 +1,696 @@ +use super::diag::NoBlockingScope; +use super::*; +use std::sync::mpsc; +use std::sync::{Arc, Barrier}; + +// Test windows are scaled: a 60 s production window is 100 ms here (stop design §5.0). +const WINDOW: Duration = Duration::from_millis(100); +// Headroom on wall-clock upper bounds for a loaded CI runner. +const SLACK: Duration = Duration::from_millis(500); + +fn catch(f: impl FnOnce() -> T) -> std::thread::Result { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) +} + +#[test] +fn fresh_is_not_cancelled() { + let h = Halt::new(); + assert!(!h.is_cancelled()); +} + +#[test] +fn cancel_flips_state_and_is_idempotent() { + let h = Halt::new(); + h.cancel(); + h.cancel(); + assert!(h.is_cancelled()); +} + +#[test] +fn clone_shares_state_both_directions() { + let a = Halt::new(); + let b = a.clone(); + b.cancel(); + assert!(a.is_cancelled()); + let c = Halt::new(); + let d = c.clone(); + c.cancel(); + assert!(d.is_cancelled()); +} + +#[test] +fn clone_shares_state_across_threads() { + let h = Halt::new(); + let h2 = h.clone(); + std::thread::spawn(move || h2.cancel()).join().unwrap(); + assert!(h.is_cancelled()); +} + +/// G10: `from_arc`/`as_arc` are pointer-exact views over one flag (repr unchanged). +#[test] +fn from_arc_as_arc_are_pointer_exact() { + let arc = Arc::new(AtomicBool::new(false)); + let halt = Halt::from_arc(arc.clone()); + assert!(Arc::ptr_eq(halt.as_arc(), &arc)); + halt.cancel(); + assert!(arc.load(Ordering::Acquire)); + let arc2 = Arc::new(AtomicBool::new(false)); + let halt2 = Halt::from_arc(arc2.clone()); + arc2.store(true, Ordering::Release); + assert!(halt2.is_cancelled()); +} + +/// §2.1: `POLL_INTERVAL` becomes an alias of the 20 ms `WAIT_SLICE`. +#[test] +fn poll_interval_aliases_wait_slice() { + assert_eq!(WAIT_SLICE, Duration::from_millis(20)); + assert_eq!(POLL_INTERVAL, WAIT_SLICE); +} + +/// LT1: `check` → `Halted` after cancel; `wait(10 s)` returns `Halted` ≤ 1 s after a +/// cancel from another thread; an uncancelled `wait(d)` returns `Ok` after `d`. +#[test] +fn halt_check_and_wait_observe_cancel() { + let h = Halt::new(); + assert!(h.check().is_ok()); + let t = Instant::now(); + assert!(h.wait(WINDOW).is_ok()); + assert!(t.elapsed() >= WINDOW); + assert!(h.wait(Duration::ZERO).is_ok()); + + let h2 = h.clone(); + let canceller = std::thread::spawn(move || { + std::thread::sleep(WINDOW); + let at = Instant::now(); + h2.cancel(); + at + }); + let r = h.wait(Duration::from_secs(10)); + let returned = Instant::now(); + let cancelled_at = canceller.join().unwrap(); + assert!(matches!(r, Err(Error::Halted)), "{r:?}"); + assert!(returned.duration_since(cancelled_at) <= Duration::from_secs(1)); + assert!(matches!(h.check(), Err(Error::Halted))); + assert!(matches!(h.wait(Duration::MAX), Err(Error::Halted))); +} + +// Run `f` on a thread, cancel `h` `after` it starts, and return its result. +fn cancel_during( + h: &Halt, + after: Duration, + f: impl FnOnce() -> T + Send + 'static, +) -> (T, Duration) { + let start = Arc::new(Barrier::new(2)); + let s2 = start.clone(); + let waiter = std::thread::spawn(move || { + s2.wait(); + let r = f(); + (r, Instant::now()) + }); + start.wait(); + std::thread::sleep(after); + let at = Instant::now(); + h.cancel(); + let (r, returned) = waiter.join().unwrap(); + (r, returned.saturating_duration_since(at)) +} + +/// LT2: `recv_timeout` returns `Halted` mid-wait; `send_timeout` returns mid-wait and +/// gives the item back. +#[test] +fn halt_recv_send_timeout_return_on_cancel() { + let (_tx, rx) = mpsc::channel::(); + let h = Halt::new(); + let h2 = h.clone(); + let (r, took) = cancel_during(&h, WINDOW, move || { + h2.recv_timeout(&rx, Duration::from_secs(10)) + }); + assert!(matches!(r, Err(Error::Halted)), "{r:?}"); + assert!(took <= Duration::from_secs(1)); + + // A rendezvous channel nobody receives on: every send blocks. + let (tx, _rx) = mpsc::sync_channel::(0); + let h = Halt::new(); + let h2 = h.clone(); + let (r, took) = cancel_during(&h, WINDOW, move || { + h2.send_timeout(&tx, "frame".to_string(), Duration::from_secs(10)) + }); + assert_eq!(r, Err(Halted("frame".to_string()))); + assert!( + matches!(Error::from(Halted(())), Error::Halted), + "`?` gives Error::Halted" + ); + assert!(took <= Duration::from_secs(1)); +} + +/// Uncancelled `recv_timeout`/`send_timeout`: item, budget expiry, and a gone peer. +#[test] +fn recv_send_timeout_without_cancel() { + let h = Halt::new(); + let (tx, rx) = mpsc::channel::(); + tx.send(5).unwrap(); + assert_eq!(h.recv_timeout(&rx, WINDOW).unwrap(), Recv::Item(5)); + let t = Instant::now(); + assert_eq!(h.recv_timeout(&rx, WINDOW).unwrap(), Recv::TimedOut); + assert!(t.elapsed() >= WINDOW && t.elapsed() < WINDOW + SLACK); + drop(tx); + let t = Instant::now(); + assert_eq!( + h.recv_timeout(&rx, Duration::from_secs(10)).unwrap(), + Recv::Disconnected + ); + assert!(t.elapsed() < SLACK, "a gone sender ends the wait at once"); + + let (stx, srx) = mpsc::sync_channel::(1); + assert_eq!(h.send_timeout(&stx, 1, WINDOW), Ok(SendOutcome::Sent)); + let t = Instant::now(); + assert_eq!( + h.send_timeout(&stx, 2, WINDOW), + Ok(SendOutcome::TimedOut(2)) + ); + assert!(t.elapsed() >= WINDOW && t.elapsed() < WINDOW + SLACK); + drop(srx); + assert_eq!( + h.send_timeout(&stx, 3, Duration::from_secs(10)), + Ok(SendOutcome::Disconnected(3)) + ); + + h.cancel(); + let (tx, rx) = mpsc::channel::(); + tx.send(9).unwrap(); + assert!(matches!(h.recv_timeout(&rx, WINDOW), Err(Error::Halted))); + assert_eq!(h.send_timeout(&stx, 4, WINDOW), Err(Halted(4))); +} + +#[cfg(feature = "rip")] +#[test] +fn crossbeam_channels_are_timed() { + let h = Halt::new(); + let (tx, rx) = crossbeam_channel::bounded::(1); + assert_eq!(h.send_timeout(&tx, 1, WINDOW), Ok(SendOutcome::Sent)); + assert_eq!(h.send_timeout(&tx, 2, WINDOW), Ok(SendOutcome::TimedOut(2))); + assert_eq!(h.recv_timeout(&rx, WINDOW).unwrap(), Recv::Item(1)); + assert_eq!(h.recv_timeout(&rx, WINDOW).unwrap(), Recv::TimedOut); + assert_eq!( + h.send_timeout(&tx, 3, Duration::ZERO), + Ok(SendOutcome::Sent) + ); + assert_eq!( + h.send_timeout(&tx, 4, Duration::ZERO), + Ok(SendOutcome::TimedOut(4)) + ); + assert_eq!(h.recv_timeout(&rx, Duration::ZERO).unwrap(), Recv::Item(3)); + assert_eq!(h.recv_timeout(&rx, Duration::ZERO).unwrap(), Recv::TimedOut); + drop(tx); + assert_eq!( + h.recv_timeout(&rx, Duration::MAX).unwrap(), + Recv::Disconnected + ); +} + +/// A zero budget still makes one non-blocking attempt: a queued item is taken and +/// a send into room succeeds; a full or empty channel reports at once. +#[test] +fn zero_budget_makes_one_attempt() { + let h = Halt::new(); + let (tx, rx) = mpsc::sync_channel::(1); + let t = Instant::now(); + assert_eq!( + h.send_timeout(&tx, 1, Duration::ZERO), + Ok(SendOutcome::Sent) + ); + assert_eq!( + h.send_timeout(&tx, 2, Duration::ZERO), + Ok(SendOutcome::TimedOut(2)) + ); + assert_eq!(h.recv_timeout(&rx, Duration::ZERO).unwrap(), Recv::Item(1)); + assert_eq!(h.recv_timeout(&rx, Duration::ZERO).unwrap(), Recv::TimedOut); + assert!(t.elapsed() < SLACK); +} + +/// A disconnected channel with an unbounded wait returns `Disconnected` at once, and +/// every repeat does too, so a caller looping on it exits instead of spinning. +#[test] +fn disconnected_unbounded_recv_returns_at_once() { + let h = Halt::new(); + let (tx, rx) = mpsc::channel::(); + let worker = std::thread::spawn(move || { + let _tx = tx; + panic!("worker died"); + }); + assert!(worker.join().is_err()); + let t = Instant::now(); + assert_eq!( + h.recv_timeout(&rx, Duration::MAX).unwrap(), + Recv::Disconnected + ); + assert_eq!(h.recv_timeout(&rx, WAIT_SLICE).unwrap(), Recv::Disconnected); + assert!(t.elapsed() < SLACK, "{:?}", t.elapsed()); +} + +/// A gone receiver with an unbounded wait returns `Disconnected` at once (item back), +/// and so does every repeat, so a caller looping on it exits instead of spinning. +#[test] +fn disconnected_unbounded_send_returns_at_once() { + let h = Halt::new(); + let (tx, rx) = mpsc::sync_channel::(0); + let worker = std::thread::spawn(move || { + let _rx = rx; + panic!("consumer died"); + }); + assert!(worker.join().is_err()); + let t = Instant::now(); + let r = h.send_timeout(&tx, 7, Duration::MAX); + assert_eq!(r, Ok(SendOutcome::Disconnected(7))); + let r = h.send_timeout(&tx, 8, WAIT_SLICE); + assert_eq!(r, Ok(SendOutcome::Disconnected(8))); + assert!(t.elapsed() < SLACK, "{:?}", t.elapsed()); +} + +/// The `SyncSender` poll backs off to one attempt per `WAIT_SLICE`: blocked for 10 +/// slices it makes ~15 attempts (1, 2, 4, 8, 16 ms, then 20 ms), not ~200 at 1 ms. +#[test] +fn sync_sender_poll_backs_off_to_a_slice() { + let mut attempts = 0; + let t = Instant::now(); + let r = backoff_poll(1u32, WAIT_SLICE * 10, |v| { + attempts += 1; + Err(SendFail::Timeout(v)) + }); + assert!(matches!(r, Err(SendFail::Timeout(1)))); + assert!(t.elapsed() >= WAIT_SLICE * 10); + assert!((5..=25).contains(&attempts), "{attempts} attempts"); + let (tx, _rx) = mpsc::sync_channel::(0); + assert!(matches!( + tx.send_slice(2, WAIT_SLICE), + Err(SendFail::Timeout(2)) + )); +} + +// A thread blocked until the returned sender sends or drops. +fn parked_thread() -> (JoinHandle, mpsc::Sender<()>) { + let (tx, rx) = mpsc::channel::<()>(); + let h = std::thread::spawn(move || { + let _ = rx.recv(); + 11 + }); + (h, tx) +} + +/// LT4: `Joined::{Done, Halted, Pending}`; never blocks past `d` + 1 slice. +#[test] +fn join_within_finished_cancelled_and_expired() { + let done = std::thread::spawn(|| 3u32); + while !done.is_finished() { + std::thread::yield_now(); + } + assert!(matches!( + join_within(done, Duration::ZERO, None), + Joined::Done(Ok(3)) + )); + + let (h, release) = parked_thread(); + let t = Instant::now(); + let Joined::Pending(h) = join_within(h, WINDOW, None) else { + panic!("a running thread past d is Pending"); + }; + let took = t.elapsed(); + assert!( + took >= WINDOW && took <= WINDOW + WAIT_SLICE + SLACK, + "{took:?}" + ); + + let halt = Halt::new(); + halt.cancel(); + let Joined::Halted(h) = join_within(h, Duration::from_secs(10), Some(&halt)) else { + panic!("a cancelled token is Halted"); + }; + drop(release); + assert!(matches!( + join_within(h, Duration::from_secs(10), Some(&Halt::new())), + Joined::Done(Ok(11)) + )); + + let panicked = std::thread::spawn(|| -> u32 { panic!("consumer died") }); + assert!(matches!( + join_within(panicked, Duration::from_secs(10), None), + Joined::Done(Err(_)) + )); +} + +/// LT5a (HR1): slow but progressing — a bump every 0.5 × window for 4 windows — +/// never expires. +#[test] +fn stall_timer_rearms_on_progress() { + let p = Progress::new(); + let mut t = StallTimer::new(WINDOW, &p); + let start = Instant::now(); + let mut next_bump = start + WINDOW / 2; + while start.elapsed() < WINDOW * 4 { + if Instant::now() >= next_bump { + p.bump(); + next_bump += WINDOW / 2; + } + assert_ne!(t.poll(&p), Stall::Expired, "at {:?}", start.elapsed()); + std::thread::sleep(Duration::from_millis(5)); + } + assert!(p.get() >= 6); +} + +/// LT5b (HR1): no progress → `StalledFor` until the window, then `Expired` within +/// window + slack; never before the window. +#[test] +fn stall_timer_expires_without_progress() { + let p = Progress::new(); + p.bump(); + let start = Instant::now(); + let mut t = StallTimer::new(WINDOW, &p); + let expired_at = loop { + match t.poll(&p) { + Stall::Expired => break start.elapsed(), + Stall::StalledFor(d) => assert!(d < WINDOW), + Stall::Progressing => panic!("nothing moved"), + } + assert!(start.elapsed() < WINDOW + SLACK, "never expired"); + std::thread::sleep(Duration::from_millis(5)); + }; + assert!(expired_at >= WINDOW, "{expired_at:?}"); + p.bump(); + assert_eq!( + t.poll(&p), + Stall::Progressing, + "progress re-arms after expiry" + ); + assert!(matches!(t.poll(&p), Stall::StalledFor(_))); +} + +/// LT5c (ST3-5): with a live `busy()` guard an `idle_only` timer does not expire, +/// while a plain timer on the same `Progress` expires at its window. +#[test] +fn busy_pauses_only_idle_only_timers() { + let p = Progress::new(); + let mut plain = StallTimer::new(WINDOW, &p); + let mut idle = StallTimer::idle_only(WINDOW, &p); + let guard = p.busy(); + assert_eq!(p.get(), 0, "busy() never bumps"); + let start = Instant::now(); + let mut plain_expired = None; + while start.elapsed() < WINDOW * 3 { + if plain.poll(&p) == Stall::Expired && plain_expired.is_none() { + plain_expired = Some(start.elapsed()); + } + assert_ne!( + idle.poll(&p), + Stall::Expired, + "idle_only expired while busy" + ); + std::thread::sleep(Duration::from_millis(5)); + } + let at = plain_expired.expect("the plain timer ignores busy()"); + assert!(at >= WINDOW && at < WINDOW + SLACK, "{at:?}"); + + // Negative control: once idle, the idle_only timer expires after a window. + drop(guard); + let idle_from = Instant::now(); + while idle.poll(&p) != Stall::Expired { + assert!( + idle_from.elapsed() < WINDOW + SLACK, + "idle_only never expired" + ); + std::thread::sleep(Duration::from_millis(5)); + } + assert!(idle_from.elapsed() >= WINDOW); +} + +/// A busy span that starts and ends between two polls counts as busy, not idle. +#[test] +fn idle_only_ignores_a_busy_span_between_polls() { + let p = Progress::new(); + let mut idle = StallTimer::idle_only(WINDOW, &p); + let g = p.busy(); + std::thread::sleep(WINDOW * 2); + drop(g); + assert!(matches!(idle.poll(&p), Stall::StalledFor(d) if d < WINDOW)); +} + +/// LT5c grep: `StallTimer::idle_only` is the T29 probe's alone (freemkv GUI), so no +/// libfreemkv source outside `halt` constructs one; T7 and T12 stay plain. +#[test] +fn idle_only_is_constructed_only_at_the_t29_site() { + fn walk(dir: &std::path::Path, hits: &mut Vec) { + for e in std::fs::read_dir(dir).unwrap() { + let path = e.unwrap().path(); + if path.is_dir() { + walk(&path, hits); + } else if path.extension().is_some_and(|x| x == "rs") { + let src = std::fs::read_to_string(&path).unwrap(); + if src.contains("idle_only(") { + hits.push(path.display().to_string()); + } + } + } + } + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut hits = Vec::new(); + walk(&root, &mut hits); + let halt = root.join("halt"); + hits.retain(|h| h != &root.join("halt.rs").display().to_string()); + hits.retain(|h| !h.starts_with(&halt.display().to_string())); + assert!(hits.is_empty(), "idle_only outside halt: {hits:?}"); +} + +// Serialise on the holder count; a poisoned lock (a failed sibling test) still serialises. +fn holder_lock() -> std::sync::MutexGuard<'static, ()> { + DRIVE_HOLDER_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()) +} + +// Wait (bounded) for detached holders to finish, so the next test sees `before`. +fn settle_holders(before: usize) { + let t = Instant::now(); + while live_drive_holders() != before { + assert!(t.elapsed() < SLACK * 4, "holders never settled"); + std::thread::sleep(Duration::from_millis(1)); + } +} + +#[test] +fn drive_holder_joins_and_counts() { + let _serial = holder_lock(); + let (tx, rx) = mpsc::channel::<()>(); + let before = live_drive_holders(); + let holder = spawn_drive_holder("test-holder", move || { + let _ = rx.recv(); + std::thread::current().name().map(str::to_owned) + }) + .unwrap(); + assert_eq!(holder.role(), "test-holder"); + assert_eq!(live_drive_holders(), before + 1); + assert!(!holder.is_finished()); + drop(tx); + let name = holder.join().unwrap(); + assert_eq!(name.as_deref(), Some("freemkv-test-holder")); + assert_eq!( + live_drive_holders(), + before, + "the count drops before join returns" + ); +} + +/// §2.1: a Drive holder dropped unjoined panics in debug builds. +#[cfg(debug_assertions)] +#[test] +fn drive_holder_dropped_unjoined_panics_in_debug() { + let _serial = holder_lock(); + let before = live_drive_holders(); + let holder = spawn_drive_holder("leaky", || 1u8).unwrap(); + let r = catch(move || drop(holder)); + assert!(r.is_err()); + settle_holders(before); +} + +/// LT6: a debug panic for each forbidden call (exec, sleep, join) under the scope; +/// `join_finished` (and a zero-length wait) are exempt; scopes nest. +#[cfg(debug_assertions)] +#[test] +fn no_blocking_scope_trips_on_exec_sleep_join() { + let _serial = holder_lock(); + let before = live_drive_holders(); + let h = Halt::new(); + let (running, release) = parked_thread(); + let finished = std::thread::spawn(|| 1u8); + while !finished.is_finished() { + std::thread::yield_now(); + } + let (hold, held) = mpsc::channel::<()>(); + let holder = spawn_drive_holder("scoped", move || held.recv().is_err()).unwrap(); + { + let _outer = NoBlockingScope::enter(); + let inner = NoBlockingScope::enter(); + drop(inner); + assert!(NoBlockingScope::active()); + assert!(catch(|| h.wait(Duration::from_millis(1))).is_err(), "sleep"); + assert!( + catch(move || join_within(running, WINDOW, None)).is_err(), + "join" + ); + assert!(catch(move || holder.join()).is_err(), "holder join"); + #[cfg(feature = "rip")] + assert!(catch(scoped_exec).is_err(), "exec"); + assert!(catch(|| h.wait(Duration::ZERO)).is_ok()); + assert!(matches!(join_finished(finished), Ok(1))); + } + assert!(!NoBlockingScope::active()); + assert!(h.wait(Duration::from_millis(1)).is_ok()); + #[cfg(feature = "rip")] + assert!(catch(scoped_exec_outside).unwrap()); + drop((release, hold)); + settle_holders(before); +} + +#[cfg(all(debug_assertions, feature = "rip"))] +fn test_drive() -> crate::drive::Drive { + use crate::scsi::{DataDirection, ScsiResult, ScsiTransport}; + struct Ok0; + impl ScsiTransport for Ok0 { + fn execute( + &mut self, + _: &[u8], + _: DataDirection, + _: &mut [u8], + _: u32, + ) -> Result { + Ok(ScsiResult { + status: 0, + bytes_transferred: 0, + sense: [0u8; 32], + }) + } + } + crate::drive::Drive::from_transport_for_test(Box::new(Ok0)) +} + +#[cfg(all(debug_assertions, feature = "rip"))] +fn scoped_exec() { + let mut d = test_drive(); + let tur = [0u8; 6]; + let _ = d.exec(&tur, crate::scsi::DataDirection::None, &mut [], 1000); +} + +#[cfg(all(debug_assertions, feature = "rip"))] +fn scoped_exec_outside() -> bool { + let mut d = test_drive(); + let tur = [0u8; 6]; + d.exec(&tur, crate::scsi::DataDirection::None, &mut [], 1000) + .is_ok() +} + +const LT7_RUNS: usize = 20; +// Cancel this long after the waiter starts: well inside a 250 ms slice, so the old +// `POLL_INTERVAL` wakes ~220 ms late on every run, far past the 150 ms bound. +const LT7_CANCEL_AFTER: Duration = Duration::from_millis(30); +const LT7_BOUND: Duration = Duration::from_millis(150); +const BUDGET: Duration = Duration::from_secs(10); + +// Median over `LT7_RUNS` of cancel → return; each run asserts the wait was halted. +fn median_wake(what: &str, run: impl Fn(&Halt) -> Duration) -> Duration { + let mut v: Vec = (0..LT7_RUNS).map(|_| run(&Halt::new())).collect(); + v.sort(); + let m = v[LT7_RUNS / 2]; + assert!( + m <= LT7_BOUND, + "{what}: median wake {m:?} > {LT7_BOUND:?} ({v:?})" + ); + m +} + +/// LT7: every halt-aware wait, blocked on a 10 s budget, returns within a slice of a +/// cancel from another thread (≤ 150 ms, median of 20). Replaces the 250 ms +/// constant-equality test: a slice regressed to 250 ms or more fails it. +#[test] +fn every_halt_aware_wait_wakes_within_a_slice() { + median_wake("Halt::wait", |h| { + let h2 = h.clone(); + let (r, took) = cancel_during(h, LT7_CANCEL_AFTER, move || h2.wait(BUDGET)); + assert!(matches!(r, Err(Error::Halted))); + took + }); + median_wake("Halt::recv_timeout", |h| { + let (tx, rx) = mpsc::channel::(); + let h2 = h.clone(); + let (r, took) = cancel_during(h, LT7_CANCEL_AFTER, move || h2.recv_timeout(&rx, BUDGET)); + drop(tx); + assert!(matches!(r, Err(Error::Halted))); + took + }); + median_wake("Halt::send_timeout", |h| { + let (tx, rx) = mpsc::sync_channel::(0); + let h2 = h.clone(); + let (r, took) = cancel_during(h, LT7_CANCEL_AFTER, move || h2.send_timeout(&tx, 1, BUDGET)); + drop(rx); + assert_eq!(r, Err(Halted(1))); + took + }); + median_wake("join_within", |h| { + let (target, release) = parked_thread(); + let h2 = h.clone(); + let (r, took) = cancel_during(h, LT7_CANCEL_AFTER, move || { + join_within(target, BUDGET, Some(&h2)) + }); + drop(release); + let Joined::Halted(target) = r else { + panic!("join_within was not halted"); + }; + target.join().unwrap(); + took + }); + #[cfg(feature = "rip")] + median_wake("Pipeline::send_with_halt", pipeline_send_wake); +} + +// A depth-1 pipeline whose consumer holds its first item until released, so the +// next sends fill the channel and the one after blocks. +#[cfg(feature = "rip")] +fn pipeline_send_wake(h: &Halt) -> Duration { + use crate::io::pipeline::{Flow, Pipeline, Sink}; + struct Gate(mpsc::Receiver<()>); + impl Sink for Gate { + type Output = (); + fn apply(&mut self, _: u32) -> Result { + let _ = self.0.recv(); + Ok(Flow::Continue) + } + fn close(self) -> Result<()> { + Ok(()) + } + } + let (open, gate) = mpsc::channel::<()>(); + let pipe = Arc::new(Pipeline::spawn(1, Gate(gate)).unwrap()); + let idle = Halt::new(); + pipe.send_with_halt(1, &idle, BUDGET).unwrap(); + // Fill the channel: the consumer may or may not have taken item 1 yet. + while pipe + .send_with_halt(2, &idle, Duration::from_millis(50)) + .is_ok() + {} + let (p2, h2) = (pipe.clone(), h.clone()); + let (r, took) = cancel_during(h, LT7_CANCEL_AFTER, move || { + p2.send_with_halt(3, &h2, BUDGET) + }); + assert_eq!(r, Err(3)); + drop(open); + let pipe = Arc::try_unwrap(pipe).ok().expect("sole owner"); + pipe.finish().unwrap(); + took +} + +/// per spec; do not change without a spec citation proving otherwise — SS-23: the +/// Release/Acquire pair `cancel`/`is_cancelled` rely on (LT3 model-checks it). +#[test] +fn ss_23_release_acquire_quote_backs_the_token() { + let t = crate::spec::stop::SS_23_RELEASE_ACQUIRE.text; + assert!(t.contains("all previous writes become visible to all threads")); + assert!(t.contains("Acquire (or stronger) load of this value")); + assert!(t.contains("all subsequent loads will see data written before the store")); +} diff --git a/src/identity.rs b/src/identity.rs index 41fe9eef..2e04df4c 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -53,13 +53,23 @@ pub struct DriveId { /// shorter cannot populate the identity fields this type promises. const INQUIRY_STANDARD_LEN: usize = 36; +/// Issues one CDB: a raw transport, or `Drive::exec`. +pub(crate) type Exec<'a> = + dyn FnMut(&[u8], DataDirection, &mut [u8], u32) -> Result + 'a; + impl DriveId { /// Probe a real drive via SCSI and build its identity. pub fn from_drive(transport: &mut dyn ScsiTransport) -> Result { + Self::identify(&mut |cdb, dir, buf, t| transport.execute(cdb, dir, buf, t)) + } + + /// [`from_drive`](Self::from_drive) over any CDB issuer: `Drive::open` passes + /// `Drive::exec`, so identification is refused once the op token is cancelled. + pub(crate) fn identify(exec: &mut Exec<'_>) -> Result { // INQUIRY — SPC-4 §6.4 let mut inquiry = vec![0u8; 96]; let cdb_inq = [0x12, 0x00, 0x00, 0x00, 0x60, 0x00]; - let inq = transport.execute(&cdb_inq, DataDirection::FromDevice, &mut inquiry, 5000)?; + let inq = exec(&cdb_inq, DataDirection::FromDevice, &mut inquiry, 5000)?; // `bytes_transferred` is device-reported and untrusted. Unchecked, a // GOOD status with a short/empty data phase decoded to blank identity + // byte0 0x00 — which reads as DIRECT ACCESS, so the drive vanishes silently. @@ -75,13 +85,14 @@ impl DriveId { let mut gc = vec![0u8; 256]; let cdb_gc = [0x46, 0x02, 0x01, 0x0C, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00]; let (firmware_date, raw_gc_010c) = - match transport.execute(&cdb_gc, DataDirection::FromDevice, &mut gc, 5000) { + match exec(&cdb_gc, DataDirection::FromDevice, &mut gc, 5000) { Ok(r) => { let date = gc_feature_descriptor(&gc, r.bytes_transferred, 0x010C) .map(|d| gc_text(&d[4..d.len().min(16)])) .unwrap_or_default(); (date, gc[..gc_reply_len(&gc, r.bytes_transferred)].to_vec()) } + Err(crate::error::Error::Halted) => return Err(crate::error::Error::Halted), Err(_) => (String::new(), Vec::new()), }; @@ -90,13 +101,14 @@ impl DriveId { // bytes deliberately yields an empty serial rather than failing. let mut gc_serial = vec![0u8; 256]; let cdb_serial = [0x46, 0x02, 0x01, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00]; - let serial_number = - match transport.execute(&cdb_serial, DataDirection::FromDevice, &mut gc_serial, 5000) { - Ok(r) => gc_feature_descriptor(&gc_serial, r.bytes_transferred, 0x0108) - .map(|d| gc_text(&d[4..])) - .unwrap_or_default(), - Err(_) => String::new(), - }; + let serial_number = match exec(&cdb_serial, DataDirection::FromDevice, &mut gc_serial, 5000) + { + Ok(r) => gc_feature_descriptor(&gc_serial, r.bytes_transferred, 0x0108) + .map(|d| gc_text(&d[4..])) + .unwrap_or_default(), + Err(crate::error::Error::Halted) => return Err(crate::error::Error::Halted), + Err(_) => String::new(), + }; Ok(DriveId { vendor_id: ascii_field(&inquiry, 8, 16), diff --git a/src/io/bounded.rs b/src/io/bounded.rs index ecdb0340..fefbc828 100644 --- a/src/io/bounded.rs +++ b/src/io/bounded.rs @@ -1,20 +1,17 @@ -//! Bounded-syscall primitive: run a (potentially-blocking) operation -//! on a worker thread, with a hard wall-clock deadline and an optional -//! cooperative [`Halt`] poll. The calling thread is never trapped -//! inside a kernel call. +//! Bounded-syscall primitive: run a (potentially-blocking) operation on a worker thread +//! while the caller waits halt-aware, bounded by a deadline (`bounded_syscall`, one +//! call's "no answer" bound) or by a stall window over a [`Progress`] +//! ([`bounded_syscall_stall`], HR1). The calling thread is never trapped in a kernel call. //! -//! Escape hatch for syscalls that trap a thread inside the kernel (`sync_file_range`, `fsync`, -//! NFS writes) where a cooperative [`Halt`] can't interrupt. The worker thread is leaked on -//! timeout or halt. +//! Escape hatch for syscalls a cooperative [`Halt`] can't interrupt (`sync_file_range`, +//! `fsync`, `FlushFileBuffers`, NFS writes). The worker thread is leaked on timeout or halt. -// Only the Linux/macOS writeback paths call this; Windows has no bounded syscall yet. -#![cfg_attr(not(any(target_os = "linux", target_os = "macos")), allow(dead_code))] - -use std::sync::mpsc::{RecvTimeoutError, sync_channel}; +use std::sync::mpsc::{Receiver, sync_channel}; use std::thread; -use std::time::{Duration, Instant}; +#[cfg(any(target_os = "linux", test))] +use std::time::Duration; -use crate::halt::{Halt, POLL_INTERVAL}; +use crate::halt::{Halt, Progress, Recv, Stall, StallTimer, WAIT_SLICE}; /// Failure outcome from a bounded syscall wrapper. #[derive(Debug)] @@ -24,8 +21,8 @@ pub(crate) enum BoundedError { /// a degraded code path rather than waiting on the syscall to /// return. Halted, - /// The deadline elapsed before the syscall returned. Same leak - /// semantics as `Halted`. + /// The deadline (or stall window) elapsed before the syscall returned. + /// Same leak semantics as `Halted`. Timeout, /// The worker thread panicked, the OS rejected the thread spawn, /// or its sender disconnected before sending a result. Treat as a @@ -36,8 +33,30 @@ pub(crate) enum BoundedError { WorkerLost, } +// Start `op` on a worker; `None` if the caller already requested halt (don't spawn and +// leak a worker that would run `op` in the background). +fn start(halt: Option<&Halt>, op: F) -> Option> +where + F: FnOnce() -> R + Send + 'static, + R: Send + 'static, +{ + if halt.is_some_and(Halt::is_cancelled) { + return None; + } + // Rendezvous channel: worker sends one value then exits. On timeout/halt + // the receiver is dropped, so the worker's send returns Err (ignored). + let (tx, rx) = sync_channel::(0); + let _ = thread::Builder::new() + .name("freemkv-bounded-syscall".into()) + .spawn(move || { + let _ = tx.send(op()); + }); + Some(rx) +} + // Runs `op` on a worker thread with a deadline + optional [`Halt`] poll; returns Ok, or Err on // Halted/Timeout/WorkerLost (worker leaked). +#[cfg(any(target_os = "linux", test))] pub(crate) fn bounded_syscall( halt: Option<&Halt>, timeout: Duration, @@ -47,49 +66,45 @@ where F: FnOnce() -> R + Send + 'static, R: Send + 'static, { - // If the caller already requested halt, don't spawn (and leak) a - // worker that would run `op` to completion in the background. - if halt.is_some_and(|h| h.is_cancelled()) { - return Err(BoundedError::Halted); + let rx = start(halt, op).ok_or(BoundedError::Halted)?; + // Halt-aware in WAIT_SLICE slices; a `timeout` past `Instant`'s range is unbounded. + let never = Halt::new(); + match halt.unwrap_or(&never).recv_timeout(&rx, timeout) { + Ok(Recv::Item(v)) => Ok(v), + Ok(Recv::TimedOut) => Err(BoundedError::Timeout), + // Worker spawn failed, or it panicked before sending: "no syscall ran". + Ok(Recv::Disconnected) => Err(BoundedError::WorkerLost), + Err(_) => Err(BoundedError::Halted), } +} - // Rendezvous channel: worker sends one value then exits. On timeout/halt - // the receiver is dropped, so the worker's send returns Err (ignored). - let (tx, rx) = sync_channel::(0); - let _ = thread::Builder::new() - .name("freemkv-bounded-syscall".into()) - .spawn(move || { - // Ignore send error: if we time out/halt before the worker finishes, - // the receiver is dropped and `tx.send` returns Err — nothing more to do. - let _ = tx.send(op()); - }); - - // `None` = a timeout past `Instant`'s range: unbounded. - let deadline = Instant::now().checked_add(timeout); +// Runs `op` on a worker thread; the caller waits halt-aware, calling `tick` every slice +// (a sampler may bump `progress`), and gives up with `Timeout` only once `timer` expires +// on `progress` (HR1: no progress for its window, never elapsed time). +pub(crate) fn bounded_syscall_stall( + halt: Option<&Halt>, + progress: &Progress, + timer: &mut StallTimer, + tick: &mut dyn FnMut(), + op: F, +) -> Result +where + F: FnOnce() -> R + Send + 'static, + R: Send + 'static, +{ + let rx = start(halt, op).ok_or(BoundedError::Halted)?; + let never = Halt::new(); + let halt = halt.unwrap_or(&never); loop { - let now = Instant::now(); - let slice = deadline.map_or(POLL_INTERVAL, |d| { - d.saturating_duration_since(now).min(POLL_INTERVAL) - }); - match rx.recv_timeout(slice) { - Ok(v) => return Ok(v), - Err(RecvTimeoutError::Timeout) => { - if let Some(h) = halt - && h.is_cancelled() - { - return Err(BoundedError::Halted); - } - if deadline.is_some_and(|d| Instant::now() >= d) { - return Err(BoundedError::Timeout); - } - // Otherwise: another slice. - } - Err(RecvTimeoutError::Disconnected) => { - // Worker thread spawn failed, or it panicked before - // sending. Caller treats this as "no syscall ran" — - // typically a no-op + log. - return Err(BoundedError::WorkerLost); - } + match halt.recv_timeout(&rx, WAIT_SLICE) { + Ok(Recv::Item(v)) => return Ok(v), + Ok(Recv::Disconnected) => return Err(BoundedError::WorkerLost), + Err(_) => return Err(BoundedError::Halted), + Ok(Recv::TimedOut) => {} + } + tick(); + if timer.poll(progress) == Stall::Expired { + return Err(BoundedError::Timeout); } } } @@ -99,6 +114,7 @@ mod tests { use super::*; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; + use std::time::Instant; // `Duration::MAX` overflowed `Instant + Duration` and panicked; it means unbounded. #[test] @@ -229,4 +245,66 @@ mod tests { "timeout did not return near deadline: {elapsed:?} (op should be leaked, not awaited)" ); } + + /// LP12: a halted wait returns at once and leaks the worker, and the worker's + /// rendezvous send to the dropped receiver does not wedge it: it ends once the + /// op returns. Guard. + #[test] + fn bounded_syscall_halt_leaks_worker_without_blocking() { + let halt = Halt::new(); + let h2 = halt.clone(); + thread::spawn(move || { + thread::sleep(Duration::from_millis(30)); + h2.cancel(); + }); + let alive = Arc::new(()); + let held = alive.clone(); + let t = Instant::now(); + let r = bounded_syscall(Some(&halt), Duration::from_secs(10), move || { + thread::sleep(Duration::from_millis(200)); + drop(held); + 1u8 + }); + assert!(matches!(r, Err(BoundedError::Halted))); + assert!( + t.elapsed() < Duration::from_millis(500), + "{:?}", + t.elapsed() + ); + let t = Instant::now(); + while Arc::strong_count(&alive) > 1 { + assert!( + t.elapsed() < Duration::from_secs(2), + "the leaked worker wedged" + ); + thread::sleep(Duration::from_millis(5)); + } + } + + /// HR1 for one blocked call: a worker blocked past the window while `tick` bumps the + /// progress is waited for; the same call with no progress times out at the window. + #[test] + fn stall_bound_rearms_on_progress_and_expires_without() { + let w = Duration::from_millis(100); + let p = Progress::new(); + let mut timer = StallTimer::new(w, &p); + let bump = p.clone(); + let r = bounded_syscall_stall(None, &p, &mut timer, &mut || bump.bump(), || { + thread::sleep(Duration::from_millis(400)); + 3u8 + }); + assert!(matches!(r, Ok(3))); + let mut timer = StallTimer::new(w, &p); + let t = Instant::now(); + let r = bounded_syscall_stall(None, &p, &mut timer, &mut || {}, || { + thread::sleep(Duration::from_secs(2)); + 0u8 + }); + assert!(matches!(r, Err(BoundedError::Timeout))); + assert!( + t.elapsed() < Duration::from_millis(1100), + "{:?}", + t.elapsed() + ); + } } diff --git a/src/io/byte_prefetcher.rs b/src/io/byte_prefetcher.rs index c48f4949..9cebda22 100644 --- a/src/io/byte_prefetcher.rs +++ b/src/io/byte_prefetcher.rs @@ -4,10 +4,11 @@ //! producer re-fills in place, for zero allocations and zero cross-thread frees in the hot //! loop. Works for any stream whose source is an `io::Read`, not just a `SectorSource`. -use crate::halt::{Halt, POLL_INTERVAL}; -use crossbeam_channel::{Receiver, RecvTimeoutError, SendTimeoutError, Sender, bounded}; +use crate::halt::{Halt, Recv, SendOutcome}; +use crossbeam_channel::{Receiver, Sender, bounded}; use std::io::Read; use std::thread::JoinHandle; +use std::time::Duration; /// Items flowing through the forward channel. pub type Batch = std::io::Result>; @@ -32,8 +33,8 @@ pub const DEFAULT_CHUNK_BYTES: usize = 16 * 1024 * 1024; /// /// Drop blocks until the producer exits. For a prompt exit, drop the /// forward receiver and recycle sender first (channel disconnection) -/// or cancel the [`Halt`] passed to [`BytePrefetcher::new`], polled -/// at [`POLL_INTERVAL`] granularity even while parked on a channel op. +/// or cancel the [`Halt`] passed to [`BytePrefetcher::new`], observed +/// within one [`WAIT_SLICE`](crate::halt::WAIT_SLICE) even while parked on a channel op. pub struct PrefetchShell { producer: Option>, } @@ -48,8 +49,11 @@ impl Drop for PrefetchShell { /// Spawned byte prefetcher. Drop joins the producer thread. pub struct BytePrefetcher { - rx: Option>, - recycle_tx: Option>>, + // Non-`Option`: `into_channels`/`Drop` swap in a disconnected stand-in (dead-channel + // `mem::replace`, as `sector::PrefetchedSectorSource` does), so a missing `rx` can + // never read as a silent, truncating clean EOF. + rx: Receiver, + recycle_tx: Sender>, producer: Option>, } @@ -76,6 +80,8 @@ impl BytePrefetcher { let _ = recycle_tx.send(vec![0u8; chunk_bytes]); } + // A never-cancelled stand-in keeps one halt-aware code path without a token. + let wait = halt.unwrap_or_default(); let producer = std::thread::Builder::new() .name("freemkv-byte-prefetch".into()) .spawn(move || { @@ -83,41 +89,23 @@ impl BytePrefetcher { // but a panic sends an error sentinel first so demux gets a typed error // instead of finalizing a truncated mux as success. let body = std::panic::AssertUnwindSafe(|| { - let cancelled = || halt.as_ref().map(|h| h.is_cancelled()).unwrap_or(false); // Liveness heartbeat: a stalled consumer or wedged reader shows up // as the beat going silent. Total is unknown, so `pos` is cumulative. let mut hb = crate::progress::Heartbeat::new("byte_prefetch"); let mut produced_bytes: u64 = 0; loop { hb.tick(produced_bytes, 0); - if cancelled() { + // Halt-aware: a cancel does not disconnect the channel, so a + // plain recv() would never re-reach the check. Disconnected = + // the consumer dropped both channels. + let Ok(Recv::Item(mut buf)) = wait.recv_timeout(&recycle_rx, Duration::MAX) + else { return; - } - // Re-poll halt every POLL_INTERVAL: a pure-AtomicBool Halt does - // not disconnect the channel, so a blocking recv() would never - // re-reach the cancel check. - let mut buf = loop { - match recycle_rx.recv_timeout(POLL_INTERVAL) { - Ok(b) => break b, - Err(RecvTimeoutError::Timeout) => { - if cancelled() { - return; - } - } - // Consumer dropped both channels. - Err(RecvTimeoutError::Disconnected) => return, - } }; - // Regrow to chunk_bytes: a prior short read truncated len to - // n < chunk_bytes. No realloc — capacity was fixed at - // construction and never shrinks. - if buf.len() < chunk_bytes { - buf.resize(chunk_bytes, 0); - } else { - // SAFETY: capacity is at least chunk_bytes - // after construction. - unsafe { buf.set_len(chunk_bytes) }; - } + // Regrow to chunk_bytes (a short read may have truncated len): sound + // resize, was `unsafe set_len` guarded only by capacity (GHSA-j8ww-f5fg-9pmh + // in `sector::prefetched`). No realloc; a no-op if len is already there. + buf.resize(chunk_bytes, 0); // Read up to one full chunk. Short reads are // valid and common — pipe `truncate` so the // consumer sees only the bytes that arrived. @@ -125,7 +113,7 @@ impl BytePrefetcher { Ok(0) => return, // EOF — drop tx, consumer sees RecvError Ok(n) => n, Err(e) => { - let _ = tx.send(Err(e)); + let _ = wait.send_timeout(&tx, Err(e), Duration::MAX); return; } }; @@ -134,19 +122,9 @@ impl BytePrefetcher { // Hand off the filled buffer, re-polling halt on // each timeout slice so a cancel can interrupt a // producer parked on a saturated forward channel. - let mut pending = Ok(buf); - loop { - match tx.send_timeout(pending, POLL_INTERVAL) { - Ok(()) => break, - Err(SendTimeoutError::Timeout(returned)) => { - if cancelled() { - return; - } - pending = returned; - } - // Consumer dropped. - Err(SendTimeoutError::Disconnected(_)) => return, - } + let sent = wait.send_timeout(&tx, Ok(buf), Duration::MAX); + if !matches!(sent, Ok(SendOutcome::Sent)) { + return; // consumer dropped, or stopped } } }); @@ -154,13 +132,14 @@ impl BytePrefetcher { // Producer panicked mid-stream — surface a typed terminal // error so the demux thread does NOT read the dropped channel // as a clean EOF and truncate output. - let _ = tx.send(Err(crate::error::Error::DemuxThreadPanicked.into())); + let e = crate::error::Error::DemuxThreadPanicked.into(); + let _ = wait.send_timeout(&tx, Err(e), Duration::MAX); } })?; Ok(Self { - rx: Some(rx), - recycle_tx: Some(recycle_tx), + rx, + recycle_tx, producer: Some(producer), }) } @@ -169,20 +148,18 @@ impl BytePrefetcher { /// caller (typically [`crate::mux::demux_thread::DemuxThread`]) /// drains `rx`, runs the demuxer in place on each filled buffer, /// and recycles back through `recycle_tx`. - pub fn into_channels(self) -> (Receiver, Sender>, PrefetchShell) { - // MOVE the fields out, never clone: the pre-1.0.0 impl cloned + `mem::forget`-ed - // `self`, leaking endpoints that defeated disconnection shutdown (producer join - // hung forever). `ManuallyDrop` + `ptr::read` moves fields out cleanly instead. - let me = std::mem::ManuallyDrop::new(self); - // SAFETY: `me` is `ManuallyDrop`, so none of these fields are dropped by `me`. - // Each `ptr::read` is one bitwise move, read exactly once — no double-frees. - let producer = unsafe { std::ptr::read(&me.producer) }; - // SAFETY: `rx` and `recycle_tx` are always `Some` here — - // `into_channels` is the only way to consume a live - // `BytePrefetcher`; `Drop::drop` is suppressed by `ManuallyDrop`. - let rx = unsafe { std::ptr::read(&me.rx) }.expect("rx always Some before drop"); - let recycle = - unsafe { std::ptr::read(&me.recycle_tx) }.expect("recycle_tx always Some before drop"); + pub fn into_channels(mut self) -> (Receiver, Sender>, PrefetchShell) { + // Same dead-channel swap as `Drop for BytePrefetcher` below: `self` is left + // holding only disconnected placeholders — no `unsafe`, no double-drop, and (being + // non-`Option`) no fallback branch that could hand back a dead `rx`. + let (dead_tx, dead_rx) = bounded::(0); + drop(dead_tx); + let rx = std::mem::replace(&mut self.rx, dead_rx); + let (dead_send, dead_recv) = bounded::>(0); + drop(dead_recv); + let recycle = std::mem::replace(&mut self.recycle_tx, dead_send); + let producer = self.producer.take(); + // `self` drops here: dead endpoints no-op, `producer` is `None` (no join). (rx, recycle, PrefetchShell { producer }) } } @@ -192,8 +169,12 @@ impl Drop for BytePrefetcher { // Drop channel endpoints BEFORE joining so the producer observes Disconnected // (send/recv) and exits promptly. Otherwise a non-EOF source fills the forward // channel and spins in send_timeout forever since rx is never drained. - drop(self.rx.take()); - drop(self.recycle_tx.take()); + let (dead_tx, dead_rx) = bounded::(0); + drop(dead_tx); + drop(std::mem::replace(&mut self.rx, dead_rx)); + let (dead_send, dead_recv) = bounded::>(0); + drop(dead_recv); + drop(std::mem::replace(&mut self.recycle_tx, dead_send)); if let Some(h) = self.producer.take() { let _ = h.join(); } @@ -267,6 +248,36 @@ mod tests { }); } + // L106 (safe `Option::take` rewrite, no `unsafe`): `shell` drop must join within the + // bound below (a leaked handle hangs it). No-double-drop is structural: an explicit + // second `Drop::drop` call is a compile error (E0040), so it can't be attempted. + #[test] + fn into_channels_then_drop_leaks_nothing() { + within(10, || { + let pf = BytePrefetcher::new(EndlessReader, 4096, None).expect("spawn"); + let (rx, recycle_tx, shell) = pf.into_channels(); + drop(rx); + drop(recycle_tx); + drop(shell); // joins: a leaked/duplicated handle would hang this within(10, ..). + }); + } + + // Channels handed back by `into_channels` must still carry real bytes end-to-end — the + // safe rewrite must not have handed back the dead placeholder channels by mistake. + #[test] + fn into_channels_channels_still_deliver_bytes() { + within(10, || { + let src = vec![7u8; 10_000]; + let pf = BytePrefetcher::new(Cursor::new(src.clone()), 4096, None).expect("spawn"); + let (out, err) = drain_to_vec(pf); + assert!(err.is_none(), "no read error expected: {err:?}"); + assert_eq!( + out, src, + "all bytes delivered, byte-identical, after into_channels" + ); + }); + } + /// Same property via the halt path: cancel the token, then the /// producer must exit and the shell join must complete. #[test] @@ -418,6 +429,22 @@ mod tests { }); } + // Exercises the regrow-fill's now-plain `resize` when len is already chunk_bytes (was + // `unsafe set_len`): three full 8-byte chunks back to back, no short read in between. + #[test] + fn full_chunks_back_to_back_exercise_the_no_shrink_resize_path() { + within(10, || { + let src = vec![0x5Cu8; 24]; // 3 whole 8-byte chunks, source ends exactly on a chunk + let pf = BytePrefetcher::new(Cursor::new(src.clone()), 8, None).expect("spawn"); + let (got, err) = drain_to_vec(pf); + assert!(err.is_none()); + assert_eq!( + got, src, + "back-to-back full-size chunks must round-trip exactly" + ); + }); + } + // Recycle-buffer reuse must not leak stale bytes between chunks of different lengths; // source 8×0xAA + 3×0xBB, chunk_bytes=8. #[test] @@ -483,4 +510,24 @@ mod tests { drop(pf); }); } + + /// LP9 (×2, §2.1 "Unbounded Drop joins stay plain joins"): after a cancel, dropping + /// the prefetcher, or its shell with both channel ends still held, returns within + /// 1 s. Guard. + #[test] + fn byte_prefetcher_drop_after_cancel_returns() { + let halt = Halt::new(); + let pf = BytePrefetcher::new(EndlessReader, 4096, Some(halt.clone())).expect("spawn"); + std::thread::sleep(std::time::Duration::from_millis(50)); + halt.cancel(); + within(1, move || drop(pf)); + + let halt = Halt::new(); + let pf = BytePrefetcher::new(EndlessReader, 4096, Some(halt.clone())).expect("spawn"); + let (rx, recycle_tx, shell) = pf.into_channels(); + std::thread::sleep(std::time::Duration::from_millis(50)); + halt.cancel(); + within(1, move || drop(shell)); + drop((rx, recycle_tx)); + } } diff --git a/src/io/flush.rs b/src/io/flush.rs new file mode 100644 index 00000000..b9057eec --- /dev/null +++ b/src/io/flush.rs @@ -0,0 +1,537 @@ +//! Durable flush with measured progress (stop design §2.10, §4.5; T12). +//! +//! No OS reports progress inside one `fsync`, so a flush is made of bounded pieces and +//! every completed piece is progress. A flush fails with `SyncTimeout` (E9056) only after +//! 60 s with no progress (HR1, Q-B: "fail 60s after writing. detect, if writing keep going +//! do nothing."); a Stop ends the wait at once. + +use std::fs::File; +use std::io; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +use super::bounded::{BoundedError, bounded_syscall_stall}; +use crate::error::Error; +use crate::halt::{Halt, Progress, StallTimer}; + +/// T12's window: this long with no flush progress is `SyncTimeout` (E9056). +pub(crate) const FLUSH_STALL: Duration = Duration::from_secs(60); + +/// A shared view of a file's durable-flush progress: the [`Progress`] every accepted +/// write and completed flush bumps, the bytes made durable, and the file length. +/// Clones share the counters; hand one to [`WritebackFile::set_flush_progress`] and +/// read it from another thread. +/// +/// [`WritebackFile::set_flush_progress`]: crate::io::WritebackFile::set_flush_progress +#[derive(Clone, Debug, Default)] +pub struct FlushProgress { + progress: Progress, + durable: Arc, + total: Arc, +} + +impl FlushProgress { + /// Counters that bump `progress` (share it with the pipeline consumer, §2.10 item 3). + pub fn new(progress: Progress) -> Self { + Self { + progress, + ..Self::default() + } + } + + /// The forward-progress counter. + pub fn progress(&self) -> &Progress { + &self.progress + } + + /// Bytes made durable (or, on Linux local storage, written out) so far; never more + /// than [`bytes_total`](Self::bytes_total). + pub fn bytes_durable(&self) -> u64 { + let total = self.bytes_total(); + self.durable.load(Ordering::Relaxed).min(total) + } + + /// The file's length as written so far. + pub fn bytes_total(&self) -> u64 { + self.total.load(Ordering::Relaxed) + } + + // `n` more bytes are durable: one unit of progress. + pub(crate) fn add_durable(&self, n: u64) { + self.durable.fetch_add(n, Ordering::Relaxed); + self.progress.bump(); + } + + // At least `v` bytes are durable. + pub(crate) fn durable_at_least(&self, v: u64) { + if self.durable.fetch_max(v, Ordering::Relaxed) < v { + self.progress.bump(); + } + } + + // The file now reaches `len` bytes. + pub(crate) fn note_total(&self, len: u64) { + self.total.fetch_max(len, Ordering::Relaxed); + } +} + +/// The flush primitives, per OS; a seam so tests run a flush in milliseconds. +pub(crate) trait FlushOps: Send + Sync + 'static { + /// Make the data written so far durable: the in-write chunk flush, and the whole-file + /// piece where there is no range sync. + fn chunk(&self, file: &File) -> io::Result<()>; + /// Sync `[off, off + len)` (Linux local); `None` where there is no range sync. + fn range(&self, file: &File, off: u64, len: u64) -> Option>; + /// The final flush. + fn finish(&self, file: &File) -> io::Result<()>; + /// A counter that moves while one flush is blocked (Linux NFS `mountstats`). + fn sample(&self) -> Option; +} + +/// Every duration and size of the flusher (§2.10), production values by default. +#[derive(Debug, Clone, Copy)] +pub(crate) struct FlushTiming { + /// T12: no progress for this long is `SyncTimeout`. + pub(crate) stall: Duration, + /// A chunk flush slower than this halves the chunk; one under a quarter of it doubles it. + pub(crate) slow_chunk: Duration, + /// The first chunk and the floor: small, so a slow device completes one inside `stall`. + pub(crate) chunk_min: u64, + pub(crate) chunk_max: u64, + /// How often a blocked wait samples [`FlushOps::sample`]. + pub(crate) sample_every: Duration, +} + +impl Default for FlushTiming { + fn default() -> Self { + Self { + stall: FLUSH_STALL, + slow_chunk: Duration::from_secs(15), + chunk_min: super::writeback::CHUNK_BYTES_MIN, + chunk_max: 64 * 1024 * 1024, + sample_every: Duration::from_secs(1), + } + } +} + +/// [`durable_sync_file`]'s piece sizing (§4.5), production values by default. +#[derive(Debug, Clone, Copy)] +pub(crate) struct DurableTiming { + pub(crate) stall: Duration, + /// A piece slower than this halves the window; one under a quarter of it doubles it. + pub(crate) piece_target: Duration, + /// The first window and the floor. + pub(crate) window_min: u64, + pub(crate) window_max: u64, + pub(crate) sample_every: Duration, +} + +impl Default for DurableTiming { + fn default() -> Self { + Self { + stall: FLUSH_STALL, + piece_target: Duration::from_secs(2), + window_min: 1024 * 1024, + window_max: 64 * 1024 * 1024, + sample_every: Duration::from_secs(1), + } + } +} + +/// Make `file` durable, reporting `on_progress(bytes_done, bytes_total)` on every piece +/// that completes (stop design §4.5). +/// +/// Pieces are sized to finish in about 2 s: Linux local storage syncs consecutive windows +/// (starting at the 1 MiB floor, doubling after a fast piece up to 64 MiB, halving after one +/// over 2 s) and then the file; Linux NFS runs one flush while the mount's `mountstats` write +/// counters report bytes; macOS (`F_FULLFSYNC`) and Windows (`FlushFileBuffers`) report the +/// one call's completion. Fails with `SyncTimeout` (E9056) only after 60 s with no +/// progress; a cancelled `halt` returns `Halted` at once, leaking the worker. +pub fn durable_sync_file( + file: &File, + halt: Option<&Halt>, + on_progress: impl FnMut(u64, u64), +) -> io::Result<()> { + durable_sync_file_with( + file, + halt, + on_progress, + os_ops(file), + DurableTiming::default(), + ) +} + +// `durable_sync_file` over `ops`, with its timing as a parameter. +pub(crate) fn durable_sync_file_with( + file: &File, + halt: Option<&Halt>, + mut on_progress: impl FnMut(u64, u64), + ops: Arc, + timing: DurableTiming, +) -> io::Result<()> { + let len = file.metadata()?.len(); + // An owned clone per worker call: a leaked worker keeps a valid fd, never a reused number. + let file = Arc::new(file.try_clone()?); + let progress = Progress::new(); + let mut timer = StallTimer::new(timing.stall, &progress); + let mut sampler = Sampler::new(timing.sample_every, timing.stall); + let mut done = 0u64; + let mut window = timing.window_min.max(1); + // Linux local: consecutive windows, each completion is progress (§4.5). + while done < len { + let piece = window.min(len - done); + let (f, o, off) = (file.clone(), ops.clone(), done); + let started = Instant::now(); + let ranged = bounded_syscall_stall(halt, &progress, &mut timer, &mut || {}, move || { + o.range(&f, off, piece) + }) + .map_err(|e| wait_failure(e, "range sync"))?; + match ranged { + None => break, + Some(r) => r?, + } + done += piece; + progress.bump(); + on_progress(done, len); + let took = started.elapsed(); + window = next_size( + window, + took, + timing.piece_target, + timing.window_min, + timing.window_max, + ); + } + // The final flush; while it is blocked, a sampled counter (NFS) is progress too. + let reported = done; + let (f, o) = (file.clone(), ops.clone()); + let finished = bounded_syscall_stall( + halt, + &progress, + &mut timer, + &mut || { + if let Some(delta) = sampler.tick(&*ops) { + progress.bump(); + done = (done + delta).min(len.saturating_sub(1)).max(done); + on_progress(done, len); + } + }, + move || o.finish(&f), + ); + finished.map_err(|e| wait_failure(e, "final flush"))??; + if done < len || (len == 0 && reported == 0) { + on_progress(len, len); + } + Ok(()) +} + +/// The adaptive piece size (§2.10 item 1, §4.5): start at the floor so the first piece of +/// a slow device completes inside the stall window; halve (to `min`) after a piece slower +/// than `slow`, double (to `max`) after one under a quarter of it. +pub(crate) fn next_size(size: u64, took: Duration, slow: Duration, min: u64, max: u64) -> u64 { + if took > slow { + (size / 2).max(min) + } else if took < slow / 4 { + size.saturating_mul(2).min(max.max(min)) + } else { + size + } +} + +// Samples `FlushOps::sample` every `every` from a blocked wait: the counter's increase since +// the last sample, if any. Logs one INFO once a wait has lasted `note_after` (§2.10 item 4). +pub(crate) struct Sampler { + every: Duration, + note_after: Duration, + started: Instant, + noted: bool, + last_at: Instant, + last: Option, +} + +impl Sampler { + pub(crate) fn new(every: Duration, note_after: Duration) -> Self { + let now = Instant::now(); + Self { + every, + note_after, + started: now, + noted: false, + last_at: now, + last: None, + } + } + + pub(crate) fn tick(&mut self, ops: &dyn FlushOps) -> Option { + let now = Instant::now(); + if !self.noted && now.duration_since(self.started) >= self.note_after { + self.noted = true; + tracing::info!( + target: "freemkv::io", + waited_s = self.note_after.as_secs(), + "durable flush still in progress; waiting while it makes progress" + ); + } + if now.duration_since(self.last_at) < self.every { + return None; + } + self.last_at = now; + let v = ops.sample()?; + let prev = self.last.replace(v)?; + (v > prev).then(|| v - prev) + } +} + +// Maps a failed wait onto the `io::Error` a flush returns; every arm means the flush did +// not observably complete (never `Ok`). +pub(crate) fn wait_failure(e: BoundedError, what: &str) -> io::Error { + match e { + BoundedError::Timeout => { + tracing::error!( + target: "freemkv::io", + what, + "durable flush made no progress for the stall window; data NOT durably flushed" + ); + Error::SyncTimeout.into() + } + BoundedError::Halted => { + tracing::warn!(target: "freemkv::io", what, "durable flush stopped (halt requested)"); + Error::Halted.into() + } + BoundedError::WorkerLost => { + tracing::error!(target: "freemkv::io", what, "durable flush worker lost before completion"); + Error::SyncWorkerLost.into() + } + } +} + +// The production primitives for `file`. +pub(crate) fn os_ops(file: &File) -> Arc { + Arc::new(platform::OsFlushOps::for_file(file)) +} + +#[cfg(target_os = "linux")] +mod platform { + use super::*; + use std::os::unix::io::AsRawFd; + + pub(super) struct OsFlushOps { + // Local storage that takes `sync_file_range`: flush in windows. + ranged: bool, + // On NFS: the file's path, to find its mount in /proc/self/mountstats. + nfs_path: Option, + } + + impl OsFlushOps { + pub(super) fn for_file(file: &File) -> Self { + use crate::platform::fs_type::{FsType, detect_fd}; + let fd = file.as_raw_fd(); + let nfs = detect_fd(fd) == FsType::Nfs; + let regular = file.metadata().is_ok_and(|m| m.is_file()); + let nfs_path = nfs + .then(|| std::fs::read_link(format!("/proc/self/fd/{fd}")).ok()) + .flatten() + .map(|p| p.to_string_lossy().into_owned()); + Self { + ranged: regular && !nfs, + nfs_path, + } + } + } + + fn rc(r: libc::c_int) -> io::Result<()> { + if r == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } + } + + impl FlushOps for OsFlushOps { + // SS-12 XSH fdatasync(): "shall force all currently queued I/O operations associated + // with the file … to the synchronized I/O completion state". + fn chunk(&self, file: &File) -> io::Result<()> { + // SAFETY: a valid fd for the call's duration. + rc(unsafe { libc::fdatasync(file.as_raw_fd()) }) + } + + // SS-13 sync_file_range(2): WAIT_BEFORE|WRITE|WAIT_AFTER "is a write-for-data-integrity + // operation", but "does not flush disk write caches" or metadata: `finish` follows. + fn range(&self, file: &File, off: u64, len: u64) -> Option> { + if !self.ranged { + return None; + } + let flags = libc::SYNC_FILE_RANGE_WAIT_BEFORE + | libc::SYNC_FILE_RANGE_WRITE + | libc::SYNC_FILE_RANGE_WAIT_AFTER; + let (off, len) = (off as libc::off64_t, len as libc::off64_t); + // SAFETY: a valid fd for the call's duration. + match rc(unsafe { libc::sync_file_range(file.as_raw_fd(), off, len, flags) }) { + // Refused, not failed (no range sync here): the whole-file flush covers it. + Err(e) + if matches!( + e.raw_os_error(), + Some(libc::EINVAL | libc::ESPIPE | libc::ENOSYS | libc::EOPNOTSUPP) + ) => + { + None + } + r => Some(r), + } + } + + // SS-12 XSH fsync(): "shall not return until the system has completed that action". + fn finish(&self, file: &File) -> io::Result<()> { + // SAFETY: a valid fd for the call's duration. + rc(unsafe { libc::fsync(file.as_raw_fd()) }) + } + + fn sample(&self) -> Option { + let path = self.nfs_path.as_deref()?; + let text = std::fs::read_to_string("/proc/self/mountstats").ok()?; + parse_mountstats(&text, path) + } + } +} + +#[cfg(target_os = "macos")] +mod platform { + use super::*; + use std::os::unix::io::AsRawFd; + + pub(super) struct OsFlushOps; + + impl OsFlushOps { + pub(super) fn for_file(_file: &File) -> Self { + Self + } + } + + fn rc(r: libc::c_int) -> io::Result<()> { + if r == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } + } + + impl FlushOps for OsFlushOps { + // A plain fsync per chunk: cheap next to F_FULLFSYNC, and only the pages dirtied + // since the last one (§2.10 macOS row). + fn chunk(&self, file: &File) -> io::Result<()> { + // SAFETY: a valid fd for the call's duration. + rc(unsafe { libc::fsync(file.as_raw_fd()) }) + } + + fn range(&self, _file: &File, _off: u64, _len: u64) -> Option> { + None + } + + // SS-14 fcntl(2) F_FULLFSYNC: "Does the same thing as fsync(2) then asks the drive to + // flush all buffered data"; only some filesystems implement it, so ENOTSUP → fsync. + fn finish(&self, file: &File) -> io::Result<()> { + let fd = file.as_raw_fd(); + // SAFETY: a valid fd for the calls' duration. + match rc(unsafe { libc::fcntl(fd, libc::F_FULLFSYNC) }) { + Err(e) if e.raw_os_error() == Some(libc::ENOTSUP) => rc(unsafe { libc::fsync(fd) }), + r => r, + } + } + + fn sample(&self) -> Option { + None + } + } +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +mod platform { + use super::*; + + pub(super) struct OsFlushOps; + + impl OsFlushOps { + pub(super) fn for_file(_file: &File) -> Self { + Self + } + } + + impl FlushOps for OsFlushOps { + // SS-15 FlushFileBuffers: "writes all the buffered information for a specified file + // to the device" — std's `sync_all` on Windows (no data-only flush exists there). + fn chunk(&self, file: &File) -> io::Result<()> { + file.sync_all() + } + + fn range(&self, _file: &File, _off: u64, _len: u64) -> Option> { + None + } + + fn finish(&self, file: &File) -> io::Result<()> { + file.sync_all() + } + + fn sample(&self) -> Option { + None + } + } +} + +// The NFS client's write counter for the NFS mount holding `path` (longest mount point +// prefix) in `/proc/self/mountstats`: the `bytes:` line's 6th value (server-write bytes) +// plus the `WRITE:` and `COMMIT:` op counts. Any increase is progress (§2.10). +#[cfg(any(target_os = "linux", test))] +pub(crate) fn parse_mountstats(text: &str, path: &str) -> Option { + let mut best: Option<(usize, u64)> = None; + let mut current: Option<(usize, u64)> = None; + let close = |cur: Option<(usize, u64)>, best: &mut Option<(usize, u64)>| { + if let Some(c) = cur + && best.is_none_or(|b| c.0 > b.0) + { + *best = Some(c); + } + }; + for line in text.lines() { + let t = line.trim(); + if let Some(rest) = t.strip_prefix("device ") { + close(current.take(), &mut best); + current = nfs_block(rest, path).map(|depth| (depth, 0)); + continue; + } + let Some((_, total)) = current.as_mut() else { + continue; + }; + let mut fields = t.split_whitespace(); + let first = fields.next().unwrap_or(""); + let value = match first { + "bytes:" => fields.nth(5), + "WRITE:" | "COMMIT:" => fields.next(), + _ => None, + }; + *total += value.and_then(|v| v.parse::().ok()).unwrap_or(0); + } + close(current, &mut best); + best.map(|b| b.1) +} + +// For a `device` line's remainder: the mount point's length if it is an NFS mount holding +// `path`. Mount points escape a space as `\040`. +#[cfg(any(target_os = "linux", test))] +fn nfs_block(rest: &str, path: &str) -> Option { + let (_, after) = rest.split_once(" mounted on ")?; + let (mnt, fstype) = after.split_once(" with fstype ")?; + if !fstype.starts_with("nfs") { + return None; + } + let mnt = mnt.replace("\\040", " "); + let under = path == mnt + || mnt == "/" + || path + .strip_prefix(mnt.as_str()) + .is_some_and(|r| r.starts_with('/')); + under.then_some(mnt.len()) +} + +#[cfg(test)] +mod tests; diff --git a/src/io/flush/tests.rs b/src/io/flush/tests.rs new file mode 100644 index 00000000..93393c0c --- /dev/null +++ b/src/io/flush/tests.rs @@ -0,0 +1,838 @@ +//! Stop design §5.1 LP13a–h, LP19 and §5.9 SP10–SP13, G19: the durable flush (T12) on +//! a scaled clock through [`FakeFlushOps`] (a 60 s window runs as [`W`]); the real +//! syscalls run on the runner's tmpdir. Wall bounds keep ≥ 5× margin. + +use super::*; +use crate::io::WritebackFile; +use std::io::Write; +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize}; +use std::time::Instant; + +const W: Duration = Duration::from_millis(200); +const SLACK: Duration = Duration::from_secs(1); +const K: u64 = 1024; + +// Blocks callers while closed; `open` releases them. +#[derive(Clone)] +struct Gate(Arc); + +impl Gate { + fn open() -> Self { + Self(Arc::new(AtomicBool::new(true))) + } + fn closed() -> Self { + Self(Arc::new(AtomicBool::new(false))) + } + fn release(&self) { + self.0.store(true, Ordering::SeqCst); + } + fn pass(&self) { + while !self.0.load(Ordering::SeqCst) { + std::thread::sleep(Duration::from_millis(2)); + } + } +} + +// Range-piece sleep by call index (LP19). +type PieceDelay = Box Duration + Send + Sync>; + +/// The `FakeFlushOps` seam (§5.1 LP13): each primitive sleeps and/or waits on a gate, +/// counts its calls, and `sample` rises with wall time when enabled. +struct FakeFlushOps { + chunk_sleep: Duration, + // Extra chunk sleep by call index (LP13f), and a device writing out at this many + // bytes per second of wall time (the throttled-device pair). + chunk_delay: Option, + rate: Option, + flushed_len: AtomicU64, + chunk_gate: Gate, + finish_sleep: Duration, + finish_gate: Gate, + chunks: AtomicUsize, + finishes: AtomicUsize, + sample_from: Option, + pieces: Option, + ranges: Mutex>, +} + +impl Default for FakeFlushOps { + fn default() -> Self { + Self { + chunk_sleep: Duration::ZERO, + chunk_delay: None, + rate: None, + flushed_len: AtomicU64::new(0), + chunk_gate: Gate::open(), + finish_sleep: Duration::ZERO, + finish_gate: Gate::open(), + chunks: AtomicUsize::new(0), + finishes: AtomicUsize::new(0), + sample_from: None, + pieces: None, + ranges: Mutex::new(Vec::new()), + } + } +} + +impl FlushOps for FakeFlushOps { + fn chunk(&self, file: &File) -> io::Result<()> { + self.chunk_gate.pass(); + std::thread::sleep(self.chunk_sleep); + let i = self.chunks.load(Ordering::SeqCst); + if let Some(d) = &self.chunk_delay { + std::thread::sleep(d(i)); + } + if let Some(rate) = self.rate { + // The dirty bytes since the last chunk, written out at `rate`. + let len = file.metadata()?.len(); + let dirty = len.saturating_sub(self.flushed_len.swap(len, Ordering::SeqCst)); + std::thread::sleep(Duration::from_secs_f64(dirty as f64 / rate as f64)); + } + self.chunks.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + fn range(&self, _file: &File, off: u64, len: u64) -> Option> { + let delay = self.pieces.as_ref()?; + let i = { + let mut r = self.ranges.lock().unwrap(); + r.push((off, len)); + r.len() - 1 + }; + std::thread::sleep(delay(i)); + Some(Ok(())) + } + fn finish(&self, _file: &File) -> io::Result<()> { + self.finish_gate.pass(); + std::thread::sleep(self.finish_sleep); + self.finishes.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + fn sample(&self) -> Option { + self.sample_from.map(|t| t.elapsed().as_millis() as u64) + } +} + +fn timing(stall: Duration) -> FlushTiming { + FlushTiming { + stall, + slow_chunk: Duration::from_secs(10), + chunk_min: K, + chunk_max: K, + sample_every: W / 4, + } +} + +fn fake_file( + ops: &Arc, + t: FlushTiming, +) -> (tempfile::TempDir, WritebackFile, FlushProgress) { + let dir = tempfile::tempdir().unwrap(); + let file = File::create(dir.path().join("out.bin")).unwrap(); + let ops: Arc = ops.clone(); + let mut w = WritebackFile::with_flush_ops(file, ops, t).unwrap(); + let flush = FlushProgress::new(Progress::new()); + w.set_flush_progress(flush.clone()); + (dir, w, flush) +} + +fn is_sync_timeout(e: &io::Error) -> bool { + crate::error::error_code(e) == Some(crate::error::E_SYNC_TIMEOUT) +} + +fn cancel_after(halt: &Halt, d: Duration) { + let h = halt.clone(); + std::thread::spawn(move || { + std::thread::sleep(d); + h.cancel(); + }); +} + +/// LP13a (T12, stall pair a; Q-B "if writing keep going do nothing"): each chunk flush +/// takes 0.5 × window; the flusher keeps completing chunks, so a flush far longer than +/// the window succeeds. +#[test] +fn flush_slow_but_progressing_never_fails() { + let ops = Arc::new(FakeFlushOps { + chunk_sleep: W / 2, + ..FakeFlushOps::default() + }); + let (_d, mut w, _) = fake_file(&ops, timing(W)); + let t = Instant::now(); + for _ in 0..8 { + w.write_all(&[7u8; K as usize]).unwrap(); + } + w.sync_all().expect("a progressing flush never fails"); + assert!( + ops.chunks.load(Ordering::SeqCst) >= 4, + "the flusher flushed chunks" + ); + assert!(t.elapsed() > W * 2, "the flush outlived the window"); +} + +/// LP13b / G19 (T12, stall pair b): a chunk flush that never returns, with no sampled +/// signal, fails `SyncTimeout` (E9056) at the window (+ ≤ 1 s) and not long before. +/// Guard: E9056 is still returned on a true stall (§5.9 G19). +#[test] +fn flush_stall_60s_fails_e9056() { + let ops = Arc::new(FakeFlushOps { + chunk_gate: Gate::closed(), + ..FakeFlushOps::default() + }); + let (_d, mut w, _) = fake_file(&ops, timing(W)); + w.write_all(&[1u8; K as usize]).unwrap(); + let t = Instant::now(); + let e = w.sync_all().expect_err("a stalled flush fails"); + let took = t.elapsed(); + ops.chunk_gate.release(); + assert!(is_sync_timeout(&e), "{e}"); + assert!(took >= W / 2, "fired early: {took:?}"); + assert!(took <= W + SLACK, "fired late: {took:?}"); +} + +/// LP13c: a cancel during a stalled final flush, and during a writer's backpressure +/// wait, returns `Halted` within 1 s; the flush worker is leaked, never joined. +#[test] +fn flush_stop_interrupts() { + let long = timing(Duration::from_secs(30)); + let ops = Arc::new(FakeFlushOps { + finish_gate: Gate::closed(), + ..FakeFlushOps::default() + }); + let (_d, mut w, _) = fake_file(&ops, long); + let halt = Halt::new(); + w.set_halt(halt.clone()); + w.write_all(b"tail").unwrap(); + cancel_after(&halt, Duration::from_millis(50)); + let t = Instant::now(); + let e = w.sync_all().expect_err("a stop ends the final flush wait"); + ops.finish_gate.release(); + assert!(crate::error::is_halt(&e), "{e}"); + assert!(t.elapsed() < SLACK, "{:?}", t.elapsed()); + + let ops = Arc::new(FakeFlushOps { + chunk_gate: Gate::closed(), + ..FakeFlushOps::default() + }); + let (_d, mut w, _) = fake_file(&ops, long); + let halt = Halt::new(); + w.set_halt(halt.clone()); + for _ in 0..3 { + w.write_all(&[2u8; K as usize]).unwrap(); + } + cancel_after(&halt, Duration::from_millis(50)); + let t = Instant::now(); + let e = w + .write_all(&[2u8; K as usize]) + .expect_err("a stop ends the backpressure wait"); + ops.chunk_gate.release(); + assert!(crate::error::is_halt(&e), "{e}"); + assert!(t.elapsed() < SLACK, "{:?}", t.elapsed()); +} + +/// LP13d (Linux NFS signal): one flush blocked for 3 × window while the sampled +/// `mountstats` counter rises is progress, not a stall; the flush then succeeds. +#[test] +fn nfs_mountstats_signal_rearms() { + let ops = Arc::new(FakeFlushOps { + chunk_sleep: W * 3, + sample_from: Some(Instant::now()), + ..FakeFlushOps::default() + }); + let (_d, mut w, _) = fake_file(&ops, timing(W)); + w.write_all(&[3u8; K as usize]).unwrap(); + let t = Instant::now(); + w.sync_all() + .expect("a rising mountstats counter is progress"); + assert!(ops.chunks.load(Ordering::SeqCst) >= 1); + assert!(t.elapsed() >= W * 2, "the blocked flush was waited for"); +} + +/// Design review item 3: once a Stop is pending, a close on NFS counts only this file's +/// own chunk completions; other writers' `mountstats` counters no longer re-arm the wait, +/// so a blocked close fails E9056 at the window instead of running on unbounded. +#[test] +fn stop_pending_close_ignores_mountstats() { + let gate = Gate::closed(); + let ops = Arc::new(FakeFlushOps { + chunk_gate: gate.clone(), + sample_from: Some(Instant::now()), + ..FakeFlushOps::default() + }); + let (_d, mut w, _) = fake_file(&ops, timing(W)); + let halt = Halt::new(); + w.set_halt(halt.clone()); + w.write_all(&[3u8; K as usize]).unwrap(); + halt.cancel(); + let g = gate.clone(); + std::thread::spawn(move || { + std::thread::sleep(W * 6); + g.release(); + }); + let t = Instant::now(); + let e = w + .flush() + .expect_err("no own progress for a window after the Stop"); + assert!(is_sync_timeout(&e), "{e}"); + assert!(t.elapsed() <= W + SLACK / 4, "bounded: {:?}", t.elapsed()); +} + +/// LP13e (§2.10 item 2): the writer blocks once written − flushed exceeds 2 × C, and +/// resumes when a chunk flush completes. +#[test] +fn writer_backpressure_at_two_chunks() { + let ops = Arc::new(FakeFlushOps { + chunk_gate: Gate::closed(), + ..FakeFlushOps::default() + }); + let (_d, mut w, _) = fake_file(&ops, timing(Duration::from_secs(30))); + let done = Arc::new(AtomicUsize::new(0)); + let d2 = done.clone(); + let writer = std::thread::spawn(move || { + for _ in 0..4 { + w.write_all(&[4u8; K as usize]).unwrap(); + d2.fetch_add(1, Ordering::SeqCst); + } + w + }); + std::thread::sleep(Duration::from_millis(150)); + assert_eq!( + done.load(Ordering::SeqCst), + 3, + "the 4th write waits at 2 × C unflushed" + ); + ops.chunk_gate.release(); + let t = Instant::now(); + while done.load(Ordering::SeqCst) < 4 { + assert!( + t.elapsed() < SLACK, + "a completed flush must release the writer" + ); + std::thread::sleep(Duration::from_millis(2)); + } + writer.join().unwrap().sync_all().unwrap(); +} + +/// LP13f (§2.10 item 1, as revised): C starts at the floor, doubles after fast chunk +/// flushes up to the cap, and halves after one slower than the slow bound (15 s, scaled), +/// never below the floor. +#[test] +fn chunk_halves_on_slow_flush() { + let ops = Arc::new(FakeFlushOps { + chunk_delay: Some(Box::new(|i| { + if i >= 2 { + Duration::from_millis(40) + } else { + Duration::ZERO + } + })), + ..FakeFlushOps::default() + }); + let t = FlushTiming { + slow_chunk: Duration::from_millis(20), + chunk_min: K, + chunk_max: 4 * K, + ..timing(Duration::from_secs(30)) + }; + let (_d, mut w, _) = fake_file(&ops, t); + assert_eq!(w.chunk_bytes(), K, "starts at the floor"); + let mut peak = K; + for _ in 0..40 { + w.write_all(&[5u8; K as usize]).unwrap(); + peak = peak.max(w.chunk_bytes()); + } + w.sync_all().unwrap(); + assert_eq!(peak, 4 * K, "fast flushes grew C to the cap, not past it"); + assert_eq!( + w.chunk_bytes(), + K, + "slow flushes halved it to the floor, not below" + ); +} + +// ── The slow-device pair (design review: C starts at the floor) ── + +/// A virtual-clock model of the flusher (§2.10 items 1–3) at production sizes: a writer +/// at `writer_bps` in 64 KiB writes, a device at `device_bps` until `dies_at`, `next_size` +/// for C. Returns the longest time with no progress (an accepted write or a completed chunk). +fn simulate(total: u64, writer_bps: f64, device_bps: f64, dies_at: f64) -> f64 { + let t = FlushTiming::default(); + let (mut clock, mut last_progress, mut worst) = (0.0f64, 0.0f64, 0.0f64); + let (mut written, mut requested, mut flushed, mut chunk) = (0u64, 0u64, 0u64, t.chunk_min); + let mut busy: Option<(f64, u64, f64)> = None; // (done_at, target, started) + let write = 64 * K; + let mut next_write = 0.0f64; + loop { + let draining = written >= total; + if draining && requested < written { + requested = written; + } + if busy.is_none() && requested > flushed { + let secs = (requested - flushed) as f64 / device_bps; + let done = if clock + secs > dies_at { + f64::INFINITY + } else { + clock + secs + }; + busy = Some((done, requested, clock)); + } + if draining && flushed >= written { + return worst; + } + let room = written.saturating_sub(flushed) <= 2 * chunk; + let wake_w = if !draining && room { + next_write + } else { + f64::INFINITY + }; + let wake_f = busy.map_or(f64::INFINITY, |b| b.0); + let now = wake_w.min(wake_f); + if now.is_infinite() { + return f64::INFINITY; // the device died: nothing ever moves again + } + clock = now; + if wake_f <= wake_w { + let (_, target, started) = busy.take().unwrap(); + flushed = target; + let took = Duration::from_secs_f64(clock - started); + chunk = next_size(chunk, took, t.slow_chunk, t.chunk_min, t.chunk_max); + } else { + written += write; + next_write = clock + write as f64 / writer_bps; + if written - requested >= chunk { + requested = written; + } + } + worst = worst.max(clock - last_progress); + last_progress = clock; + } +} + +/// Design review item 1 (fake clock, production sizes): a writer and a device both at +/// ~100 KiB/s over 512 MiB never go a whole 60 s without progress, so no false +/// `SyncTimeout`; with the old 64 MiB first chunk the same run went minutes without one. +/// A device that dies mid-run still leaves only silence, which T12 times out. +#[test] +fn slow_device_keeps_progressing_on_a_fake_clock() { + let bps = 100.0 * 1024.0; + let worst = simulate(512 * K * K, bps, bps, f64::INFINITY); + assert!( + worst < FLUSH_STALL.as_secs_f64(), + "longest silence {worst:.1} s" + ); + // The old sizing: one 64 MiB chunk at 100 KiB/s is ~655 s of silence. + assert!(64.0 * 1024.0 * 1024.0 / bps > FLUSH_STALL.as_secs_f64()); + let dead = simulate(512 * K * K, bps, bps, 300.0); + assert!( + dead >= FLUSH_STALL.as_secs_f64(), + "a dead device is silence: {dead}" + ); +} + +/// Design review item 1 (scaled wall clock): a writer and a device both throttled so a +/// floor chunk takes a quarter window, and a cap chunk four windows. Starting at the floor +/// the flush keeps progressing to `Ok`; the same device stalled mid-run still fails E9056 +/// within the window. +#[test] +fn throttled_writer_never_latches_sync_timeout() { + let floor = 16 * K; + let rate = floor * 1000 / (W.as_millis() as u64 / 4); // bytes per second + let t = FlushTiming { + slow_chunk: W / 2, + chunk_min: floor, + chunk_max: 16 * floor, + ..timing(W) + }; + let ops = Arc::new(FakeFlushOps { + rate: Some(rate), + ..FakeFlushOps::default() + }); + let (_d, mut w, _) = fake_file(&ops, t); + for _ in 0..24 { + w.write_all(&[8u8; 16 * K as usize]) + .expect("a slow but healthy device never latches SyncTimeout"); + std::thread::sleep(W / 4); + } + w.sync_all().expect("and its final flush completes"); + assert!(ops.chunks.load(Ordering::SeqCst) >= 4); + + let gate = Gate::open(); + let n = Arc::new(AtomicUsize::new(0)); + let (g2, n2) = (gate.clone(), n.clone()); + let ops = Arc::new(FakeFlushOps { + rate: Some(rate), + chunk_delay: Some(Box::new(move |i| { + n2.store(i, Ordering::SeqCst); + if i == 2 { + g2.0.store(false, Ordering::SeqCst); + } + Duration::ZERO + })), + chunk_gate: gate.clone(), + ..FakeFlushOps::default() + }); + let (_d, mut w, _) = fake_file(&ops, t); + let t0 = Instant::now(); + let r = (|| { + for _ in 0..24 { + w.write_all(&[8u8; 16 * K as usize])?; + std::thread::sleep(W / 4); + } + w.sync_all() + })(); + let e = r.expect_err("a stalled device still times out"); + assert!(is_sync_timeout(&e), "{e}"); + assert!( + n.load(Ordering::SeqCst) >= 2, + "it progressed before the stall" + ); + assert!(t0.elapsed() < W * 24, "{:?}", t0.elapsed()); + gate.release(); +} + +/// LP13g (§2.10 item 4): a flusher stalled a whole window while writing latches +/// `SyncTimeout`: the blocked write returns E9056, and so does the next one (sticky). +#[test] +fn flusher_stall_during_writing_latches_e9056() { + let ops = Arc::new(FakeFlushOps { + chunk_gate: Gate::closed(), + ..FakeFlushOps::default() + }); + let (_d, mut w, _) = fake_file(&ops, timing(W)); + for _ in 0..3 { + w.write_all(&[6u8; K as usize]).unwrap(); + } + let t = Instant::now(); + let e = w + .write_all(&[6u8; K as usize]) + .expect_err("the stalled flusher fails the write"); + assert!(is_sync_timeout(&e), "{e}"); + assert!(t.elapsed() <= W + SLACK, "{:?}", t.elapsed()); + let t = Instant::now(); + let e = w.write(b"x").expect_err("sticky"); + assert!(is_sync_timeout(&e), "{e}"); + assert!( + t.elapsed() < Duration::from_millis(100), + "a latched error does not wait" + ); + ops.chunk_gate.release(); +} + +fn sized_file(len: u64) -> (tempfile::TempDir, File) { + let dir = tempfile::tempdir().unwrap(); + let f = File::create(dir.path().join("sync.bin")).unwrap(); + f.set_len(len).unwrap(); + (dir, f) +} + +fn durable_timing(stall: Duration) -> DurableTiming { + DurableTiming { + stall, + piece_target: Duration::from_millis(20), + window_min: 4 * K, + window_max: 16 * K, + sample_every: W / 4, + } +} + +/// LP19 (§4.5), Linux-local shape: every window's completion reports `bytes_done`; the +/// window starts at the floor (1 MiB, scaled), doubles after fast pieces to the cap and +/// halves after one over the target (2 s, scaled); the final flush follows. +#[test] +fn durable_sync_file_reports_piece_completions() { + let ops = Arc::new(FakeFlushOps { + pieces: Some(Box::new(|i| { + if i == 2 || i == 3 { + Duration::from_millis(40) + } else { + Duration::ZERO + } + })), + ..FakeFlushOps::default() + }); + let (_d, f) = sized_file(64 * K); + let mut seen = Vec::new(); + let dyn_ops: Arc = ops.clone(); + let t = durable_timing(Duration::from_secs(30)); + durable_sync_file_with(&f, None, |d, n| seen.push((d, n)), dyn_ops, t).unwrap(); + let lens: Vec = ops.ranges.lock().unwrap().iter().map(|r| r.1).collect(); + assert_eq!(lens, [4 * K, 8 * K, 16 * K, 8 * K, 4 * K, 8 * K, 16 * K]); + assert_eq!( + ops.finishes.load(Ordering::SeqCst), + 1, + "then the final flush" + ); + assert!(seen.windows(2).all(|p| p[0].0 < p[1].0), "{seen:?}"); + assert_eq!(seen.last(), Some(&(64 * K, 64 * K))); + assert!(seen.len() >= lens.len(), "one report per completed piece"); +} + +/// LP19, NFS shape: one whole-file flush blocked 3 × window while the sampled +/// `mountstats` counter rises reports those deltas, is not a stall, then completes. +#[test] +fn durable_sync_file_reports_mountstats_deltas() { + let ops = Arc::new(FakeFlushOps { + finish_sleep: W * 3, + sample_from: Some(Instant::now()), + ..FakeFlushOps::default() + }); + let (_d, f) = sized_file(64 * K); + let mut seen = Vec::new(); + let t = durable_timing(W); + durable_sync_file_with(&f, None, |d, n| seen.push((d, n)), ops, t).unwrap(); + let before_done = seen.iter().filter(|(d, _)| *d < 64 * K).count(); + assert!(before_done >= 2, "deltas reported while blocked: {seen:?}"); + assert_eq!(seen.last(), Some(&(64 * K, 64 * K))); +} + +/// LP19, macOS/Windows shape: no range sync and no sampled signal, so the one call's +/// completion is the report. +#[test] +fn durable_sync_file_reports_the_single_completion() { + let ops = Arc::new(FakeFlushOps { + finish_sleep: Duration::from_millis(10), + ..FakeFlushOps::default() + }); + let (_d, f) = sized_file(8 * K); + let mut seen = Vec::new(); + let t = durable_timing(W); + durable_sync_file_with(&f, None, |d, n| seen.push((d, n)), ops, t).unwrap(); + assert_eq!(seen, [(8 * K, 8 * K)]); +} + +/// `durable_sync_file`: a blocked flush with no signal is E9056 at the window (G19); +/// a cancel returns `Halted` at once. +#[test] +fn durable_sync_file_stall_and_stop() { + let gate = Gate::closed(); + let ops = Arc::new(FakeFlushOps { + finish_gate: gate.clone(), + ..FakeFlushOps::default() + }); + let (_d, f) = sized_file(8 * K); + let t = Instant::now(); + let e = durable_sync_file_with(&f, None, |_, _| {}, ops.clone(), durable_timing(W)) + .expect_err("a stalled flush fails"); + assert!(is_sync_timeout(&e), "{e}"); + assert!(t.elapsed() <= W + SLACK, "{:?}", t.elapsed()); + + let halt = Halt::new(); + cancel_after(&halt, Duration::from_millis(50)); + let t = Instant::now(); + let long = durable_timing(Duration::from_secs(30)); + let e = durable_sync_file_with(&f, Some(&halt), |_, _| {}, ops, long) + .expect_err("a stop ends the wait"); + gate.release(); + assert!(crate::error::is_halt(&e), "{e}"); + assert!(t.elapsed() < SLACK, "{:?}", t.elapsed()); +} + +const MOUNTSTATS: &str = "\ +device rootfs mounted on / with fstype rootfs +device nas:/export mounted on /mnt/nas with fstype nfs4 statvers=1.1 +\topts:\trw,vers=4.2,rsize=1048576,wsize=1048576 +\tage:\t1234 +\tbytes:\t100 200 300 400 500 600 7 8 +\tRPC iostats version: 1.1 p/v: 100003/4 (nfs) +\tper-op statistics +\t NULL: 0 0 0 0 0 0 0 0 +\t WRITE: 10 10 0 1000 2000 5 6 7 +\t COMMIT: 3 3 0 0 0 0 0 0 +device nas:/deep mounted on /mnt/nas/deep with fstype nfs statvers=1.1 +\tbytes:\t1 2 3 4 5 9000 0 0 +\tper-op statistics +\t WRITE: 90 90 0 0 0 0 0 0 +\t COMMIT: 9 9 0 0 0 0 0 0 +device nas:/sp mounted on /mnt/my\\040share with fstype nfs statvers=1.1 +\tbytes:\t0 0 0 0 0 40 0 0 +\t WRITE: 1 1 0 0 0 0 0 0 +\t COMMIT: 1 1 0 0 0 0 0 0 +device /dev/sda1 mounted on /data with fstype ext4 +"; + +/// LP13h `mountstats_parser` (§2.10 table, Linux NFS): server-write bytes (the 6th +/// `bytes:` field) + WRITE ops + COMMIT ops of the NFS mount holding the path, the +/// longest mount point prefix; an escaped mount point matches; a local mount is `None`. +#[test] +fn mountstats_parser() { + assert_eq!( + parse_mountstats(MOUNTSTATS, "/mnt/nas/movie.mkv"), + Some(613) + ); + assert_eq!( + parse_mountstats(MOUNTSTATS, "/mnt/nas/deep/a.iso"), + Some(9099) + ); + assert_eq!(parse_mountstats(MOUNTSTATS, "/mnt/my share/x"), Some(42)); + assert_eq!(parse_mountstats(MOUNTSTATS, "/mnt/nasty/x"), None); + assert_eq!(parse_mountstats(MOUNTSTATS, "/data/x"), None); + assert_eq!(parse_mountstats("", "/mnt/nas/x"), None); +} + +// Real syscalls through the production WritebackFile: written bytes become durable, +// the progress moves, and sync_all succeeds (LP13h and its macOS/Windows equivalents). +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +fn real_flush_progress() { + let dir = tempfile::tempdir().unwrap(); + let mut w = WritebackFile::create(&dir.path().join("real.bin")).unwrap(); + let flush = FlushProgress::new(Progress::new()); + w.set_flush_progress(flush.clone()); + let block = vec![9u8; 1024 * 1024]; + for _ in 0..8 { + w.write_all(&block).unwrap(); + } + w.sync_all().unwrap(); + assert!( + flush.progress().get() > 0, + "writes and flushes are progress" + ); + assert_eq!(flush.bytes_total(), 8 * 1024 * 1024); + assert_eq!(flush.bytes_durable(), 8 * 1024 * 1024, "everything durable"); +} + +/// LP13h `linux_real_flush_progress` (dev CI, runner tmpdir). +#[cfg(target_os = "linux")] +#[test] +fn linux_real_flush_progress() { + real_flush_progress(); +} + +/// LP13h, macOS equivalent (runs on qa `release-tests`). +#[cfg(target_os = "macos")] +#[test] +fn macos_real_flush_progress() { + real_flush_progress(); +} + +/// LP13h, Windows equivalent (runs on qa `release-tests`). +#[cfg(target_os = "windows")] +#[test] +fn windows_real_flush_progress() { + real_flush_progress(); +} + +#[cfg(unix)] +fn written_tmp() -> (tempfile::TempDir, File) { + let dir = tempfile::tempdir().unwrap(); + let mut f = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(dir.path().join("sp.bin")) + .unwrap(); + f.write_all(&[0x5au8; 64 * 1024]).unwrap(); + (dir, f) +} + +/// SP10, per spec (SS-12 XSH fdatasync(): "shall force all currently queued I/O +/// operations … to the synchronized I/O completion state"): a written file on the +/// runner's tmpdir syncs with 0. On tmpfs it is a no-op success, asserted the same. +#[cfg(target_os = "linux")] +#[test] +fn fdatasync_after_write_returns_ok_on_tmpdir() { + use std::os::unix::io::AsRawFd; + let (_d, f) = written_tmp(); + // SAFETY: a valid fd for the call's duration. + let rc = unsafe { libc::fdatasync(f.as_raw_fd()) }; + assert_eq!(rc, 0, "{}", io::Error::last_os_error()); +} + +/// SP11, per spec (SS-13 sync_file_range(2): WAIT_BEFORE | WRITE | WAIT_AFTER "is a +/// write-for-data-integrity operation"): the flags are accepted and the range reads back +/// from the device via O_DIRECT. Skipped (logged `SKIP`) on tmpfs/overlay or O_DIRECT EINVAL. +#[cfg(target_os = "linux")] +#[test] +fn sync_file_range_wait_after_completes_written_range() { + use std::io::Read; + use std::os::unix::fs::OpenOptionsExt; + use std::os::unix::io::AsRawFd; + let (dir, f) = written_tmp(); + let flags = libc::SYNC_FILE_RANGE_WAIT_BEFORE + | libc::SYNC_FILE_RANGE_WRITE + | libc::SYNC_FILE_RANGE_WAIT_AFTER; + // SAFETY: a valid fd for the call's duration. + let rc = unsafe { libc::sync_file_range(f.as_raw_fd(), 0, 64 * 1024, flags) }; + assert_eq!(rc, 0, "{}", io::Error::last_os_error()); + // SAFETY: statfs fills a zeroed struct for a valid fd. + let mut st: libc::statfs = unsafe { std::mem::zeroed() }; + assert_eq!(unsafe { libc::fstatfs(f.as_raw_fd(), &mut st) }, 0); + const TMPFS: i64 = 0x0102_1994; + const OVERLAY: i64 = 0x794c_7630; + if [TMPFS, OVERLAY].contains(&(st.f_type as i64)) { + eprintln!("SKIP sync_file_range: tmpdir is tmpfs/overlay"); + return; + } + let direct = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECT) + .open(dir.path().join("sp.bin")); + let mut direct = match direct { + Err(e) if e.raw_os_error() == Some(libc::EINVAL) => { + eprintln!("SKIP sync_file_range: O_DIRECT is EINVAL here"); + return; + } + r => r.unwrap(), + }; + // O_DIRECT needs an aligned buffer: over-allocate and read at a 4 KiB boundary. + let mut raw = vec![0u8; 64 * 1024 + 4096]; + let off = raw.as_ptr().align_offset(4096); + let buf = &mut raw[off..off + 64 * 1024]; + match direct.read_exact(buf) { + Err(e) if e.raw_os_error() == Some(libc::EINVAL) => { + eprintln!("SKIP sync_file_range: O_DIRECT read is EINVAL here"); + } + r => { + r.unwrap(); + assert!( + buf.iter().all(|&b| b == 0x5a), + "the range reached the device" + ); + } + } +} + +/// SP12, per spec (SS-14 fcntl(2) F_FULLFSYNC: "Does the same thing as fsync(2) then +/// asks the drive to flush all buffered data"): 0, or on a filesystem without it +/// (ENOTSUP) the `fsync` fallback is 0. Compiles on dev; runs on qa macOS. +#[cfg(target_os = "macos")] +#[test] +fn f_fullfsync_or_enotsup_falls_back() { + use std::os::unix::io::AsRawFd; + let (_d, f) = written_tmp(); + // SAFETY: a valid fd for the calls' duration. + let rc = unsafe { libc::fcntl(f.as_raw_fd(), libc::F_FULLFSYNC) }; + if rc != 0 { + let e = io::Error::last_os_error(); + assert_eq!(e.raw_os_error(), Some(libc::ENOTSUP), "{e}"); + assert_eq!(unsafe { libc::fsync(f.as_raw_fd()) }, 0); + } +} + +/// SP13, per spec (SS-15 FlushFileBuffers: "writes all the buffered information for a +/// specified file to the device"): succeeds on a written file. Compiles on dev; qa Windows. +#[cfg(target_os = "windows")] +#[test] +fn flushfilebuffers_ok() { + let dir = tempfile::tempdir().unwrap(); + let mut f = File::create(dir.path().join("sp.bin")).unwrap(); + f.write_all(&[0x5au8; 64 * 1024]).unwrap(); + // std's `File::sync_all` is `FlushFileBuffers` on Windows. + f.sync_all().unwrap(); +} + +/// Every failed flush wait is an error, never `Ok`, and the three causes stay apart: +/// E9056 (stall), `is_halt` (a Stop) and E9057 (worker lost). Moved from the per-OS +/// `durable_sync` mapping tests. +#[test] +fn every_wait_failure_is_a_distinct_error() { + use crate::error::{E_SYNC_WORKER_LOST, error_code}; + use crate::io::bounded::BoundedError; + let timeout = wait_failure(BoundedError::Timeout, "t"); + assert!(is_sync_timeout(&timeout), "{timeout}"); + assert_eq!(timeout.kind(), io::ErrorKind::TimedOut); + let halted = wait_failure(BoundedError::Halted, "t"); + assert!(crate::error::is_halt(&halted), "{halted}"); + let lost = wait_failure(BoundedError::WorkerLost, "t"); + assert_eq!(error_code(&lost), Some(E_SYNC_WORKER_LOST)); +} diff --git a/src/io/mod.rs b/src/io/mod.rs index 282c5fd3..cdd0d217 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -10,6 +10,7 @@ pub(crate) mod bounded; pub mod byte_prefetcher; pub mod file_sector_source; +mod flush; pub mod fsync; pub mod image_writer; pub mod sink; @@ -21,6 +22,7 @@ pub(crate) mod platform_macos; pub mod pipeline; +pub use flush::{FlushProgress, durable_sync_file}; pub use writeback_file::WritebackFile; pub use pipeline::{ diff --git a/src/io/pipeline.rs b/src/io/pipeline.rs index b5ea944d..da46f307 100644 --- a/src/io/pipeline.rs +++ b/src/io/pipeline.rs @@ -13,18 +13,32 @@ use std::time::{Duration, Instant}; use crossbeam_channel::{Sender, TrySendError, bounded}; use crate::error::Error; -use crate::halt::Halt; +use crate::halt::join_within; +use crate::halt::{Halt, Halted, Joined, Progress, SendOutcome, Stall, StallTimer, WAIT_SLICE}; -// Deadline for finish_with_halt's polling join. +/// `finish_with_halt`'s join window (T7): this long with no consumer progress is +/// `PipelineJoinTimeout`. A stall window, not a total (HR1). pub const JOIN_TIMEOUT_SECS: u64 = 600; -// Grace period after a halt/timeout fires in finish_with_halt, to let a "nearly done" consumer -// join cleanly. +// Grace after a halt or a join stall (T8, D2): a RUNNING consumer gets this long, then a +// CLOSING one waits in windows of it that re-arm on each progress bump. const FINISH_GRACE_SECS: u64 = 5; -// Halt-check cadence for the send loop; aliases crate::halt::POLL_INTERVAL. -use crate::halt::POLL_INTERVAL; -const SEND_HALT_CHECK_INTERVAL: Duration = POLL_INTERVAL; +// The join's stall window (T7) and grace (T8): a parameter so tests run in ms. +#[derive(Debug, Clone, Copy)] +pub(crate) struct JoinTiming { + pub(crate) join_window: Duration, + pub(crate) grace: Duration, +} + +impl Default for JoinTiming { + fn default() -> Self { + Self { + join_window: Duration::from_secs(JOIN_TIMEOUT_SECS), + grace: Duration::from_secs(FINISH_GRACE_SECS), + } + } +} // Cached FREEMKV_DEBUG=1 lookup — called per item on the mux hot loop, // so the env lock is paid once, not per call. @@ -69,28 +83,37 @@ mod state { pub const CLOSING: u8 = 2; } -// Spin-polls handle.is_finished() for `grace` before accepting the thread leak, so a "nearly -// done" consumer still joins cleanly. +fn join_result(r: thread::Result>) -> Result { + r.unwrap_or_else(|payload| Err(consumer_panicked(payload))) +} + +// The grace (T8, D2): wait `grace` for the consumer; then abandon a RUNNING one, but wait +// for a CLOSING one while its `progress` moves, `grace` re-arming on each bump (a committed +// close is never abandoned for being slow). Only a CLOSING stall leaks it. fn finish_with_grace( handle: thread::JoinHandle>, state: &Arc, + progress: &Progress, grace: Duration, leak_err: Error, + on_poll: &mut dyn FnMut(), ) -> Result { // `None` = a grace past `Instant`'s range: wait unbounded. - let deadline = Instant::now().checked_add(grace); - while deadline.is_none_or(|d| Instant::now() < d) { - if handle.is_finished() { - return match handle.join() { - Ok(result) => result, - Err(payload) => Err(consumer_panicked(payload)), - }; + let end = Instant::now().checked_add(grace); + let mut handle = handle; + loop { + let left = end.map_or(WAIT_SLICE, |e| e.saturating_duration_since(Instant::now())); + handle = match join_within(handle, left.min(WAIT_SLICE), None) { + Joined::Done(r) => return join_result(r), + Joined::Halted(h) | Joined::Pending(h) => h, + }; + on_poll(); + if end.is_some_and(|e| Instant::now() >= e) { + break; } - thread::sleep(POLL_INTERVAL); } - // Grace expired. CLAIM abandonment before dropping the handle so the leaked consumer - // skips further `apply`/`close()` once its wedged syscall returns. Compare-exchange, - // not a store: if it already committed to `close()`, wait for its result instead. + // CLAIM abandonment before dropping the handle so the leaked consumer skips further + // `apply`/`close()`. Compare-exchange, not a store: a consumer already CLOSING wins. if state .compare_exchange( state::RUNNING, @@ -98,42 +121,38 @@ fn finish_with_grace( Ordering::AcqRel, Ordering::Acquire, ) - .is_err() + .is_ok() { tracing::warn!( target: "freemkv::pipeline", - phase = "finish_with_halt_close_in_flight", - "pipeline consumer had already committed to finalising the output; \ - waiting for it rather than reporting an unfinalised output" + phase = "finish_with_halt_grace_expired", + "pipeline consumer did not finish within {}s grace period; abandoning thread \ + (output will not be finalised)", + grace.as_secs() ); - let close_deadline = Instant::now().checked_add(grace); - while close_deadline.is_none_or(|d| Instant::now() < d) { - if handle.is_finished() { - return match handle.join() { - Ok(result) => result, - Err(payload) => Err(consumer_panicked(payload)), - }; - } - thread::sleep(POLL_INTERVAL); - } - // Still finalising after a second grace window: leak and report the wedge. - // The output may end up finalised by the leaked thread — but that is now a - // wedged-`close()` case, not the check-then-finalise race. + // Dropping the handle detaches the thread until its kernel call returns. drop(handle); return Err(leak_err); } tracing::warn!( target: "freemkv::pipeline", - phase = "finish_with_halt_grace_expired", - "pipeline consumer did not finish within {}s grace period; abandoning thread \ - (output will not be finalised)", - FINISH_GRACE_SECS + phase = "finish_with_halt_close_in_flight", + "pipeline consumer had already committed to finalising the output; \ + waiting for it while it makes progress" ); - // Dropping `handle` without joining detaches the thread; the consumer keeps - // running until its kernel call returns or the process exits. This is the - // intentional "leak" from `finish_with_halt`'s contract, bounded by `abandoned`. - drop(handle); - Err(leak_err) + let mut timer = StallTimer::new(grace, progress); + loop { + handle = match join_within(handle, WAIT_SLICE, None) { + Joined::Done(r) => return join_result(r), + Joined::Halted(h) | Joined::Pending(h) => h, + }; + on_poll(); + if timer.poll(progress) == Stall::Expired { + // A close with no progress for a whole window: leak and report the wedge. + drop(handle); + return Err(leak_err); + } + } } /// Default channel depth for callers without a specific reason to @@ -209,6 +228,8 @@ pub struct Pipeline { /// this; [`Pipeline::consumer_failed`] exposes it to plain /// [`Pipeline::send`] users. failed: Arc, + /// The consumer's forward progress (T7): shared with the sink's output. + progress: Progress, } impl Pipeline { @@ -228,12 +249,25 @@ impl Pipeline { name: &str, depth: usize, sink: S, + ) -> Result { + Self::spawn_named_with_progress(name, depth, sink, Progress::new()) + } + + /// Like [`Pipeline::spawn_named`], with the consumer's [`Progress`] supplied, so the + /// sink's output (a [`WritebackFile`](crate::io::WritebackFile) given the same counter) + /// shares it (stop design §2.10 item 3). + pub fn spawn_named_with_progress>( + name: &str, + depth: usize, + sink: S, + progress: Progress, ) -> Result { let (tx, rx) = bounded::(depth); let state = Arc::new(AtomicU8::new(state::RUNNING)); let state_consumer = state.clone(); let failed = Arc::new(AtomicBool::new(false)); let failed_consumer = failed.clone(); + let progress_consumer = progress.clone(); let handle = thread::Builder::new() .name(name.into()) .spawn(move || -> Result { @@ -274,7 +308,9 @@ impl Pipeline { // path. let apply_start = debug.then(Instant::now); - match sink.apply(item) { + let applied = sink.apply(item); + progress_consumer.bump(); + match applied { Ok(Flow::Continue) => {} Ok(Flow::Stop) => { stopped = true; @@ -369,7 +405,10 @@ impl Pipeline { { return Err(Error::Halted); } - sink.close() + progress_consumer.bump(); + let closed = sink.close(); + progress_consumer.bump(); + closed } } }) @@ -380,9 +419,16 @@ impl Pipeline { handle, state, failed, + progress, }) } + /// The consumer's forward-progress counter: bumped per item applied and at + /// `close()` entry and exit; [`finish_with_halt`](Self::finish_with_halt) waits on it. + pub fn progress(&self) -> &Progress { + &self.progress + } + /// Whether the consumer's `apply` has already failed fatally. /// /// The consumer keeps draining the channel after an `apply` error (so the @@ -461,77 +507,51 @@ impl Pipeline { self.tx.try_send(item) } - /// Halt-aware bounded variant of [`Pipeline::send`]. Uses - /// [`crossbeam_channel::Sender::send_timeout`] so the producer - /// BLOCKS on consumer drain rather than polling, in slices of - /// [`SEND_HALT_CHECK_INTERVAL`]. + /// Halt-aware bounded variant of [`Pipeline::send`]: blocks on consumer drain via + /// [`Halt::send_timeout`], observing a cancel or a failed consumer within one + /// [`WAIT_SLICE`]. /// /// Returns `Ok(())` once the item lands in the channel, or `Err(item)` if the consumer - /// disconnected, the halt fired, or the deadline elapsed. NOT a `foo_with_X` variant of - /// [`Pipeline::send`] despite the name. + /// disconnected or failed, the halt fired, or the deadline elapsed. NOT a `foo_with_X` + /// variant of [`Pipeline::send`] despite the name. pub fn send_with_halt(&self, item: I, halt: &Halt, deadline: Duration) -> Result<(), I> { - use crossbeam_channel::SendTimeoutError; // `None` = a deadline past `Instant`'s range (e.g. `Duration::MAX`): unbounded. let end = Instant::now().checked_add(deadline); let mut pending = item; loop { - // The consumer's `apply` failed fatally: hand the item back now, since - // otherwise sends keep succeeding and the producer reads a whole UHD - // title before learning at `finish()` the write died on frame one. + // The consumer's `apply` failed fatally: hand the item back now, or the + // producer reads a whole title before `finish()` reports frame one's failure. if self.consumer_failed() { - if debug_enabled() { - tracing::debug!( - "Pipeline send_with_halt: consumer apply failed, returning item={}", - std::any::type_name::() - ); - } - return Err(pending); + return Err(self.refused(pending, "consumer apply failed")); } - // Pre-check the cheap exit conditions before parking. - if halt.is_cancelled() { - if debug_enabled() { - tracing::debug!( - "Pipeline send_with_halt: halt observed, returning item={}", - std::any::type_name::() - ); - } - return Err(pending); - } - let now = Instant::now(); - if end.is_some_and(|end| now >= end) { - if debug_enabled() { - tracing::debug!( - "Pipeline send_with_halt: deadline elapsed, returning item={}", - std::any::type_name::() - ); - } - return Err(pending); - } - // Wait for space-available or halt-check tick, whichever is sooner. - // send_timeout is kernel-wakeup based: recv on a saturated channel - // signals this thread the moment a slot opens up. - let slice = end.map_or(SEND_HALT_CHECK_INTERVAL, |end| { - SEND_HALT_CHECK_INTERVAL.min(end.saturating_duration_since(now)) + let slice = end.map_or(WAIT_SLICE, |end| { + end.saturating_duration_since(Instant::now()) + .min(WAIT_SLICE) }); - match self.tx.send_timeout(pending, slice) { - Ok(()) => return Ok(()), - Err(SendTimeoutError::Timeout(returned)) => { - pending = returned; - // loop: re-check halt + deadline, then park again + match halt.send_timeout(&self.tx, pending, slice) { + Ok(SendOutcome::Sent) => return Ok(()), + Ok(SendOutcome::TimedOut(back)) if end.is_some_and(|e| Instant::now() >= e) => { + return Err(self.refused(back, "deadline elapsed")); } - Err(SendTimeoutError::Disconnected(returned)) => { - if debug_enabled() { - tracing::debug!( - "Pipeline send_with_halt: consumer disconnected, item={}", - std::any::type_name::() - ); - } - return Err(returned); + Ok(SendOutcome::TimedOut(back)) => pending = back, + Ok(SendOutcome::Disconnected(back)) => { + return Err(self.refused(back, "consumer disconnected")); } + Err(Halted(back)) => return Err(self.refused(back, "halt observed")), } } } + fn refused(&self, item: I, why: &str) -> I { + if debug_enabled() { + tracing::debug!( + "Pipeline send_with_halt: {why}, returning item={}", + std::any::type_name::() + ); + } + item + } + /// Drop the producer-side channel and wait for the consumer /// thread to finish. Returns whatever the consumer's `close()` /// produced, or the first `apply` error, or — on consumer panic — @@ -544,6 +564,7 @@ impl Pipeline { handle, state: _, failed: _, + progress: _, } = self; // Explicit drop, although the destructure already drops `tx` // at end-of-scope. Being explicit keeps the intent obvious. @@ -554,54 +575,67 @@ impl Pipeline { } } - /// Halt-aware, deadline-bounded variant of [`Pipeline::finish`]. - /// Drops the producer-side channel, then polls - /// `JoinHandle::is_finished()`, checking the optional [`Halt`] - /// token and the [`JOIN_TIMEOUT_SECS`] deadline between slices. + /// Halt-aware, stall-bounded variant of [`Pipeline::finish`]. Drops the producer-side + /// channel, then waits for the consumer, checking the optional [`Halt`] every + /// [`WAIT_SLICE`]. It fails only after [`JOIN_TIMEOUT_SECS`] with no consumer progress + /// (see [`Pipeline::progress`]), never on total time. /// /// Returns `Ok(R)` on a clean exit, or one of [`Error::Halted`], /// [`Error::PipelineJoinTimeout`], [`Error::PipelineConsumerPanicked`] for the wedge cases - /// (leaks the consumer after a grace spin). NOT a `foo_with_X` variant of - /// [`Pipeline::finish`]. + /// (leaks the consumer after the grace). NOT a `foo_with_X` variant of [`Pipeline::finish`]. pub fn finish_with_halt(self, halt: Option<&Halt>) -> Result { + self.finish_with_halt_timing(halt, JoinTiming::default()) + } + + // `finish_with_halt_timing`, calling `on_poll` on every slice of the wait (the + // driver forwards flush progress from it, §4.5). + pub(crate) fn finish_with_halt_observed( + self, + halt: Option<&Halt>, + timing: JoinTiming, + on_poll: &mut dyn FnMut(), + ) -> Result { let Pipeline { tx, handle, state, failed: _, + progress, } = self; drop(tx); - let deadline = Instant::now() + Duration::from_secs(JOIN_TIMEOUT_SECS); + // T7: a stall window over the consumer's progress, not a total from here. + let mut timer = StallTimer::new(timing.join_window, &progress); + let mut handle = handle; loop { - if handle.is_finished() { - return match handle.join() { - Ok(result) => result, - Err(payload) => Err(consumer_panicked(payload)), - }; + handle = match join_within(handle, WAIT_SLICE, halt) { + Joined::Done(r) => return join_result(r), + Joined::Halted(h) => { + let (g, e) = (timing.grace, Error::Halted); + return finish_with_grace(h, &state, &progress, g, e, on_poll); + } + Joined::Pending(h) => h, + }; + on_poll(); + if timer.poll(&progress) == Stall::Expired { + let (g, e) = (timing.grace, Error::PipelineJoinTimeout); + return finish_with_grace(handle, &state, &progress, g, e, on_poll); } - if let Some(h) = halt - && h.is_cancelled() - { - return finish_with_grace( - handle, - &state, - Duration::from_secs(FINISH_GRACE_SECS), - Error::Halted, - ); - } - if Instant::now() >= deadline { - return finish_with_grace( - handle, - &state, - Duration::from_secs(FINISH_GRACE_SECS), - Error::PipelineJoinTimeout, - ); - } - thread::sleep(POLL_INTERVAL); } } + + // `finish_with_halt` with its windows as parameters (T7, T8). + pub(crate) fn finish_with_halt_timing( + self, + halt: Option<&Halt>, + timing: JoinTiming, + ) -> Result { + self.finish_with_halt_observed(halt, timing, &mut || {}) + } } +#[cfg(test)] +mod stop_tests; + #[cfg(test)] mod tests { use super::*; @@ -612,7 +646,14 @@ mod tests { let handle = thread::spawn(|| Ok::(3)); let state = Arc::new(AtomicU8::new(state::RUNNING)); let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - finish_with_grace(handle, &state, Duration::MAX, Error::Halted) + finish_with_grace( + handle, + &state, + &Progress::new(), + Duration::MAX, + Error::Halted, + &mut || {}, + ) })); assert!(matches!(r.expect("must not panic"), Ok(3))); } @@ -1607,7 +1648,14 @@ mod tests { }); let grace = Duration::from_secs(1); - let res = finish_with_grace(handle, &state, grace, Error::Halted); + let res = finish_with_grace( + handle, + &state, + &Progress::new(), + grace, + Error::Halted, + &mut || {}, + ); assert!( matches!(res, Ok(42)), "a finalise already in flight must be waited for, not abandoned: {res:?}" diff --git a/src/io/pipeline/stop_tests.rs b/src/io/pipeline/stop_tests.rs new file mode 100644 index 00000000..bb2aa28a --- /dev/null +++ b/src/io/pipeline/stop_tests.rs @@ -0,0 +1,324 @@ +//! Stop design §5.1 "Pipeline, IO and threads": the join and grace rules (T7, T8) on a +//! scaled clock — a 600 s window runs as [`WINDOW`], the 5 s grace as [`GRACE`]. +//! Stall pairs per §5.0: (a) progress at 0.5 × window for ≥ 4 windows is not a +//! timeout; (b) a stall fires within window (+ grace) + 1 s. Wall bounds keep ≥ 5× margin. + +use super::*; +use std::sync::atomic::AtomicUsize; + +const WINDOW: Duration = Duration::from_millis(200); +const GRACE: Duration = Duration::from_millis(200); +const SLACK: Duration = Duration::from_secs(1); + +struct Sum(u64); + +impl Sink for Sum { + type Output = u64; + fn apply(&mut self, v: u64) -> Result { + self.0 += v; + Ok(Flow::Continue) + } + fn close(self) -> Result { + Ok(self.0) + } +} + +fn timing() -> JoinTiming { + JoinTiming { + join_window: WINDOW, + grace: GRACE, + } +} + +// Blocks in `apply` until `release`; flags `started` so a test can sync on it. +struct Wedge { + release: Arc, + started: Arc, +} + +impl Sink for Wedge { + type Output = (); + fn apply(&mut self, _: u64) -> Result { + self.started.store(true, Ordering::SeqCst); + while !self.release.load(Ordering::SeqCst) { + thread::sleep(Duration::from_millis(5)); + } + Ok(Flow::Continue) + } + fn close(self) -> Result<(), Error> { + Ok(()) + } +} + +fn wedged() -> (Pipeline, Arc) { + let release = Arc::new(AtomicBool::new(false)); + let started = Arc::new(AtomicBool::new(false)); + let pipe = Pipeline::spawn( + DEFAULT_PIPELINE_DEPTH, + Wedge { + release: release.clone(), + started: started.clone(), + }, + ) + .unwrap(); + pipe.send(0).unwrap(); + wait_until(&started); + (pipe, release) +} + +fn wait_until(flag: &AtomicBool) { + let t = Instant::now(); + while !flag.load(Ordering::SeqCst) { + assert!(t.elapsed() < SLACK * 5, "the consumer never got there"); + thread::sleep(Duration::from_millis(1)); + } +} + +// `close()` runs a script: sleep `stall` without progress, then `steps` sleeps of +// `step`, bumping the shared progress after each. `in_close` flags the entry. +struct ScriptedClose { + progress: Progress, + in_close: Arc, + stall: Duration, + step: Duration, + steps: u32, +} + +impl Sink for ScriptedClose { + type Output = u32; + fn apply(&mut self, _: u64) -> Result { + Ok(Flow::Continue) + } + fn close(self) -> Result { + self.in_close.store(true, Ordering::SeqCst); + thread::sleep(self.stall); + for _ in 0..self.steps { + thread::sleep(self.step); + self.progress.bump(); + } + Ok(self.steps) + } +} + +fn scripted(stall: Duration, steps: u32) -> (Pipeline, Arc) { + let progress = Progress::new(); + let in_close = Arc::new(AtomicBool::new(false)); + let sink = ScriptedClose { + progress: progress.clone(), + in_close: in_close.clone(), + stall, + step: GRACE / 2, + steps, + }; + let pipe = Pipeline::spawn_named_with_progress("t-closing", 4, sink, progress).unwrap(); + (pipe, in_close) +} + +/// LP1 (pipeline half; the `*.partial` output rule is ST-L3's LP3/LP1 half): a +/// halted RUNNING consumer is abandoned after the RUNNING grace, `Halted` ≤ grace + 1 s. +#[test] +fn halted_running_consumer_abandoned_after_grace() { + let (pipe, release) = wedged(); + let halt = Halt::new(); + halt.cancel(); + let t = Instant::now(); + let r = pipe.finish_with_halt_timing(Some(&halt), timing()); + let took = t.elapsed(); + release.store(true, Ordering::SeqCst); + assert!(matches!(r, Err(Error::Halted)), "{r:?}"); + assert!( + took >= GRACE, + "abandoned before the grace ran out: {took:?}" + ); + assert!(took <= GRACE + SLACK, "grace overran: {took:?}"); +} + +/// LP2 (T8, D2): CLOSING entered before the cancel is Done. The caller waits for +/// `close()` past the 5 s (scaled) grace while it bumps the consumer's progress. +#[test] +fn closing_before_cancel_is_done() { + let (pipe, in_close) = scripted(Duration::ZERO, 8); + let halt = Halt::new(); + let h2 = halt.clone(); + let t = Instant::now(); + let caller = thread::spawn(move || pipe.finish_with_halt_timing(Some(&h2), timing())); + wait_until(&in_close); + halt.cancel(); + let r = caller.join().unwrap(); + assert!( + matches!(r, Ok(8)), + "a committed close is never abandoned: {r:?}" + ); + assert!( + t.elapsed() > GRACE * 2, + "the close outlived two grace windows" + ); +} + +/// LP4a (T7, stall pair a): one item applied per 0.5 × window for 4 windows is +/// progress, so the join waits and returns `Ok` — 600 s is a stall window, not a total. +#[test] +fn join_rearms_on_consumer_progress() { + let count = Arc::new(AtomicUsize::new(0)); + let sink = SlowSinkFor { + delay: WINDOW / 2, + count: count.clone(), + }; + let pipe = Pipeline::spawn(16, sink).unwrap(); + for _ in 0..8 { + pipe.send(()).unwrap(); + } + let t = Instant::now(); + let r = pipe.finish_with_halt_timing(None, timing()); + assert!( + matches!(r, Ok(8)), + "a progressing consumer is not a stall: {r:?}" + ); + assert!( + t.elapsed() > WINDOW * 3, + "the join outlived several windows" + ); +} + +struct SlowSinkFor { + delay: Duration, + count: Arc, +} + +impl Sink<()> for SlowSinkFor { + type Output = usize; + fn apply(&mut self, _: ()) -> Result { + thread::sleep(self.delay); + self.count.fetch_add(1, Ordering::SeqCst); + Ok(Flow::Continue) + } + fn close(self) -> Result { + Ok(self.count.load(Ordering::SeqCst)) + } +} + +/// LP4b (T7, stall pair b): a frozen consumer fails `PipelineJoinTimeout` (E9014) +/// within window + grace + 1 s, and not before the window. +#[test] +fn join_times_out_after_stall() { + let (pipe, release) = wedged(); + let t = Instant::now(); + let r = pipe.finish_with_halt_timing(None, timing()); + let took = t.elapsed(); + release.store(true, Ordering::SeqCst); + assert!(matches!(r, Err(Error::PipelineJoinTimeout)), "{r:?}"); + assert!(took >= WINDOW, "fired before the window: {took:?}"); + assert!(took <= WINDOW + GRACE + SLACK, "fired late: {took:?}"); +} + +/// LP5a (T8, non-halted): after a join stall the CLOSING window re-arms on each +/// progress bump, so a slow but progressing close returns its output. +#[test] +fn non_halted_closing_rearms() { + let (pipe, _in_close) = scripted(WINDOW * 3 / 2, 8); + let r = pipe.finish_with_halt_timing(None, timing()); + assert!( + matches!(r, Ok(8)), + "a progressing close is waited for: {r:?}" + ); +} + +// `close()` blocks until released: a frozen finalise. +struct FrozenClose { + in_close: Arc, + release: Arc, +} + +impl Sink for FrozenClose { + type Output = (); + fn apply(&mut self, _: u64) -> Result { + Ok(Flow::Continue) + } + fn close(self) -> Result<(), Error> { + self.in_close.store(true, Ordering::SeqCst); + while !self.release.load(Ordering::SeqCst) { + thread::sleep(Duration::from_millis(5)); + } + Ok(()) + } +} + +/// LP5b (T8, non-halted, stall pair b): a frozen close is leaked with E9014 after +/// the join window and one CLOSING grace with no progress. +#[test] +fn non_halted_closing_stalled_leaks() { + let (in_close, release) = (Arc::default(), Arc::::default()); + let sink = FrozenClose { + in_close: Arc::clone(&in_close), + release: release.clone(), + }; + let pipe = Pipeline::spawn(4, sink).unwrap(); + let t = Instant::now(); + let r = pipe.finish_with_halt_timing(None, timing()); + let took = t.elapsed(); + release.store(true, Ordering::SeqCst); + assert!(in_close.load(Ordering::SeqCst)); + assert!(matches!(r, Err(Error::PipelineJoinTimeout)), "{r:?}"); + assert!(took <= WINDOW + GRACE * 2 + SLACK, "fired late: {took:?}"); +} + +/// LP6 / G8: `send_with_halt` on a full channel returns the item on a cancel, +/// within a slice. Guard: per §5.9 G8, do not change without a design change. +#[test] +fn send_with_halt_full_channel_cancel() { + let release = Arc::new(AtomicBool::new(false)); + let started = Arc::new(AtomicBool::new(false)); + let sink = Wedge { + release: release.clone(), + started: started.clone(), + }; + let pipe = Pipeline::spawn(1, sink).unwrap(); + pipe.send(0).unwrap(); + wait_until(&started); + pipe.send(1).unwrap(); + let halt = Halt::new(); + let h2 = halt.clone(); + let canceller = thread::spawn(move || { + thread::sleep(Duration::from_millis(50)); + h2.cancel(); + }); + let t = Instant::now(); + let r = pipe.send_with_halt(7, &halt, Duration::from_secs(10)); + let took = t.elapsed(); + canceller.join().unwrap(); + release.store(true, Ordering::SeqCst); + let _ = pipe.finish(); + assert_eq!(r, Err(7), "the refused item comes back"); + assert!(took < Duration::from_millis(50) + SLACK / 2, "{took:?}"); +} + +/// G7: a non-halted pipeline that finishes normally returns `Ok` with its output +/// and adds no wait on the happy path. Guard (§5.9 G7). +#[test] +fn non_halted_happy_path_returns_at_once() { + let pipe = Pipeline::spawn(4, Sum(0)).unwrap(); + for i in 1..=4u64 { + pipe.send(i).unwrap(); + } + let t = Instant::now(); + let r = pipe.finish_with_halt(None); + assert!(matches!(r, Ok(10)), "{r:?}"); + assert!( + t.elapsed() < Duration::from_millis(500), + "{:?}", + t.elapsed() + ); +} + +/// The consumer bumps its progress per item applied and at `close()` entry and +/// exit (§2.5), so `progress()` has moved by at least items + 2 after the join. +#[test] +fn consumer_bumps_progress_per_item_and_close() { + let pipe = Pipeline::spawn(4, Sum(0)).unwrap(); + let progress = pipe.progress().clone(); + for i in 0..3u64 { + pipe.send(i).unwrap(); + } + pipe.finish().unwrap(); + assert!(progress.get() >= 5, "{}", progress.get()); +} diff --git a/src/io/writeback.rs b/src/io/writeback.rs index 8ecb3c39..df79762c 100644 --- a/src/io/writeback.rs +++ b/src/io/writeback.rs @@ -6,6 +6,9 @@ //! `pub use` below). Callers — and `WritebackFile` itself — are //! platform-independent. +/// The smallest writeback / flush chunk: 4 MiB (the §2.10 flusher's floor too). +pub(crate) const CHUNK_BYTES_MIN: u64 = 4 * 1024 * 1024; + #[cfg(target_os = "linux")] mod linux; #[cfg(not(target_os = "linux"))] diff --git a/src/io/writeback/linux.rs b/src/io/writeback/linux.rs index 233a1160..7a609dbc 100644 --- a/src/io/writeback/linux.rs +++ b/src/io/writeback/linux.rs @@ -14,7 +14,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; const ADAPTIVE_WINDOW: usize = 16; -const CHUNK_BYTES_MIN: u64 = 4 * 1024 * 1024; +use super::CHUNK_BYTES_MIN; const CHUNK_BYTES_MAX: u64 = 256 * 1024 * 1024; const ADAPTIVE_GROW_MS: u64 = 200; const ADAPTIVE_SHRINK_MS: u64 = 20; @@ -73,6 +73,10 @@ pub(crate) struct WritebackPipeline { wb_errno: Option, /// The `WAIT_AFTER` syscall; a seam so tests can inject a writeback error. wait_op: WaitOp, + /// T11's bound on one `WAIT_AFTER` ([`WAIT_AFTER_TIMEOUT`]); a seam for tests. + wait_timeout: Duration, + /// Each completed `WAIT_AFTER` is flush progress (§2.10 Linux local row). + flush: crate::io::flush::FlushProgress, } /// `(fd, off, len) -> 0 or errno`. @@ -136,9 +140,21 @@ impl WritebackPipeline { waitable, wb_errno: None, wait_op: sys_wait_after, + wait_timeout: WAIT_AFTER_TIMEOUT, + flush: crate::io::flush::FlushProgress::default(), } } + pub(crate) fn set_flush_progress(&mut self, flush: crate::io::flush::FlushProgress) { + self.flush = flush; + } + + /// NFS or degraded (`skip_wait`) on a waitable fd: nothing bounds the dirty pages, so + /// the §2.10 flusher runs. + pub(crate) fn needs_flusher(&self) -> bool { + self.waitable && self.skip_wait() + } + /// The latched writeback error, if any `WAIT_AFTER` failed. pub(crate) fn error(&self) -> Option { self.wb_errno.map(std::io::Error::from_raw_os_error) @@ -168,7 +184,8 @@ impl WritebackPipeline { } fn wait_after(&self, off: u64, len: u64) -> WaitOutcome { - wait_after_with_timeout(self.clone_for_worker(), self.fd, off, len, self.wait_op) + let worker = self.clone_for_worker(); + wait_after_with_timeout(worker, self.fd, off, len, self.wait_op, self.wait_timeout) } /// True if we should bypass the WAIT_AFTER + DONTNEED finalisation @@ -219,6 +236,7 @@ impl WritebackPipeline { match self.wait_after(prev_off, prev_len) { WaitOutcome::Done(ms) => { wait_ms = ms; + self.flush.add_durable(prev_len); let t_fadv = Instant::now(); unsafe { libc::posix_fadvise( @@ -378,14 +396,18 @@ impl WritebackPipeline { return; } match self.wait_after(prev_off, prev_len) { - WaitOutcome::Done(_ms) => unsafe { - libc::posix_fadvise( - self.fd, - prev_off as i64, - prev_len as i64, - libc::POSIX_FADV_DONTNEED, - ); - }, + WaitOutcome::Done(_ms) => { + self.flush.add_durable(prev_len); + // SAFETY: a valid fd; an advisory call. + unsafe { + libc::posix_fadvise( + self.fd, + prev_off as i64, + prev_len as i64, + libc::POSIX_FADV_DONTNEED, + ); + } + } WaitOutcome::Failed(errno) => self.latch_error(errno, prev_off, prev_len), WaitOutcome::TimedOut => { self.degraded.store(true, Ordering::Relaxed); @@ -442,11 +464,12 @@ fn wait_after_with_timeout( off: u64, len: u64, op: WaitOp, + timeout: Duration, ) -> WaitOutcome { let started = Instant::now(); // The owned clone keeps the file description alive until the worker drops it; // without one (try_clone failed) the raw fd carries the fd-reuse risk on timeout. - let result = crate::io::bounded::bounded_syscall(None, WAIT_AFTER_TIMEOUT, move || { + let result = crate::io::bounded::bounded_syscall(None, timeout, move || { let fd = worker_file .as_ref() .map(|f| f.as_raw_fd()) @@ -768,4 +791,26 @@ mod tests { // The clone fd must be valid (non-negative on Unix). assert!(clone_fd >= 0, "clone fd must be non-negative"); } + + fn slow_wait(_fd: RawFd, _off: u64, _len: u64) -> i32 { + std::thread::sleep(Duration::from_millis(300)); + 0 + } + + /// LP15 / G6 (T11): a `WAIT_AFTER` that outlives its bound degrades the pipeline + /// (no more waits) and never fails a write, `finalize` or `error()`. Guard: per + /// the stop design §3.1 T11, do not change without a design change. + #[test] + fn wait_after_timeout_degrades_never_fails() { + let (_f, mut p) = local_pipeline(CHUNK_BYTES_MIN); + p.wait_op = slow_wait; + p.wait_timeout = Duration::from_millis(30); + p.note_progress(CHUNK_BYTES_MIN); + p.note_progress(2 * CHUNK_BYTES_MIN); + assert!(p.skip_wait(), "a timed-out WAIT_AFTER degrades"); + assert_eq!(p.chunk_bytes, CHUNK_BYTES_MIN); + p.note_progress(3 * CHUNK_BYTES_MIN); + p.finalize(); + assert!(p.error().is_none(), "a timeout is never an error"); + } } diff --git a/src/io/writeback/noop.rs b/src/io/writeback/noop.rs index 785f0095..94a83e19 100644 --- a/src/io/writeback/noop.rs +++ b/src/io/writeback/noop.rs @@ -18,6 +18,11 @@ impl WritebackPipeline { pub(crate) fn note_progress(&mut self, _pos: u64) {} pub(crate) fn handle_seek(&mut self, _new_pos: u64) {} pub(crate) fn finalize(&mut self) {} + pub(crate) fn set_flush_progress(&mut self, _flush: crate::io::flush::FlushProgress) {} + // No bounded writeback here: the §2.10 flusher always runs. + pub(crate) fn needs_flusher(&self) -> bool { + true + } pub(crate) fn error(&self) -> Option { self.wb_errno.map(std::io::Error::from_raw_os_error) } diff --git a/src/io/writeback_file/flusher.rs b/src/io/writeback_file/flusher.rs new file mode 100644 index 00000000..635f7ded --- /dev/null +++ b/src/io/writeback_file/flusher.rs @@ -0,0 +1,241 @@ +//! The in-write flusher (stop design §2.10 items 1–4): one worker per file makes written +//! bytes durable in chunks of `C` while the writer runs. `C` halves (to a floor) when one +//! chunk flush is slow; the writer waits, halt-aware, while more than `2 × C` is unflushed; +//! a flusher with no progress for the stall window latches `SyncTimeout` (sticky). + +use std::fs::File; +use std::io; +use std::sync::{Arc, Condvar, Mutex, MutexGuard}; +use std::time::Instant; + +use crate::error::Error; +use crate::halt::{Halt, Stall, StallTimer, WAIT_SLICE}; +use crate::io::flush::{FlushOps, FlushProgress, FlushTiming, Sampler}; + +pub(super) struct Flusher { + shared: Arc, + timing: FlushTiming, + ops: Arc, +} + +struct Shared { + state: Mutex, + cv: Condvar, +} + +struct State { + // Bytes handed to the worker, and bytes it has made durable (both in written-byte units). + requested: u64, + flushed: u64, + chunk: u64, + // The first failure: a flush error, or a stall latched as `SyncTimeout`. Sticky. + error: Option, + stop: bool, + flush: FlushProgress, +} + +#[derive(Clone, Copy)] +enum Latched { + Os(io::ErrorKind, Option), + SyncTimeout, +} + +impl Latched { + fn of(e: &io::Error) -> Self { + Self::Os(e.kind(), e.raw_os_error()) + } + fn error(self) -> io::Error { + match self { + Latched::Os(_, Some(errno)) => io::Error::from_raw_os_error(errno), + Latched::Os(kind, None) => io::Error::from(kind), + Latched::SyncTimeout => Error::SyncTimeout.into(), + } + } +} + +impl Flusher { + /// Start the worker on a clone of `file`; `base` bytes are already accounted for. + pub(super) fn spawn( + file: &File, + ops: Arc, + timing: FlushTiming, + flush: FlushProgress, + base: u64, + ) -> io::Result { + let shared = Arc::new(Shared { + state: Mutex::new(State { + requested: base, + flushed: base, + chunk: timing.chunk_min, + error: None, + stop: false, + flush, + }), + cv: Condvar::new(), + }); + let worker = (file.try_clone()?, ops.clone(), shared.clone()); + std::thread::Builder::new() + .name("freemkv-flusher".into()) + .spawn(move || run(worker.0, &*worker.1, &worker.2, timing))?; + Ok(Self { + shared, + timing, + ops, + }) + } + + fn lock(&self) -> MutexGuard<'_, State> { + self.shared.state.lock().unwrap_or_else(|e| e.into_inner()) + } + + pub(super) fn set_flush_progress(&self, flush: FlushProgress) { + self.lock().flush = flush; + } + + #[cfg(test)] + pub(super) fn chunk(&self) -> u64 { + self.lock().chunk + } + + pub(super) fn error(&self) -> Option { + self.lock().error.map(Latched::error) + } + + /// `written` bytes accepted so far: hand the worker a chunk once `C` has accumulated. + pub(super) fn note_written(&self, written: u64) { + let mut st = self.lock(); + if written.saturating_sub(st.requested) >= st.chunk { + st.requested = written; + self.shared.cv.notify_all(); + } + } + + /// Backpressure before a write: wait while more than `2 × C` is unflushed. + pub(super) fn wait_room(&self, written: u64, halt: Option<&Halt>) -> io::Result<()> { + self.wait(halt, halt, |st| { + written.saturating_sub(st.flushed) <= 2 * st.chunk + }) + } + + /// Hand over everything written and wait until it is durable. `abort` ends the wait + /// at once; `stop` (a Stop pending without aborting, e.g. a container's close) only + /// narrows progress to this file's own chunk completions. + pub(super) fn drain( + &self, + written: u64, + abort: Option<&Halt>, + stop: Option<&Halt>, + ) -> io::Result<()> { + { + let mut st = self.lock(); + if written > st.requested { + st.requested = written; + self.shared.cv.notify_all(); + } + } + self.wait(abort, stop, |st| st.flushed >= written) + } + + // Wait for `done` under the stall timer on the shared progress; `abort` ends it. A + // sampled counter (NFS) counts only while no Stop is pending: other writers on the + // mount move it too. Expiry latches `SyncTimeout` (sticky). + fn wait( + &self, + abort: Option<&Halt>, + stop: Option<&Halt>, + done: impl Fn(&State) -> bool, + ) -> io::Result<()> { + let progress = self.lock().flush.progress().clone(); + let mut timer = StallTimer::new(self.timing.stall, &progress); + let mut sampler = Sampler::new(self.timing.sample_every, self.timing.stall); + let mut st = self.lock(); + loop { + if let Some(e) = st.error { + return Err(e.error()); + } + if done(&st) { + return Ok(()); + } + st = self + .shared + .cv + .wait_timeout(st, WAIT_SLICE) + .unwrap_or_else(|e| e.into_inner()) + .0; + if let Some(h) = abort { + h.check().map_err(io::Error::from)?; + } + drop(st); + let stopping = stop.is_some_and(Halt::is_cancelled); + if sampler.tick(&*self.ops).is_some() && !stopping { + progress.bump(); + } + let stalled = timer.poll(&progress) == Stall::Expired; + st = self.lock(); + if stalled && !done(&st) { + tracing::error!( + target: "freemkv::io", + stall_s = self.timing.stall.as_secs(), + "WritebackFile flusher made no progress for the stall window" + ); + st.error.get_or_insert(Latched::SyncTimeout); + } + } + } +} + +impl Drop for Flusher { + // The worker ends once idle; one blocked in a flush ends when the call returns. + fn drop(&mut self) { + self.lock().stop = true; + self.shared.cv.notify_all(); + } +} + +fn run(file: File, ops: &dyn FlushOps, shared: &Shared, timing: FlushTiming) { + let lock = || shared.state.lock().unwrap_or_else(|e| e.into_inner()); + loop { + let target = { + let mut st = lock(); + while !st.stop && st.error.is_none() && st.requested <= st.flushed { + st = shared.cv.wait(st).unwrap_or_else(|e| e.into_inner()); + } + if st.stop || st.error.is_some() { + return; + } + st.requested + }; + let started = Instant::now(); + let r = ops.chunk(&file); + let took = started.elapsed(); + let mut st = lock(); + match r { + Ok(()) => { + let newly = target.saturating_sub(st.flushed); + st.flushed = st.flushed.max(target); + st.flush.add_durable(newly); + let before = st.chunk; + st.chunk = crate::io::flush::next_size( + before, + took, + timing.slow_chunk, + timing.chunk_min, + timing.chunk_max, + ); + if st.chunk < before { + tracing::info!( + target: "freemkv::io", + chunk = st.chunk, + took_ms = took.as_millis() as u64, + "WritebackFile flusher: slow chunk flush, chunk halved" + ); + } + } + Err(e) => { + tracing::error!(target: "freemkv::io", error = %e, "WritebackFile chunk flush failed"); + st.error.get_or_insert(Latched::of(&e)); + } + } + shared.cv.notify_all(); + } +} diff --git a/src/io/writeback_file/linux.rs b/src/io/writeback_file/linux.rs index 3778d78d..1f4ef1e8 100644 --- a/src/io/writeback_file/linux.rs +++ b/src/io/writeback_file/linux.rs @@ -4,14 +4,11 @@ //! without growing the reported file size. Reduces extent //! fragmentation on large sequential writes (mux output on NFS in //! particular). -//! - `durable_sync`: `fsync` wrapped in -//! [`crate::io::bounded::bounded_syscall`] with a 60 s deadline so a -//! wedged NFS server can't trap the calling thread indefinitely. +//! +//! The durable flush lives in [`crate::io::flush`] (stop design §2.10). use std::fs::File; -use std::io; use std::os::unix::io::AsRawFd; -use std::time::Duration; /// Pre-reserve extents for `size_bytes` of upcoming sequential writes. /// Best-effort: a non-zero rc is logged but not propagated, since the @@ -28,130 +25,3 @@ pub(super) fn preallocate(file: &File, size_bytes: u64) { rc == 0 ); } - -// Bounded `fsync` (60 s deadline); a zero return is the only durability signal POSIX gives, so -// timeout/halt/lost-worker all map to `Err`. -pub(super) fn durable_sync(file: &File) -> io::Result<()> { - // Clone so a leaked worker thread retains a valid fd even after the - // original File is closed and its fd number is reused. - let owned = match file.try_clone() { - Ok(f) => Some(f), - Err(e) => { - let fd = file.as_raw_fd(); - tracing::warn!( - target: "mux", - "WritebackFile::sync_all fd={fd}: try_clone failed ({e}), fsync worker will use raw fd (fd-reuse risk on timeout)" - ); - None - } - }; - let fallback_fd = file.as_raw_fd(); - match crate::io::bounded::bounded_syscall( - None, - Duration::from_secs(60), - move || -> io::Result<()> { - let fd = owned.as_ref().map(|f| f.as_raw_fd()).unwrap_or(fallback_fd); - let rc = unsafe { libc::fsync(fd) }; - // `owned` (if Some) drops here, releasing the cloned fd. - if rc == 0 { - Ok(()) - } else { - Err(io::Error::last_os_error()) - } - }, - ) { - Ok(inner) => inner, - Err(e) => bounded_failure_to_result(e), - } -} - -#[cfg(test)] -#[cfg(target_os = "linux")] -mod tests { - use super::*; - use tempfile::NamedTempFile; - - // Regression for the fd-reuse / use-after-close fix in `durable_sync`. Structural test only - // (the race is non-deterministic and not cleanly testable). - #[test] - fn durable_sync_worker_uses_owned_clone_with_distinct_fd() { - let f = NamedTempFile::new().expect("tempfile create"); - let original_fd = f.as_file().as_raw_fd(); - - // try_clone must succeed for a normal local file. - let owned = f - .as_file() - .try_clone() - .expect("try_clone must succeed for a local tempfile"); - let clone_fd = owned.as_raw_fd(); - - // The clone must be a distinct fd (dup'd, not aliased). - assert_ne!( - clone_fd, original_fd, - "owned clone must have a distinct fd number — not an alias of the original" - ); - assert!(clone_fd >= 0, "clone fd must be a valid non-negative fd"); - - // durable_sync must complete without error on the local tempfile. - durable_sync(f.as_file()).expect("durable_sync must return Ok on a local tempfile"); - } -} - -// Maps a bounded-fsync failure onto the `io::Error` `durable_sync` returns. Every arm means "no -// sync observably ran"; mirrors the macOS `F_FULLFSYNC` mapping. -fn bounded_failure_to_result(e: crate::io::bounded::BoundedError) -> io::Result<()> { - match e { - crate::io::bounded::BoundedError::Timeout => { - tracing::error!( - target: "mux", - "WritebackFile::sync_all fsync timed out after 60s; data NOT durably flushed, kernel will flush on close" - ); - Err(crate::error::Error::SyncTimeout.into()) - } - crate::io::bounded::BoundedError::Halted => { - tracing::warn!( - target: "mux", - "WritebackFile::sync_all fsync skipped (halt requested); data NOT durably flushed, kernel will flush on close" - ); - Err(crate::error::Error::Halted.into()) - } - crate::io::bounded::BoundedError::WorkerLost => { - tracing::error!( - target: "mux", - "WritebackFile::sync_all fsync worker lost before completion; data NOT durably flushed, kernel will flush on close" - ); - // EIO, matching the macOS sibling: a consumer distinguishing these - // three failures does so on the same value on every platform. - // ErrorKind::Other carries nothing a caller can branch on. - Err(crate::error::Error::SyncWorkerLost.into()) - } - } -} - -#[cfg(test)] -mod bounded_failure_tests { - use super::*; - use crate::io::bounded::BoundedError; - - /// Every bounded-fsync failure must be an error. Asserted per variant rather - /// than as a loop so a new variant defaulting to Ok cannot slip through. - #[test] - fn no_bounded_fsync_failure_maps_to_ok() { - assert_eq!( - bounded_failure_to_result(BoundedError::Timeout) - .expect_err("a timed-out fsync must be an error") - .kind(), - io::ErrorKind::TimedOut - ); - assert_eq!( - bounded_failure_to_result(BoundedError::Halted) - .expect_err("a halted fsync must be an error") - .kind(), - io::ErrorKind::Interrupted - ); - assert!( - bounded_failure_to_result(BoundedError::WorkerLost).is_err(), - "a lost fsync worker must be an error" - ); - } -} diff --git a/src/io/writeback_file/macos.rs b/src/io/writeback_file/macos.rs index 24909caa..0cefdec1 100644 --- a/src/io/writeback_file/macos.rs +++ b/src/io/writeback_file/macos.rs @@ -2,23 +2,16 @@ //! //! - `preallocate`: `fcntl(F_PREALLOCATE)`, macOS's fallocate-equiv. //! Tries a contiguous allocation first, falls back to scattered extents. -//! - `durable_sync`: `fcntl(F_FULLFSYNC)` wrapped in -//! [`crate::io::bounded::bounded_syscall`] with a 60 s deadline; falls -//! back to plain `fsync` if unsupported. +//! +//! The durable flush (`F_FULLFSYNC`) lives in [`crate::io::flush`] (stop design §2.10). use std::fs::File; -use std::io; use std::os::unix::io::AsRawFd; -use std::time::Duration; use crate::io::platform_macos::{ F_ALLOCATEALL, F_ALLOCATECONTIG, F_PEOFPOSMODE, F_PREALLOCATE, Fstore, }; -/// `fcntl(F_FULLFSYNC)` opcode. Documented in `man 2 fcntl` on macOS; -/// not in the `libc` crate as a named constant. -const F_FULLFSYNC: libc::c_int = 51; - pub(super) fn preallocate(file: &File, size_bytes: u64) { // Clamp to the signed `off_t` range; an unchecked `as off_t` cast // would wrap a >= 2^63 size to a negative length. @@ -44,176 +37,3 @@ pub(super) fn preallocate(file: &File, size_bytes: u64) { rc != -1 ); } - -// fd-reuse safety: a leaked worker thread must not hit a recycled fd number, so we try_clone an -// owned File into the closure instead of a bare fd. -pub(super) fn durable_sync(file: &File) -> io::Result<()> { - // Clone so a leaked worker thread retains a valid fd even after the - // original File is closed and its fd number is reused. - let owned = match file.try_clone() { - Ok(f) => Some(f), - Err(e) => { - let fd = file.as_raw_fd(); - tracing::warn!( - target: "mux", - "WritebackFile::sync_all fd={fd}: try_clone failed ({e}), F_FULLFSYNC worker will use raw fd (fd-reuse risk on timeout)" - ); - None - } - }; - let fallback_fd = file.as_raw_fd(); - match crate::io::bounded::bounded_syscall( - None, - Duration::from_secs(60), - move || -> io::Result<()> { - let fd = owned.as_ref().map(|f| f.as_raw_fd()).unwrap_or(fallback_fd); - // Try F_FULLFSYNC first. If it isn't supported on this - // filesystem (older HFS, some network mounts) fall back to - // plain fsync — better than nothing. - let rc = unsafe { libc::fcntl(fd, F_FULLFSYNC, 0) }; - if rc == 0 { - // `owned` (if Some) drops here, releasing the cloned fd. - return Ok(()); - } - let err = io::Error::last_os_error(); - if err.raw_os_error() == Some(libc::ENOTSUP) { - let rc = unsafe { libc::fsync(fd) }; - // `owned` drops here. - if rc == 0 { - Ok(()) - } else { - Err(io::Error::last_os_error()) - } - } else { - // `owned` drops here. - Err(err) - } - }, - ) { - Ok(inner) => inner, - Err(e) => bounded_failure_to_result(e), - } -} - -// Maps a BoundedError onto the io::Error durable_sync returns. Every arm means no sync -// observably ran — never mapped to Ok. No message text (no user-facing English). -fn bounded_failure_to_result(e: crate::io::bounded::BoundedError) -> io::Result<()> { - match e { - crate::io::bounded::BoundedError::Timeout => { - tracing::error!( - target: "mux", - "WritebackFile::sync_all F_FULLFSYNC timed out after 60s; data NOT durably flushed, kernel will flush on close" - ); - Err(crate::error::Error::SyncTimeout.into()) - } - crate::io::bounded::BoundedError::Halted => { - tracing::warn!( - target: "mux", - "WritebackFile::sync_all F_FULLFSYNC skipped (halt requested); data NOT durably flushed, kernel will flush on close" - ); - Err(crate::error::Error::Halted.into()) - } - crate::io::bounded::BoundedError::WorkerLost => { - tracing::error!( - target: "mux", - "WritebackFile::sync_all F_FULLFSYNC worker lost before completion; data NOT durably flushed, kernel will flush on close" - ); - Err(crate::error::Error::SyncWorkerLost.into()) - } - } -} - -#[cfg(test)] -#[cfg(target_os = "macos")] -mod tests { - use super::*; - use tempfile::NamedTempFile; - - // Regression for the fd-reuse fix in durable_sync: try_clone succeeds and yields a distinct - // fd. The real race is non-deterministic; this structural check is the accepted substitute. - #[test] - fn durable_sync_worker_uses_owned_clone_with_distinct_fd() { - let f = NamedTempFile::new().expect("tempfile create"); - let original_fd = f.as_file().as_raw_fd(); - - // try_clone must succeed for a normal local file. - let owned = f - .as_file() - .try_clone() - .expect("try_clone must succeed for a local tempfile"); - let clone_fd = owned.as_raw_fd(); - - // The clone must be a distinct fd (dup'd, not aliased). - assert_ne!( - clone_fd, original_fd, - "owned clone must have a distinct fd number — not an alias of the original" - ); - assert!(clone_fd >= 0, "clone fd must be a valid non-negative fd"); - - // durable_sync must complete without error on the local tempfile. - durable_sync(f.as_file()).expect("durable_sync must return Ok on a local tempfile"); - } - - // Every BoundedError arm means no sync observably ran; asserted on the concrete - // ErrorKind/errno so a future arm reverting to Ok(()) fails here. - #[test] - fn every_bounded_failure_is_reported_as_an_error() { - use crate::io::bounded::BoundedError; - - let timeout = bounded_failure_to_result(BoundedError::Timeout) - .expect_err("a timed-out F_FULLFSYNC must be an error"); - assert_eq!( - timeout.kind(), - io::ErrorKind::TimedOut, - "a timed-out F_FULLFSYNC must not be reported as a completed sync" - ); - - let halted = bounded_failure_to_result(BoundedError::Halted) - .expect_err("a halted F_FULLFSYNC must be an error"); - assert_eq!( - halted.kind(), - io::ErrorKind::Interrupted, - "a halted F_FULLFSYNC must not be reported as a completed sync" - ); - - // The three arms must be DISTINGUISHABLE, not merely non-Ok. Each carries its - // own numeric code via the "E" prefix `From for io::Error` mints — - // a bare `ErrorKind` can't be classified, so a user cancel used to read as I/O failure. - let lost = bounded_failure_to_result(BoundedError::WorkerLost) - .expect_err("a lost F_FULLFSYNC worker must be an error"); - assert!( - lost.to_string() - .starts_with(&format!("E{}", crate::error::E_SYNC_WORKER_LOST)), - "a lost worker must be identifiable, got {lost}" - ); - assert!( - timeout - .to_string() - .starts_with(&format!("E{}", crate::error::E_SYNC_TIMEOUT)), - "a timeout must be distinguishable from a lost worker, got {timeout}" - ); - assert!( - crate::error::is_halt(&halted), - "a halt must satisfy the crate's own is_halt(), or the CLI reports a \ - user cancel as a failure; got {halted}" - ); - } - - // A WritebackFile::sync_all hitting any of these arms must surface Err, - // not a silent Ok. Pinned here since the timeout isn't deterministically - // inducible in a unit test. - #[test] - fn bounded_failures_are_never_mapped_to_ok() { - use crate::io::bounded::BoundedError; - for e in [ - BoundedError::Timeout, - BoundedError::Halted, - BoundedError::WorkerLost, - ] { - assert!( - bounded_failure_to_result(e).is_err(), - "a bounded F_FULLFSYNC failure must never map to Ok" - ); - } - } -} diff --git a/src/io/writeback_file/mod.rs b/src/io/writeback_file/mod.rs index 76aabd89..5135bbd6 100644 --- a/src/io/writeback_file/mod.rs +++ b/src/io/writeback_file/mod.rs @@ -5,6 +5,7 @@ //! Platform-specific preallocation/durable-flush primitives live in per-OS sibling modules, //! dispatched via the cfg-gated `mod` decls below. +mod flusher; #[cfg(target_os = "linux")] mod linux; #[cfg(target_os = "macos")] @@ -26,8 +27,12 @@ use windows as platform; use std::fs::{File, OpenOptions}; use std::io::{self, Seek, SeekFrom, Write}; use std::path::Path; +use std::sync::Arc; +use super::flush::{DurableTiming, FlushOps, FlushProgress, FlushTiming}; use super::writeback::WritebackPipeline; +use crate::halt::Halt; +use flusher::Flusher; // Linux sync_file_range granularity. 32 MiB measured best on a 1 GbE NFS // mount (8/64/128 MiB all worse); override via FREEMKV_WRITEBACK_CHUNK_MIB. @@ -57,6 +62,19 @@ pub struct WritebackFile { seek_count: u64, /// Sum of |delta| over all position-moving seeks, in bytes. seek_bytes: u64, + /// Shared flush counters (§2.10): accepted writes and completed flushes. + flush: FlushProgress, + /// Bytes accepted so far (any position): what the flusher must make durable. + written: u64, + /// The in-write flusher (§2.10): started at the first write where the OS page cache + /// has no bounded writeback of its own (Linux NFS/degraded, macOS, Windows). + flusher: Option, + /// Whether a flusher applies at all (a regular file); tests force it. + flushable: bool, + force_flusher: bool, + ops: Arc, + timing: FlushTiming, + halt: Option, } impl WritebackFile { @@ -64,18 +82,121 @@ impl WritebackFile { /// once so the pipeline starts tracking from wherever the file /// already is (typically 0 for fresh files; non-zero for resumed /// or appended files). - pub fn new(mut file: File) -> io::Result { + pub fn new(file: File) -> io::Result { + let ops = super::flush::os_ops(&file); + Self::with_ops(file, ops, FlushTiming::default(), false) + } + + fn with_ops( + mut file: File, + ops: Arc, + timing: FlushTiming, + force_flusher: bool, + ) -> io::Result { let pos = file.stream_position()?; - let pipeline = WritebackPipeline::new(&file, pos, writeback_chunk_bytes()); + let mut pipeline = WritebackPipeline::new(&file, pos, writeback_chunk_bytes()); + let flush = FlushProgress::default(); + pipeline.set_flush_progress(flush.clone()); + flush.note_total(pos); + let flushable = file.metadata().is_ok_and(|m| m.is_file()); Ok(Self { file, pipeline, pos, seek_count: 0, seek_bytes: 0, + flush, + written: 0, + flusher: None, + flushable, + force_flusher, + ops, + timing, + halt: None, }) } + /// The token a write blocked on flush backpressure, or [`sync_all`](Self::sync_all), + /// observes: a cancel returns [`E_HALTED`](crate::error::E_HALTED) at once. `flush` + /// (a container's `close()`) is not interrupted: a committed close completes. + pub fn set_halt(&mut self, halt: Halt) { + self.halt = Some(halt); + } + + /// Share `flush`'s counters (§2.10 item 3): give it the pipeline consumer's + /// [`Progress`](crate::halt::Progress) so a flushing `close()` counts as progress. + /// Call before the first write. + pub fn set_flush_progress(&mut self, flush: FlushProgress) { + flush.note_total(self.flush.bytes_total()); + self.pipeline.set_flush_progress(flush.clone()); + if let Some(f) = &self.flusher { + f.set_flush_progress(flush.clone()); + } + self.flush = flush; + } + + /// This file's flush counters: accepted writes and completed flushes. + pub fn flush_progress(&self) -> &FlushProgress { + &self.flush + } + + // Flusher mode over `ops` whatever the OS, with the timing as a parameter. + #[cfg(test)] + pub(crate) fn with_flush_ops( + file: File, + ops: Arc, + timing: FlushTiming, + ) -> io::Result { + Self::with_ops(file, ops, timing, true) + } + + // The flusher's current chunk size. + #[cfg(test)] + pub(crate) fn chunk_bytes(&self) -> u64 { + self.flusher + .as_ref() + .map_or(self.timing.chunk_min, Flusher::chunk) + } + + // Start the flusher once this file needs one (Linux: only NFS or a degraded pipeline). + fn ensure_flusher(&mut self) { + let wanted = self.force_flusher || (self.flushable && self.pipeline.needs_flusher()); + if self.flusher.is_some() || !wanted { + return; + } + let (ops, flush) = (self.ops.clone(), self.flush.clone()); + match Flusher::spawn(&self.file, ops, self.timing, flush, self.written) { + Ok(f) => self.flusher = Some(f), + Err(e) => tracing::warn!( + target: "freemkv::io", + error = %e, + "WritebackFile flusher did not start; flushing at sync_all only" + ), + } + } + + // Before a write: a latched error, then the flusher's backpressure (halt-aware). + fn before_write(&mut self) -> io::Result<()> { + self.check_writeback()?; + self.ensure_flusher(); + match &self.flusher { + Some(f) => f.wait_room(self.written, self.halt.as_ref()), + None => Ok(()), + } + } + + // After `n` bytes were accepted: progress ("writing"), and a chunk for the flusher. + fn after_write(&mut self, n: usize) { + self.pos += n as u64; + self.written += n as u64; + self.pipeline.note_progress(self.pos); + self.flush.note_total(self.pos); + self.flush.progress().bump(); + if let Some(f) = &self.flusher { + f.note_written(self.written); + } + } + /// Create a new file at `path` (truncating any existing contents) /// and wrap it. Convenience for the common /// `File::create(path)` + `WritebackFile::new(file)` pair so callers @@ -109,12 +230,13 @@ impl WritebackFile { Self::new(file) } - /// Drain in-flight writeback then issue a full fsync, in place of `File::sync_all`. Bounded - /// by [`crate::io::bounded::bounded_syscall`] on Linux/macOS (60 s), so a wedged NFS server - /// cannot trap the caller indefinitely; `Ok(())` is a durability barrier. Failure is - /// [`E_SYNC_TIMEOUT`](crate::error::E_SYNC_TIMEOUT), [`E_HALTED`](crate::error::E_HALTED), - /// or [`E_SYNC_WORKER_LOST`](crate::error::E_SYNC_WORKER_LOST), or the OS error of a failed - /// chunk writeback (sticky: later writes fail with it too). + /// Drain in-flight writeback, flush the rest, then the final flush + /// ([`durable_sync_file`](crate::io::durable_sync_file)), in place of `File::sync_all`. + /// It waits while the flush makes progress and fails with + /// [`E_SYNC_TIMEOUT`](crate::error::E_SYNC_TIMEOUT) only after 60 s without any (§2.10); + /// a cancelled [`set_halt`](Self::set_halt) token is [`E_HALTED`](crate::error::E_HALTED) + /// at once. Also [`E_SYNC_WORKER_LOST`](crate::error::E_SYNC_WORKER_LOST), or the OS error + /// of a failed chunk writeback (sticky: later writes fail with it too). pub fn sync_all(&mut self) -> io::Result<()> { if self.seek_count > 0 { tracing::debug!( @@ -125,7 +247,21 @@ impl WritebackFile { ); } self.pipeline.finalize(); - let synced = platform::durable_sync(&self.file); + let synced = self.drain(self.halt.clone().as_ref()).and_then(|()| { + let timing = DurableTiming { + stall: self.timing.stall, + sample_every: self.timing.sample_every, + ..DurableTiming::default() + }; + let flush = &self.flush; + super::flush::durable_sync_file_with( + &self.file, + self.halt.as_ref(), + |done, _| flush.durable_at_least(done), + self.ops.clone(), + timing, + ) + }); // A latched writeback error outranks fsync's verdict: the failed // WAIT_AFTER consumed it, so fsync can return 0 over lost data. match self.pipeline.error() { @@ -134,34 +270,49 @@ impl WritebackFile { } } + // Everything written handed to the flusher and made durable, if one runs. After a Stop + // only this file's chunk completions are progress (a close on NFS stays bounded). + fn drain(&self, abort: Option<&Halt>) -> io::Result<()> { + match &self.flusher { + Some(f) => f.drain(self.written, abort, self.halt.as_ref()), + None => Ok(()), + } + } + fn check_writeback(&self) -> io::Result<()> { - self.pipeline.error().map_or(Ok(()), Err) + if let Some(e) = self.pipeline.error() { + return Err(e); + } + self.flusher + .as_ref() + .and_then(Flusher::error) + .map_or(Ok(()), Err) } } impl Write for WritebackFile { fn write(&mut self, buf: &[u8]) -> io::Result { - self.check_writeback()?; + self.before_write()?; let n = self.file.write(buf)?; - self.pos += n as u64; - self.pipeline.note_progress(self.pos); + self.after_write(n); Ok(n) } fn write_all(&mut self, buf: &[u8]) -> io::Result<()> { - self.check_writeback()?; + self.before_write()?; self.file.write_all(buf)?; - self.pos += buf.len() as u64; - self.pipeline.note_progress(self.pos); + self.after_write(buf.len()); Ok(()) } - // Drains the in-flight chunk so its writeback verdict is known: sinks finished - // by `flush` alone (m2ts behind a BufWriter) must still see a latched error. + // Drains the in-flight chunk and the flusher so their verdict is known: sinks finished + // by `flush` alone (m2ts behind a BufWriter) must still see a latched error. Stall-bounded + // but not halt-aware: this is a container's close, which a Stop does not abandon (§2.5). fn flush(&mut self) -> io::Result<()> { self.file.flush()?; self.pipeline.finalize(); - self.check_writeback() + let drained = self.drain(None); + self.check_writeback().and(drained) } } diff --git a/src/io/writeback_file/other.rs b/src/io/writeback_file/other.rs index aee40133..71bfa8ca 100644 --- a/src/io/writeback_file/other.rs +++ b/src/io/writeback_file/other.rs @@ -1,15 +1,9 @@ //! Fallback platform impl for [`super::WritebackFile`] on targets //! without a dedicated implementation (BSDs, illumos, etc.). //! -//! - `preallocate` is a logged no-op. -//! - `durable_sync` calls `File::sync_all` directly (no bounded-syscall -//! wrapper — the wrapper depends on Linux/macOS unix idioms that -//! aren't universally portable). If a future BSD impl needs the -//! 60-s deadline, it should land in its own per-OS file rather than -//! bloat this fallback. +//! `preallocate` is a logged no-op; the durable flush lives in [`crate::io::flush`]. use std::fs::File; -use std::io; pub(super) fn preallocate(_file: &File, size_bytes: u64) { tracing::debug!( @@ -17,7 +11,3 @@ pub(super) fn preallocate(_file: &File, size_bytes: u64) { "WritebackFile preallocate size_hint={size_bytes} skipped (no impl on this target)" ); } - -pub(super) fn durable_sync(file: &File) -> io::Result<()> { - file.sync_all() -} diff --git a/src/io/writeback_file/windows.rs b/src/io/writeback_file/windows.rs index 18263a74..9789e2e2 100644 --- a/src/io/writeback_file/windows.rs +++ b/src/io/writeback_file/windows.rs @@ -1,14 +1,10 @@ //! Windows platform impl for [`super::WritebackFile`]. //! -//! Current behaviour: -//! -//! - `preallocate` is a debug-logged no-op (no `fallocate`-equivalent -//! that keeps the reported size). -//! - `durable_sync` delegates to `File::sync_all` (`FlushFileBuffers`), -//! unbounded unlike the Linux/macOS impls. +//! `preallocate` is a debug-logged no-op (no `fallocate`-equivalent that keeps the +//! reported size). The durable flush (`FlushFileBuffers`, halt-aware and stall-bounded) +//! lives in [`crate::io::flush`] (stop design §2.10). use std::fs::File; -use std::io; pub(super) fn preallocate(_file: &File, size_bytes: u64) { tracing::debug!( @@ -16,9 +12,3 @@ pub(super) fn preallocate(_file: &File, size_bytes: u64) { "WritebackFile preallocate size_hint={size_bytes} skipped (no-op on windows)" ); } - -pub(super) fn durable_sync(file: &File) -> io::Result<()> { - // `File::sync_all` on Windows is `FlushFileBuffers`. Not wrapped in - // the bounded-syscall primitive (see the module doc) — unbounded. - file.sync_all() -} diff --git a/src/lib.rs b/src/lib.rs index 313b1e88..ba97f31e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -57,6 +57,8 @@ pub(crate) const MUX_APP: &str = concat!("freemkv ", env!("FREEMKV_VERSION"), en // Transport-only surface (`--features scsi`, `default-features = false`): `error` // = SCSI error variants, `scsi` = passthrough. Rip tree (heavy deps) gated below. pub mod error; +// `halt` is std-only; ungated so the `loom` job (`--features scsi`) can model it. +pub mod halt; pub mod scsi; pub mod spec; @@ -87,8 +89,6 @@ pub mod drive; pub(crate) mod dvdnav; #[cfg(feature = "rip")] pub mod event; -#[cfg(feature = "rip")] -pub mod halt; #[cfg(all(test, feature = "rip"))] mod harness; #[cfg(feature = "rip")] diff --git a/src/mux/demux_thread.rs b/src/mux/demux_thread.rs index c4eed9cd..f9ed5b50 100644 --- a/src/mux/demux_thread.rs +++ b/src/mux/demux_thread.rs @@ -24,14 +24,18 @@ pub enum DemuxBatch { /// Underlying reader returned an error. Terminal. Err(std::io::Error), /// Explicit clean-completion sentinel. The worker sends this as its - /// LAST message on every non-error exit (input exhausted, or halt - /// cancelled) so the consumer can distinguish a normal end-of-stream + /// LAST message when the input is exhausted and no Stop is pending (a Stop + /// is `Err(Halted)`, never EOF) so the consumer can distinguish a normal end-of-stream /// from a bare channel disconnection. A worker that panics mid-stream /// drops `tx` without sending this, so the consumer sees `RecvError` /// and reports the panic rather than silently truncating output. Eof, } +fn halted(halt: Option<&Halt>) -> bool { + halt.is_some_and(Halt::is_cancelled) +} + /// Spawned demux thread. Drop joins. /// /// In zero-copy mode the thread also owns an opaque @@ -85,11 +89,10 @@ impl DemuxThread { let mut fed_bytes: u64 = 0; loop { hb.tick(fed_bytes, 0); - if halt.as_ref().map(|h| h.is_cancelled()).unwrap_or(false) { - // Caller-initiated stop is a clean termination — - // send the Eof sentinel so the consumer doesn't - // mistake it for a worker panic. - let _ = tx.send(DemuxBatch::Eof); + // A Stop is `Halted`, never a clean EOF (LP11): a truncated + // title must not finalise as a complete container. + if halted(halt.as_ref()) { + let _ = tx.send(DemuxBatch::Err(crate::error::Error::Halted.into())); return; } let t0 = if prof { @@ -187,8 +190,14 @@ impl DemuxThread { } } // Clean EOF sentinel. A panic during `feed`/`flush` skips this and - // drops `tx`, which the consumer reads as an error, not clean EOF. - let _ = tx.send(DemuxBatch::Eof); + // drops `tx`, which the consumer reads as an error, not clean EOF. A + // halted prefetcher closes its channel too: that close is a Stop. + let last = if halted(halt.as_ref()) { + DemuxBatch::Err(crate::error::Error::Halted.into()) + } else { + DemuxBatch::Eof + }; + let _ = tx.send(last); }); let handle = match spawn_result { @@ -341,26 +350,42 @@ mod tests { assert!(pes_idx.unwrap() < eof_idx.unwrap(), "tail before Eof"); } + /// LP11 / L096 on the highway: a Stop is `Err(Halted)`, never the clean `Eof` + /// sentinel, whether the worker sees the cancel itself or the halted prefetcher + /// closes its channel first. #[test] - fn halt_cancellation_sends_eof_not_panic() { - // A caller-initiated halt is a CLEAN termination — the worker must - // send Eof (not just drop tx), so the consumer doesn't mistake the - // stop for a worker panic. - let (pf_tx, pf_rx) = bounded::>>(4); - let (rc_tx, _rc_rx) = bounded::>(4); - let halt = Halt::new(); - halt.cancel(); // already cancelled before the loop runs - let ts = super::super::ts::TsDemuxer::new(&[0x1011]); - let (_dt, rx) = - DemuxThread::spawn_zero_copy(pf_rx, rc_tx, (), Some(halt), Some(ts), None).unwrap(); - // Keep pf_tx alive so the ONLY exit is the halt path, not producer - // disconnect. - let batches = collect_batches(&rx, Duration::from_secs(5)); - drop(pf_tx); - assert!( - matches!(batches.last(), Some(DemuxBatch::Eof)), - "halt cancellation must yield a clean Eof sentinel" - ); + fn halt_cancellation_sends_halted_not_eof() { + let batches = |close_first: bool| { + let (pf_tx, pf_rx) = bounded::>>(4); + let (rc_tx, _rc_rx) = bounded::>(4); + let halt = Halt::new(); + let ts = super::super::ts::TsDemuxer::new(&[0x1011]); + let (dt, rx) = + DemuxThread::spawn_zero_copy(pf_rx, rc_tx, (), Some(halt.clone()), Some(ts), None) + .unwrap(); + halt.cancel(); + if close_first { + drop(pf_tx); + let b = collect_batches(&rx, Duration::from_secs(5)); + drop(rx); + drop(dt); + b + } else { + let b = collect_batches(&rx, Duration::from_secs(5)); + drop(pf_tx); + drop(rx); + drop(dt); + b + } + }; + for close_first in [false, true] { + let b = batches(close_first); + assert!( + matches!(b.last(), Some(DemuxBatch::Err(e)) if crate::error::is_halt(e)), + "close_first={close_first}: a Stop is Halted" + ); + assert!(!b.iter().any(|x| matches!(x, DemuxBatch::Eof)), "no Eof"); + } } #[test] @@ -621,4 +646,39 @@ mod tests { .recv_timeout(Duration::from_secs(10)) .expect("drop must not deadlock on the blocked demux worker"); } + + /// LP10 (§2.1 "Unbounded Drop joins"): a worker blocked on its full output + /// channel, then a cancel: dropping the stream returns within 1 s. Guard. + #[test] + fn demux_drop_with_blocked_worker_after_cancel() { + let (pf_tx, pf_rx) = bounded::>>(16); + let (rc_tx, _rc_rx) = bounded::>(16); + let pid = 0x1011; + let ts = super::super::ts::TsDemuxer::new(&[pid]); + let halt = Halt::new(); + let (dt, rx) = + DemuxThread::spawn_zero_copy(pf_rx, rc_tx, (), Some(halt.clone()), Some(ts), None) + .unwrap(); + for i in 0..8u8 { + pf_tx.send(Ok(bdts_pes_packet(pid, &[i]))).unwrap(); + } + std::thread::sleep(Duration::from_millis(100)); + halt.cancel(); + let stream = super::super::pipelined_stream::PipelinedPesStream::new( + dt, + rx, + crate::disc::DiscTitle::empty(), + Vec::new(), + Vec::new(), + ); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + drop(stream); + let _ = done_tx.send(()); + }); + done_rx + .recv_timeout(Duration::from_secs(1)) + .expect("drop after a cancel must return within 1 s"); + drop(pf_tx); + } } diff --git a/src/mux/disc.rs b/src/mux/disc.rs index a9df6a3b..d04af2cf 100644 --- a/src/mux/disc.rs +++ b/src/mux/disc.rs @@ -1814,6 +1814,8 @@ mod tests { // crossing the prefetch channel, not become a fabricated transport failure. #[test] fn bad_sector_keeps_its_identity_across_the_prefetch_channel() { + // Spawns the prefetch producer, a Drive holder. + let _serial = crate::sector::prefetched::holder_test_lock(); const COUNT: u32 = 9; let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); let reader = RecordingReader { @@ -1858,6 +1860,8 @@ mod tests { // exhaustion after that must NOT look like a completed pass. #[test] fn dead_prefetch_producer_does_not_silently_zero_fill_the_title() { + // Spawns the prefetch producer, a Drive holder. + let _serial = crate::sector::prefetched::holder_test_lock(); const COUNT: u32 = 30; let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); let reader = RecordingReader { diff --git a/src/mux/driver.rs b/src/mux/driver.rs index f436d5db..d0a3303b 100644 --- a/src/mux/driver.rs +++ b/src/mux/driver.rs @@ -16,13 +16,14 @@ use crate::disc::DiscTitle; use crate::error::Error; use crate::event::{BatchSizeReason, Event, EventKind}; use crate::halt::Halt; +use crate::io::FlushProgress; use crate::io::pipeline::{Flow, Pipeline, Sink, WRITE_PIPELINE_DEPTH}; use crate::pes::{CountingStream, PesFrame, Stream}; use crate::sector::{FileSectorSource, KeyFetch, SectorSource}; use crate::session::DiscSession; use super::resolve::{ - InputOptions, StreamUrl, build_iso_pipeline, input_with_halt, output, parse_url, + InputOptions, StreamUrl, build_iso_pipeline, input_with_halt, output, output_with, parse_url, resolve_mux_key_map, }; use super::videomap::{Medium, SourceInfo}; @@ -176,6 +177,10 @@ pub trait MuxEvents: Send + Sync + 'static { fn on_batch_size_changed(&self, _batch: u16, _reason: BatchSizeReason) {} /// A read error occurred at `lba`. fn on_read_error(&self, _lba: u32) {} + /// While the output is being flushed at the end (the driver waiting on a closing + /// consumer): more bytes became durable. One call per increase, at most 4 per second, + /// none while nothing moves, so silence means a stalled flush (stop design §4.5). + fn on_flush_progress(&self, _bytes_durable: u64, _bytes_total: u64) {} } /// A [`MuxEvents`] that ignores everything — test-only (production callers @@ -525,17 +530,59 @@ fn reader_event_fn(events: Arc) -> crate::sector::prefetched::Eve // Join the write consumer after the pump. A send that hit its deadline means the // consumer is wedged, so the join gets only the short grace, not JOIN_TIMEOUT. +// While it waits, each increase of the output's durable bytes is forwarded (§4.5, LP20). fn finish_pumped( pipe: Pipeline, halt: &Halt, send_timed_out: bool, + flush: &FlushProgress, + events: &dyn MuxEvents, ) -> Result { - if send_timed_out { - let wedged = Halt::new(); + let mut fwd = FlushForwarder { + flush, + events, + last: flush.bytes_durable(), + last_call: None, + }; + let timing = crate::io::pipeline::JoinTiming::default(); + let wedged = Halt::new(); + let halt = if send_timed_out { wedged.cancel(); - return pipe.finish_with_halt(Some(&wedged)); + &wedged + } else { + halt + }; + let joined = pipe.finish_with_halt_observed(Some(halt), timing, &mut || fwd.poll(false)); + fwd.poll(true); + joined +} + +// At most one `on_flush_progress` per this long (4 Hz, §4.5). +const FLUSH_PROGRESS_EVERY: Duration = Duration::from_millis(250); + +// Forwards increases of the output's durable bytes to `MuxEvents::on_flush_progress`: +// one call per increase, rate-limited, none while nothing moves. +struct FlushForwarder<'a> { + flush: &'a FlushProgress, + events: &'a dyn MuxEvents, + last: u64, + last_call: Option, +} + +impl FlushForwarder<'_> { + // `last_word`: the wait is over, so a pending increase goes out now. + fn poll(&mut self, last_word: bool) { + let done = self.flush.bytes_durable(); + let due = self + .last_call + .is_none_or(|t| t.elapsed() >= FLUSH_PROGRESS_EVERY); + if done > self.last && (due || last_word) { + self.events + .on_flush_progress(done, self.flush.bytes_total()); + self.last = done; + self.last_call = Some(std::time::Instant::now()); + } } - pipe.finish_with_halt(Some(halt)) } // Whether a finished mux counts as COMPLETED: interrupted, finalize_failed, or halt_cancelled @@ -694,7 +741,13 @@ fn drive_mux( let num_streams = info.streams.len(); // ── Open the sink, wrap in a byte counter, hand it to the write pipeline ── - let mut output_stream = output(dest_url, &out_title, source)?; + // The output file's flush counters share the consumer's progress (§2.10 item 3). + let flush = FlushProgress::new(crate::halt::Progress::new()); + let out_flush = super::resolve::OutputFlush { + progress: &flush, + halt, + }; + let mut output_stream = output_with(dest_url, &out_title, source, Some(out_flush))?; for track in 0..num_streams { output_stream.set_track_timing(track, stream.track_timing(track))?; } @@ -733,12 +786,18 @@ fn drive_mux( bytes: bytes.clone(), late_configs: late_configs.clone(), }; - let pipe = Pipeline::spawn_named("freemkv-mux-consumer", WRITE_PIPELINE_DEPTH, sink) - .map_err(std::io::Error::from)?; + let consumer_progress = flush.progress().clone(); + let pipe = Pipeline::spawn_named_with_progress( + "freemkv-mux-consumer", + WRITE_PIPELINE_DEPTH, + sink, + consumer_progress, + ) + .map_err(std::io::Error::from)?; // ── Frame pump ── Per-frame send deadline is a hard bound (autorip) or // effectively unbounded (CLI). Either way `send_with_halt` re-checks halt - // every `POLL_INTERVAL`, so Ctrl-C / `/api/stop` stays responsive. + // every `WAIT_SLICE`, so Ctrl-C / `/api/stop` stays responsive. let deadline = send_deadline; let mut interrupted = false; // A send refused with no halt and a healthy consumer ran out its deadline. @@ -826,7 +885,7 @@ fn drive_mux( // the container. On halt/wedge this returns an error variant, translated // to `completed = false` rather than a hard failure. let (bytes_written, undelivered_streams, finalize_failed) = - match finish_pumped(pipe, halt, send_timed_out) { + match finish_pumped(pipe, halt, send_timed_out, &flush, events) { Ok(c) => (c.bytes, c.undelivered, false), Err(Error::Halted | Error::PipelineJoinTimeout) => { (bytes.load(Ordering::Relaxed), Vec::new(), true) @@ -1629,6 +1688,8 @@ mod tests { // Mutation: dropping `reader_event_fn` leaves `saw_read_total` false. #[test] fn mux_stream_iso_forwards_reader_progress_through_arc() { + // Spawns the prefetch producer, a Drive holder. + let _serial = crate::sector::prefetched::holder_test_lock(); let es = [0xDE, 0xAD, 0xBE, 0xEF, 0x11, 0x22]; let pkt = bdts_data_packet(0x1100, true, &audio_pes(&es)); let mut data = vec![0u8; 3 * 2048]; // 3 sectors = one AACS unit = 6144 bytes @@ -2947,7 +3008,8 @@ mod tests { assert!(timed_out, "the stuck consumer trips the send deadline"); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { - let _ = tx.send(finish_pumped(pipe, &halt, true).is_err()); + let flush = FlushProgress::default(); + let _ = tx.send(finish_pumped(pipe, &halt, true, &flush, &NoopEvents).is_err()); }); let failed = rx .recv_timeout(Duration::from_secs(30)) @@ -3027,4 +3089,74 @@ mod tests { let back = MkvStream::open(std::fs::File::open(&dst).unwrap()).unwrap(); assert_eq!(back.track_timing(0), timing); } + + // Records every `on_flush_progress` call. + #[derive(Default)] + struct FlushSpy(std::sync::Mutex>); + + impl MuxEvents for FlushSpy { + fn on_flush_progress(&self, durable: u64, total: u64) { + self.0.lock().unwrap().push((durable, total)); + } + } + + // `close()` makes `steps` bytes durable, `gap` apart, then idles `tail`. + struct FlushingClose { + flush: FlushProgress, + steps: u64, + gap: Duration, + tail: Duration, + } + + impl Sink for FlushingClose { + type Output = (); + fn apply(&mut self, _: u64) -> Result { + Ok(Flow::Continue) + } + fn close(self) -> Result<(), Error> { + self.flush.note_total(self.steps * 1000); + for _ in 0..self.steps { + std::thread::sleep(self.gap); + self.flush.add_durable(1000); + } + std::thread::sleep(self.tail); + Ok(()) + } + } + + fn finish_flushing(steps: u64, gap: Duration, tail: Duration) -> Vec<(u64, u64)> { + let flush = FlushProgress::new(crate::halt::Progress::new()); + let sink = FlushingClose { + flush: flush.clone(), + steps, + gap, + tail, + }; + let progress = flush.progress().clone(); + let pipe = Pipeline::spawn_named_with_progress("t-flush", 4, sink, progress).unwrap(); + let spy = FlushSpy::default(); + finish_pumped(pipe, &Halt::new(), false, &flush, &spy).unwrap(); + spy.0.into_inner().unwrap() + } + + /// LP20 (§4.5): while the driver waits on a closing consumer, each increase of the + /// flusher's bytes produces one `on_flush_progress`, and none while it is static. + #[test] + fn finish_with_halt_forwards_flush_progress() { + let calls = finish_flushing(4, Duration::from_millis(300), Duration::from_millis(600)); + let want: Vec<(u64, u64)> = (1..=4).map(|i| (i * 1000, 4000)).collect(); + assert_eq!( + calls, want, + "one call per increase, none during the idle tail" + ); + } + + /// LP20, the rate limit: a burst of increases is forwarded at most 4 times a second, + /// and the last value still arrives. + #[test] + fn flush_progress_is_rate_limited_to_4hz() { + let calls = finish_flushing(20, Duration::from_millis(5), Duration::from_millis(600)); + assert!(!calls.is_empty() && calls.len() <= 3, "{calls:?}"); + assert_eq!(calls.last(), Some(&(20_000, 20_000))); + } } diff --git a/src/mux/pipelined_stream.rs b/src/mux/pipelined_stream.rs index 026b20b1..8f08036b 100644 --- a/src/mux/pipelined_stream.rs +++ b/src/mux/pipelined_stream.rs @@ -64,6 +64,8 @@ pub struct PipelinedPesStream { au_asm: Vec, /// Bounds the header pump's wait for in-band codec configs (AAC). header_gate: super::header_gate::HeaderGate, + /// The op's stop token; a never-cancelled stand-in when there is none. + halt: crate::halt::Halt, } /// The `Codec` of a stream, for configuring its [`AuAssembler`](crate::mux::au_assembly::AuAssembler). @@ -115,13 +117,28 @@ impl PipelinedPesStream { is_video, au_asm, header_gate: super::header_gate::HeaderGate::default(), + halt: crate::halt::Halt::new(), } } + // The op's stop token: a cancel ends a blocked `read` with `Halted` (LP11). + pub(crate) fn with_halt(mut self, halt: Option) -> Self { + self.halt = halt.unwrap_or_default(); + self + } + // Pull one batch, parse it, enqueue frames on pending_frames. Ok(true) = // success, Ok(false) = clean EOF, Err = demuxer error. fn pump_one_batch(&mut self) -> io::Result { - match self.demux_rx.recv() { + let batch = match self + .halt + .recv_timeout(&self.demux_rx, std::time::Duration::MAX) + { + Ok(crate::halt::Recv::Item(b)) => Ok(b), + Ok(_) => Err(()), + Err(halted) => return Err(halted.into()), + }; + match batch { Ok(DemuxBatch::Ts(packets)) => { self.consume_ts(packets); Ok(true) @@ -131,7 +148,11 @@ impl PipelinedPesStream { Ok(true) } Ok(DemuxBatch::Err(e)) => Err(e), - // Explicit clean-completion sentinel from the demux worker. + // Explicit clean-completion sentinel from the demux worker; a Stop that + // raced it is still `Halted`, never a truncated clean end (LP11). + Ok(DemuxBatch::Eof) if self.halt.is_cancelled() => { + Err(crate::error::Error::Halted.into()) + } Ok(DemuxBatch::Eof) => Ok(false), // Channel disconnected WITHOUT an `Eof`/`Err` sentinel — the worker // panicked or was dropped mid-stream. Surface as an error so a parser/demux @@ -521,6 +542,67 @@ mod tests { } } + /// LP11: a reader blocked on the demux channel returns `Halted` once the op's + /// token is cancelled, with the demux worker still alive (no batch, no EOF). + #[test] + fn pipelined_stream_cancel_unblocks_reader() { + let (stream, tx) = make_stream(DiscTitle::empty(), vec![], vec![]); + let halt = crate::halt::Halt::new(); + let mut stream = stream.with_halt(Some(halt.clone())); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = done_tx.send(stream.read().map(|f| f.is_some())); + }); + std::thread::sleep(std::time::Duration::from_millis(50)); + halt.cancel(); + let r = done_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("a cancel must unblock the reader within a slice"); + let err = r.expect_err("a stop is not a frame or EOF"); + assert!(crate::error::is_halt(&err), "{err}"); + drop(tx); + } + + /// LP11 on the highway: a Stop mid-stream through a real demux worker yields + /// `Err(Halted)`, never `Ok(None)` (a truncated container finalised as complete), + /// and an `Eof` that raced the Stop is `Halted` too. + #[test] + fn highway_stop_mid_stream_is_halted_never_eof() { + let halt = crate::halt::Halt::new(); + let (pf_tx, pf_rx) = bounded::>>(4); + let (rec_tx, _rec_rx) = bounded::>(8); + let (dt, rx) = + DemuxThread::spawn_zero_copy(pf_rx, rec_tx, (), Some(halt.clone()), None, None) + .expect("spawn"); + let mut stream = PipelinedPesStream::new(dt, rx, DiscTitle::empty(), vec![], vec![]) + .with_halt(Some(halt.clone())); + pf_tx.send(Ok(vec![0u8; 188])).unwrap(); + halt.cancel(); + // The halted prefetcher closes its channel. + drop(pf_tx); + let mut outcome = None; + for _ in 0..16 { + match stream.read() { + Ok(Some(_)) => continue, + Ok(None) => panic!("a Stop must never read as a clean end of stream"), + Err(e) => { + outcome = Some(e); + break; + } + } + } + let e = outcome.expect("the Stop surfaced"); + assert!(crate::error::is_halt(&e), "{e}"); + + let (mut stream, tx) = make_stream(DiscTitle::empty(), vec![], vec![]); + let halt = crate::halt::Halt::new(); + stream = stream.with_halt(Some(halt.clone())); + halt.cancel(); + tx.send(DemuxBatch::Eof).unwrap(); + let e = stream.read().expect_err("an Eof after a Stop is not clean"); + assert!(crate::error::is_halt(&e), "{e}"); + } + // CLEAN EOF: explicit Eof sentinel → consumer returns Ok(None) and stays // Ok(None) on subsequent reads. #[test] diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 8d625324..8ff60dd6 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -621,6 +621,38 @@ pub fn output( title: &crate::disc::DiscTitle, source: Option<&super::videomap::SourceInfo>, ) -> io::Result> { + output_with(url, title, source, None) +} + +/// What a file output shares with the mux (§2.10): the flush counters (the consumer's +/// progress) and the op's stop token for its flush backpressure. +pub(crate) struct OutputFlush<'a> { + pub(crate) progress: &'a crate::io::FlushProgress, + pub(crate) halt: &'a crate::halt::Halt, +} + +// A file output's bounded-cache writer, sharing `flush` when given. +fn writeback_file( + path: &Path, + size_hint: u64, + flush: Option<&OutputFlush>, +) -> io::Result { + let mut w = crate::io::WritebackFile::create_with_size_hint(path, size_hint)?; + if let Some(f) = flush { + w.set_flush_progress(f.progress.clone()); + w.set_halt(f.halt.clone()); + } + Ok(w) +} + +// [`output`], with the file outputs sharing `flush` (the mux driver's path). +pub(crate) fn output_with( + url: &str, + title: &crate::disc::DiscTitle, + source: Option<&super::videomap::SourceInfo>, + flush: Option, +) -> io::Result> { + let flush = flush.as_ref(); let parsed = parse_url(url); match parsed { StreamUrl::Mkv { ref path } => { @@ -631,7 +663,7 @@ pub fn output( let writer: Box = Box::new(std::io::BufWriter::with_capacity( IO_BUF_SIZE, - crate::io::WritebackFile::create_with_size_hint(path, title.size_bytes)?, + writeback_file(path, title.size_bytes, flush)?, )); Ok(Box::new(MkvStream::create(writer, title, Some(path))?)) } @@ -642,7 +674,7 @@ pub fn output( // seek WritebackFile handles. BufWriter coalesces moov writes. let writer = std::io::BufWriter::with_capacity( IO_BUF_SIZE, - crate::io::WritebackFile::create_with_size_hint(path, title.size_bytes)?, + writeback_file(path, title.size_bytes, flush)?, ); Ok(Box::new(super::mp4::Mp4Sink::create(writer, title)?)) } @@ -650,7 +682,7 @@ pub fn output( validate_file_path(path, "m2ts")?; let writer = std::io::BufWriter::with_capacity( IO_BUF_SIZE, - crate::io::WritebackFile::create_with_size_hint(path, title.size_bytes)?, + writeback_file(path, title.size_bytes, flush)?, ); Ok(Box::new(M2tsStream::create(writer, title)?)) } @@ -1028,7 +1060,7 @@ fn resolve_fmts_key_map( let probed = probe_fmts_index_keys(reader, &clip_extents, &segments, fetch, format, halt)?; // Only memoise a run whose every index reached a DEFINITE phase — - // a read-faulted index defaulting to `Phase::All` is a transient + // a read-faulted index falling back to `Phase::Verify` is a transient // fault, not a property of these extents; caching it would spread. if probed.all_phases_definite { memo.insert(ek, (probed.keys.clone(), probed.phases.clone())); @@ -1077,10 +1109,11 @@ fn resolve_fmts_key_map( unresolved += 1; continue; }; + // No resolved phase → `Verify` (KU design §5.3, K-5), never an unverified `All`. let phase = phase_of_index .get(&seg.index) .copied() - .unwrap_or(crate::decrypt::Phase::All); + .unwrap_or(crate::decrypt::Phase::Verify); let start_byte = seg.start_spn as u64 * 192; let end_byte = (seg.end_spn as u64 + 1) * 192; // Clip bytes → LBAs through the FORENSIC CLIP's extents, never the @@ -1144,7 +1177,7 @@ fn resolve_fmts_key_map( struct FmtsIndexKeys { keys: Vec<[u8; 16]>, phases: std::collections::HashMap, - /// Every index reached a DEFINITE phase — no index fell back to `Phase::All` + /// Every index reached a DEFINITE phase — no index fell back to `Phase::Verify` /// after [`IndexProbe::ReadFault`]. Only such a run is safe to memoise; see /// [`resolve_mux_key_map_cached`]. all_phases_definite: bool, @@ -1271,10 +1304,10 @@ fn probe_fmts_index_keys( return Err(crate::error::Error::FmtsKeyMissing.into()); } IndexProbe::ReadFault => { - // EVERY probe read faulted (transient drive fault). Zero - // evidence ⇒ not a wrong key. Leave phase unresolved so the - // range-builder defaults to `Phase::All`. Degraded, never abort. - tracing::warn!(target: "freemkv::keysource", index = tag, "fmts: index phase probe read-faulted on every segment — defaulting Phase::All (recoverable read fault, not a wrong key)"); + // EVERY probe read faulted (zero evidence ⇒ not a wrong key; never + // abort). KU design §5.3 (evidence KS-25/KS-26): `Phase::Verify`. + tracing::warn!(target: "freemkv::keysource", index = tag, "fmts: index phase probe read-faulted on every segment — Phase::Verify: each unit kept only if it verifies (recoverable read fault, not a wrong key)"); + phase_of_index.insert(tag, crate::decrypt::Phase::Verify); all_phases_definite = false; } } @@ -1791,16 +1824,19 @@ pub fn build_iso_pipeline( let (rx, recycle_tx, shell) = prefetched.into_channels(); let (parsers, pid_to_track, ts, ps) = build_demux_state(&title, format); - let (demux_thread, demux_rx) = - super::demux_thread::DemuxThread::spawn_zero_copy(rx, recycle_tx, shell, halt, ts, ps) - .map_err(|e| -> io::Error { e.into() })?; - Ok(PipelinedPesStream::new( - demux_thread, - demux_rx, - title, - parsers, - pid_to_track, - )) + let (demux_thread, demux_rx) = super::demux_thread::DemuxThread::spawn_zero_copy( + rx, + recycle_tx, + shell, + halt.clone(), + ts, + ps, + ) + .map_err(|e| -> io::Error { e.into() })?; + Ok( + PipelinedPesStream::new(demux_thread, demux_rx, title, parsers, pid_to_track) + .with_halt(halt), + ) } // Assemble the M2TS file mux pipeline (read -> demux -> parse). Scans the head @@ -2478,6 +2514,8 @@ mod tests { // Empty extents -> clean immediate EOF, no panic/hang. #[test] fn build_iso_pipeline_empty_extents_clean_eof() { + // Spawns the prefetch producer, a Drive holder. + let _serial = crate::sector::prefetched::holder_test_lock(); let title = aac_audio_title(0x1100); // extents empty by default let mut stream = build_iso_pipeline( MemSource { data: Vec::new() }, @@ -2504,6 +2542,8 @@ mod tests { // then clean EOF. #[test] fn build_iso_pipeline_delivers_one_frame_then_eof() { + // Spawns the prefetch producer, a Drive holder. + let _serial = crate::sector::prefetched::holder_test_lock(); let es = [0xDE, 0xAD, 0xBE, 0xEF, 0x11, 0x22]; let pes = audio_pes(&es); let pkt = bdts_data_packet(0x1100, true, &pes); @@ -2568,6 +2608,8 @@ mod tests { // a frame from the excluded PID. #[test] fn build_iso_pipeline_pruned_title_drops_unselected_pid_frames() { + // Spawns the prefetch producer, a Drive holder. + let _serial = crate::sector::prefetched::holder_test_lock(); use crate::disc::{AudioChannels, AudioStream, Codec, LabelPurpose, SampleRate, Stream}; use crate::mux::select::{PidFilter, StreamSelection}; @@ -2648,6 +2690,8 @@ mod tests { /// would spin the producer forever). Surfaced as an io error, not a hang. #[test] fn build_iso_pipeline_zero_batch_rejected() { + // Spawns the prefetch producer, a Drive holder. + let _serial = crate::sector::prefetched::holder_test_lock(); let title = aac_audio_title(0x1100); let res = build_iso_pipeline( MemSource { data: Vec::new() }, @@ -2667,6 +2711,8 @@ mod tests { // scrambled-uncrackable must HARD-FAIL, never mux garbage. #[test] fn build_iso_pipeline_dvd_none_keys_scrambled_hard_fails() { + // Spawns the prefetch producer, a Drive holder. + let _serial = crate::sector::prefetched::holder_test_lock(); // One CSS-scrambled, crib-less (uncrackable) MPEG-PS sector. let key = [0x11u8, 0x22, 0x33, 0x44, 0x55]; let mut sec = vec![0u8; 2048]; @@ -4352,8 +4398,7 @@ mod tests { ); } - // A read-faulted phase (defaulted to Phase::All) must NOT be memoised; the next title - // re-probes. + // A read-faulted phase (Phase::Verify) must NOT be memoised; the next title re-probes. #[test] fn fmts_read_faulted_phase_is_not_memoised() { use std::sync::atomic::Ordering; @@ -4380,8 +4425,8 @@ mod tests { .expect("a read fault must NOT abort a rip whose index keys are good"); assert_eq!( first.entry_for(10_600).map(|(_, p, _)| p), - Some(crate::decrypt::Phase::All), - "the read-faulted index defaults to Phase::All" + Some(crate::decrypt::Phase::Verify), + "the read-faulted index falls back to Phase::Verify (K-5)" ); assert_eq!( first.entry_for(10_300).map(|(_, p, _)| p), @@ -4411,6 +4456,61 @@ mod tests { ); } + /// LK14 (K-5), per evidence (no public FMTS spec) — KS-25, KS-26: an index whose every + /// phase probe faulted is decrypted unit by unit, each kept only if it verifies; per spec + /// KS-3 [BD] §3.10.1 "A new CBC cipher chain is started for each Aligned Unit". + #[test] + fn fmts_verify_phase_drops_wrong_half() { + use crate::aacs::content::{ALIGNED_UNIT_LEN, aacs_unit_encrypted, is_clean}; + use crate::spec::keys::{ + KS_3_CBC_PER_UNIT, KS_25_AACS2_EVIDENCE, KS_26_FMTS_ANCHOR_EVIDENCE, + }; + assert!( + KS_3_CBC_PER_UNIT + .text + .contains("new CBC cipher chain is started for each") + ); + assert_eq!(KS_25_AACS2_EVIDENCE.kind, crate::spec::QuoteKind::Evidence); + assert_eq!( + KS_26_FMTS_ANCHOR_EVIDENCE.kind, + crate::spec::QuoteKind::Evidence + ); + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let fetch = counting_fmts_fetch(calls); + // Every read of the index-2 segment (LBAs 10600..10840) faults: its phase is unknown. + let mut reader = FmtsDisc::new(); + reader.fault_span = Some((10_600, 10_840)); + let mut keys = fmts_keys(); + let map = super::resolve_mux_key_map( + &mut reader, + &fmts_title(FMTS_CONTENT_SECTORS), + &mut keys, + Some(&fetch), + ContentFormat::BdTs, + None, + ) + .expect("a read-faulted phase probe never aborts the rip"); + // Later the segment reads: unit 0 is index 2's half, unit 1 the other variant's. + let mut buf = vec![0u8; 2 * ALIGNED_UNIT_LEN]; + FmtsDisc::new() + .read_sectors(10_600, 6, &mut buf, false) + .unwrap(); + let other_half = buf[ALIGNED_UNIT_LEN..].to_vec(); + crate::decrypt::decrypt_sectors_mapped(&mut buf, &keys, 10_600, &map) + .expect("a unit that fails the verify is left as ciphertext, not an error"); + let (ours, theirs) = buf.split_at(ALIGNED_UNIT_LEN); + assert!(is_clean(ours, ContentFormat::BdTs), "our half decrypts"); + assert!( + !aacs_unit_encrypted(ours, ContentFormat::BdTs), + "and reads clear" + ); + assert!(theirs == other_half, "the wrong half is left as ciphertext"); + assert!( + aacs_unit_encrypted(theirs, ContentFormat::BdTs), + "still flagged" + ); + } + // The UDF walk deciding FMTS-or-not must be attempted ONCE per disc, not once per playlist. #[test] fn non_fmts_disc_walks_the_filesystem_once_for_every_title() { diff --git a/src/scsi/fd_handoff.rs b/src/scsi/fd_handoff.rs index 305f2f5a..defd3e3f 100644 --- a/src/scsi/fd_handoff.rs +++ b/src/scsi/fd_handoff.rs @@ -188,6 +188,20 @@ mod tests { /// A full recovery-thread cap means SG_IO calls are already stuck: Drop /// closes at once rather than block on an inline ALLOW. Only a failed spawn /// (checked by the caller) falls back to an inline ALLOW. + /// LD15 (GUARD + a new row): the transport's drop-ALLOW fires whenever PREVENT is + /// still held, including after the Drive's own ALLOW was answered with an error + /// (`prevent_held` stays set); only a dead-bus ALLOW or a full cap skips it. + #[test] + fn fd_handoff_drop_allow_after_failed_drive_allow() { + // The Drive's ALLOW got a CHECK CONDITION: PREVENT still held, bus alive. + assert_eq!(drop_unlock_fd(true, false, 5, None), Some(5)); + assert_eq!(drop_unlock_plan(Some(5), true), DropUnlock::Detached(5)); + // Cap full: skipped (the UI warns the tray may stay locked). + assert_eq!(drop_unlock_plan(Some(5), false), DropUnlock::Skip); + // The ALLOW died on the transport: never retried. + assert_eq!(drop_unlock_fd(true, true, 5, None), None); + } + #[test] fn drop_unlock_plan_skips_when_the_thread_cap_is_full() { assert_eq!(drop_unlock_plan(None, true), DropUnlock::Skip); diff --git a/src/scsi/linux.rs b/src/scsi/linux.rs index cf10bc10..ffa59a59 100644 --- a/src/scsi/linux.rs +++ b/src/scsi/linux.rs @@ -92,6 +92,8 @@ pub struct SgIoTransport { /// that publishes after that point sees it and closes its own fd, since /// nothing will ever drain the slot again. dead: Arc, + /// The last command's sense-key specific progress indication (§2.11). + last_progress: Option, } impl SgIoTransport { @@ -115,6 +117,7 @@ impl SgIoTransport { prevent_held: false, allow_transport_failed: false, dead: Arc::new(AtomicBool::new(false)), + last_progress: None, }) } @@ -286,6 +289,10 @@ impl Drop for SgIoTransport { impl ScsiTransport for SgIoTransport { // Execute via one synchronous SG_IO ioctl; errors map to IoError/ScsiError. + fn last_sense_progress(&self) -> Option { + self.last_progress + } + fn execute( &mut self, cdb: &[u8], @@ -297,6 +304,7 @@ impl ScsiTransport for SgIoTransport { // pub, so an external caller could pass an empty or over-length // CDB. Shared helper keeps the check identical across platforms. let cmd_len = super::checked_cdb_len(cdb, K_MAX_CDB_SIZE)?; + self.last_progress = None; let exec_t0 = std::time::Instant::now(); let opcode = cdb[0]; // Held from the moment a PREVENT is attempted: its outcome may be unknown. @@ -471,6 +479,7 @@ impl ScsiTransport for SgIoTransport { // `ScsiSense::is_medium_error()` etc. if hdr.status != 0 { let parsed = super::parse_sense(&sense, hdr.sb_len_wr); + self.last_progress = super::parse_sense_progress(&sense, hdr.sb_len_wr); tracing::trace!( target: "freemkv::scsi", phase = "scsi_err", diff --git a/src/scsi/macos.rs b/src/scsi/macos.rs index 0101f4eb..f95326e3 100644 --- a/src/scsi/macos.rs +++ b/src/scsi/macos.rs @@ -9,6 +9,7 @@ use super::{DataDirection, ScsiResult, ScsiTransport}; use crate::error::{Error, Result}; +use crate::halt::Halt; use std::path::Path; use std::sync::atomic::{AtomicBool, Ordering}; @@ -19,6 +20,10 @@ const K_SENSE_DATA_SIZE: usize = 32; /// shim so a pathological >255-byte slice can't wrap a `u8`. const K_MAX_CDB_SIZE: usize = 16; +// The timeout a caller's `timeout_ms == 0` gets: Linux SG_IO's default, include/linux/blkdev.h +// "#define BLK_DEFAULT_SG_TIMEOUT (60 * HZ)". On macOS 0 would mean "Wait Forever". +const ZERO_TIMEOUT_MS: u32 = 60_000; + // The C shim uses a single global IOKit handle, so only one MacScsiTransport may exist at a // time — a second open() would race the shared handle with the first drop(). static OPEN: AtomicBool = AtomicBool::new(false); @@ -32,8 +37,11 @@ struct ShimDriveInfo { firmware: [u8; 16], } +// shim_open_exclusive's result when the cancel byte was set (`SHIM_CANCELLED` in the shim). +const SHIM_CANCELLED: i32 = -6; + unsafe extern "C" { - fn shim_open_exclusive(bsd_name: *const u8) -> i32; + fn shim_open_exclusive(bsd_name: *const u8, cancel: *const u8) -> i32; fn shim_close(); fn shim_execute( cdb: *const u8, @@ -45,6 +53,7 @@ unsafe extern "C" { sense_len: u32, task_status_out: *mut u8, transfer_count: *mut u64, + timeout_ms: u32, ) -> i32; fn shim_list_drives(out: *mut ShimDriveInfo, max_entries: i32) -> i32; fn shim_media_present(bsd_name: *const u8) -> i32; @@ -63,6 +72,11 @@ fn bsd_name_of(device: &Path) -> Result<&str> { .unwrap_or(dev_str)) } +// The token's flag as the shim's `const volatile uint8_t *` cancel byte; valid while `halt` is. +fn cancel_byte(halt: &Halt) -> *const u8 { + halt.as_arc().as_ptr().cast_const().cast() +} + fn device_path_for_selector(selector: &str) -> String { if selector.starts_with("ioreg:") { selector.to_string() @@ -75,6 +89,8 @@ fn device_path_for_selector(selector: &str) -> String { // variant, pulled out standalone so the mapping can be unit-tested. fn map_shim_open_error(rc: i32, path: String) -> Error { match rc { + // The caller's token was cancelled during an open wait (§2.9 M2). + SHIM_CANCELLED => Error::Halted, // -2/-3/-4: IOCreatePlugInInterfaceForService / // QueryInterface MMCDeviceInterface / // GetSCSITaskDeviceInterface failed. @@ -89,12 +105,16 @@ fn map_shim_open_error(rc: i32, path: String) -> Error { pub struct MacScsiTransport { _bsd_name: String, + /// The last command's sense-key specific progress indication (§2.11). + last_progress: Option, } unsafe impl Send for MacScsiTransport {} impl MacScsiTransport { - pub fn open(device: &Path) -> Result { + /// Open `device`; every wait inside the shim's open ends within a slice of `halt` + /// being cancelled, and a cancelled open is [`Error::Halted`] (stop design §2.9 M2). + pub fn open(device: &Path, halt: &Halt) -> Result { let bsd_name = bsd_name_of(device)?; // Enforce single-instance: the shim's global handle can't back two @@ -109,7 +129,7 @@ impl MacScsiTransport { let mut bsd_c = bsd_name.as_bytes().to_vec(); bsd_c.push(0); - let rc = unsafe { shim_open_exclusive(bsd_c.as_ptr()) }; + let rc = unsafe { shim_open_exclusive(bsd_c.as_ptr(), cancel_byte(halt)) }; if rc != 0 { // Release the single-instance lock taken by the OPEN.swap above; // a failed open must not leave it held or every later open wedges. @@ -119,6 +139,7 @@ impl MacScsiTransport { Ok(MacScsiTransport { _bsd_name: bsd_name.to_string(), + last_progress: None, }) } } @@ -131,16 +152,18 @@ impl Drop for MacScsiTransport { } impl ScsiTransport for MacScsiTransport { + fn last_sense_progress(&self) -> Option { + self.last_progress + } + fn execute( &mut self, cdb: &[u8], direction: DataDirection, data: &mut [u8], - // NOTE: timeout_ms is ignored on macOS — the C shim hardcodes a - // fixed 30s `SetTimeoutDuration`, tracked separately since macOS - // is dev/test-only per project rules. - _timeout_ms: u32, + timeout_ms: u32, ) -> Result { + self.last_progress = None; // Match the Linux guard: a >=4 GiB buffer would wrap when cast to // u32 for the shim, producing a short transfer reported as success // with the wrong byte count. @@ -177,6 +200,12 @@ impl ScsiTransport for MacScsiTransport { K_SENSE_DATA_SIZE as u32, &mut task_status, &mut transfer_count, + // SCSITaskLib.h SetTimeoutDuration: "A value of zero is equivalent to "Wait Forever"". + if timeout_ms == 0 { + ZERO_TIMEOUT_MS + } else { + timeout_ms + }, ) }; @@ -199,6 +228,7 @@ impl ScsiTransport for MacScsiTransport { if task_status != 0 { let parsed = super::parse_sense(&sense, K_SENSE_DATA_SIZE as u8); + self.last_progress = super::parse_sense_progress(&sense, K_SENSE_DATA_SIZE as u8); return Err(Error::ScsiError { opcode: cdb.first().copied().unwrap_or(0), status: task_status, @@ -275,12 +305,25 @@ pub(super) fn disc_presence(path: &Path) -> Result { #[cfg(test)] mod tests { use super::{ - K_MAX_CDB_SIZE, OPEN, ShimDriveInfo, bsd_name_of, cstr_to_str, device_path_for_selector, - disc_presence, drive_info_from_shim, map_shim_open_error, + K_MAX_CDB_SIZE, MacScsiTransport, OPEN, SHIM_CANCELLED, ShimDriveInfo, bsd_name_of, + cancel_byte, cstr_to_str, device_path_for_selector, disc_presence, drive_info_from_shim, + map_shim_open_error, }; use crate::error::Error; + use crate::halt::{Halt, WAIT_SLICE}; + use crate::scsi::{DataDirection, ScsiTransport}; use std::path::Path; + use std::sync::Mutex; use std::sync::atomic::Ordering; + use std::thread; + use std::time::{Duration, Instant}; + + // Serializes the tests that read or take the process-global OPEN bit and shim handle. + static SHIM_GLOBALS: Mutex<()> = Mutex::new(()); + + fn shim_globals() -> std::sync::MutexGuard<'static, ()> { + SHIM_GLOBALS.lock().unwrap_or_else(|e| e.into_inner()) + } #[test] fn bsd_name_strips_dev_and_raw_dev_prefixes() { @@ -382,6 +425,7 @@ mod tests { #[test] fn presence_probe_does_not_open_a_transport() { let path = Path::new("/dev/freemkv-no-such-device"); + let _globals = shim_globals(); // Snapshot rather than assume `false`: OPEN is a process-global bit any // concurrent test's live transport can hold, so compare the delta — it // scopes the check to this call, can't flake, still catches a held lock. @@ -427,7 +471,7 @@ mod tests { } other => panic!("expected DeviceLocked, got {other:?}"), } - for rc in [-1, -6, i32::MIN] { + for rc in [-1, -7, i32::MIN] { match map_shim_open_error(rc, "disk4".into()) { Error::DeviceNotFound { path } => assert_eq!(path, "disk4"), other => panic!("rc={rc}: expected DeviceNotFound, got {other:?}"), @@ -462,4 +506,265 @@ mod tests { "CDB of exactly K_MAX_CDB_SIZE should not trigger guard" ); } + + // ── shim_selftest (stop design §5.1 LM1; qa release-tests on macOS, D6) ── + + unsafe extern "C" { + fn shim_selftest_wait_slice_ms() -> i32; + fn shim_selftest_sleep(ms: u32, cancel: *const u8) -> i32; + fn shim_selftest_sem_wait(ms: u32, signalled: i32, cancel: *const u8) -> i32; + fn shim_selftest_run_and_reap( + path: *const u8, + arg: *const u8, + budget_ms: u32, + cancel: *const u8, + pid_out: *mut i32, + ) -> i32; + fn shim_selftest_reaped(pid: i32) -> i32; + fn shim_selftest_install_fake_device() -> i32; + fn shim_selftest_last_timeout_ms() -> u32; + } + + /// A wait that is never cancelled would run this long; every cancelled wait must end far sooner. + const LONG_MS: u32 = 10_000; + /// Cancel lands this long after the wait starts. + const CANCEL_AFTER: Duration = Duration::from_millis(50); + /// A cancelled wait returns within this of the cancel: a few 20 ms slices, CI-robust (§3.2). + const WAKE_BOUND: Duration = Duration::from_millis(150); + + // Run `wait` against a token cancelled CANCEL_AFTER in; returns its result and the time + // from the cancel to its return. + fn cancel_during(wait: impl FnOnce(*const u8) -> i32) -> (i32, Duration) { + let halt = Halt::new(); + let canceller = { + let halt = halt.clone(); + thread::spawn(move || { + thread::sleep(CANCEL_AFTER); + let at = Instant::now(); + halt.cancel(); + at + }) + }; + let rc = wait(cancel_byte(&halt)); + let returned = Instant::now(); + let cancelled_at = canceller.join().expect("canceller thread"); + (rc, returned.saturating_duration_since(cancelled_at)) + } + + /// Per design §2.9 M2 ("every wait is sliced at ≤ 20 ms") and §2.1 `WAIT_SLICE`: the shim's + /// slice is the library's. Guard; do not change without a design citation. + #[test] + fn shim_selftest_wait_slice_is_the_library_wait_slice() { + let slice = unsafe { shim_selftest_wait_slice_ms() }; + assert!(slice <= 20, "shim slice {slice} ms exceeds the 20 ms bound"); + assert_eq!(Duration::from_millis(slice as u64), WAIT_SLICE); + } + + /// §2.9 M2: the settle and ObtainExclusiveAccess retry sleeps end within a slice of a cancel. + #[test] + fn shim_selftest_cancel_ends_the_settle_sleep() { + let (rc, wake) = cancel_during(|c| unsafe { shim_selftest_sleep(LONG_MS, c) }); + assert_eq!(rc, SHIM_CANCELLED); + assert!( + wake <= WAKE_BOUND, + "settle sleep woke {wake:?} after the cancel" + ); + + let halt = Halt::new(); + halt.cancel(); + let t0 = Instant::now(); + let rc = unsafe { shim_selftest_sleep(LONG_MS, cancel_byte(&halt)) }; + assert_eq!( + rc, SHIM_CANCELLED, + "a token cancelled before the wait ends it at once" + ); + assert!(t0.elapsed() <= WAKE_BOUND); + + let t0 = Instant::now(); + let rc = unsafe { shim_selftest_sleep(60, cancel_byte(&Halt::new())) }; + assert_eq!(rc, 0, "an uncancelled sleep runs out normally"); + assert!(t0.elapsed() >= Duration::from_millis(60)); + } + + /// §2.9 M2: the DiskArbitration claim wait (5 s) is sliced and ends on a cancel. + /// Apple dispatch/semaphore.h: "Returns zero on success, or non-zero if the timeout occurred." + #[test] + fn shim_selftest_cancel_ends_the_da_claim_wait() { + let (rc, wake) = cancel_during(|c| unsafe { shim_selftest_sem_wait(LONG_MS, 0, c) }); + assert_eq!(rc, SHIM_CANCELLED); + assert!( + wake <= WAKE_BOUND, + "DA claim wait woke {wake:?} after the cancel" + ); + + let never = Halt::new(); + let rc = unsafe { shim_selftest_sem_wait(60, 0, cancel_byte(&never)) }; + assert_eq!(rc, 1, "an unsignalled, uncancelled wait times out"); + let t0 = Instant::now(); + let rc = unsafe { shim_selftest_sem_wait(LONG_MS, 1, cancel_byte(&never)) }; + assert_eq!(rc, 0, "a signalled claim is taken"); + assert!(t0.elapsed() <= WAKE_BOUND); + } + + /// §2.9 M2: "On cancel, diskutil is killed and reaped". The child is `sleep` standing in for + /// a wedged `diskutil unmountDisk`, run through the same spawn-and-reap path. + #[test] + fn shim_selftest_cancel_kills_and_reaps_the_unmount() { + let mut pid = 0; + let (rc, wake) = cancel_during(|c| unsafe { + shim_selftest_run_and_reap( + c"/bin/sleep".as_ptr().cast(), + c"30".as_ptr().cast(), + LONG_MS, + c, + &mut pid, + ) + }); + assert_eq!(rc, SHIM_CANCELLED); + assert!( + wake <= WAKE_BOUND, + "unmount wait woke {wake:?} after the cancel" + ); + assert!(pid > 0, "the child was spawned"); + // wait(2) ECHILD: "The process specified by pid does not exist or is not a child of + // the calling process" — the shim reaped it, leaving no zombie. + assert_eq!( + unsafe { shim_selftest_reaped(pid) }, + 1, + "child {pid} not reaped" + ); + } + + /// The unmount budget still kills and reaps a wedged child with no cancel, and a child + /// that exits on its own is reaped as a normal exit. + #[test] + fn shim_selftest_unmount_budget_and_normal_exit_reap() { + let never = Halt::new(); + let mut pid = 0; + let rc = unsafe { + shim_selftest_run_and_reap( + c"/bin/sleep".as_ptr().cast(), + c"30".as_ptr().cast(), + 60, + cancel_byte(&never), + &mut pid, + ) + }; + assert_eq!(rc, 1, "a wedged child is killed when the budget is spent"); + assert_eq!( + unsafe { shim_selftest_reaped(pid) }, + 1, + "child {pid} not reaped" + ); + + let rc = unsafe { + shim_selftest_run_and_reap( + c"/bin/sleep".as_ptr().cast(), + c"0".as_ptr().cast(), + LONG_MS, + cancel_byte(&never), + &mut pid, + ) + }; + assert_eq!(rc, 0, "a child that exits is a normal exit"); + assert_eq!( + unsafe { shim_selftest_reaped(pid) }, + 1, + "child {pid} not reaped" + ); + } + + /// §2.9 M2: `SHIM_CANCELLED` → `Halted`. A cancelled open is `Halted` before any side + /// effect, and the single-instance lock is released. + #[test] + fn shim_selftest_cancelled_open_is_halted() { + assert!(matches!( + map_shim_open_error(SHIM_CANCELLED, "disk4".into()), + Error::Halted + )); + + let _globals = shim_globals(); + let halt = Halt::new(); + halt.cancel(); + let t0 = Instant::now(); + let r = MacScsiTransport::open(Path::new("/dev/freemkv-no-such-device"), &halt); + assert!( + matches!(r, Err(Error::Halted)), + "expected Halted, got {:?}", + r.err() + ); + assert!( + t0.elapsed() <= WAKE_BOUND, + "cancelled open took {:?}", + t0.elapsed() + ); + assert!( + !OPEN.load(Ordering::Acquire), + "a cancelled open left OPEN held" + ); + } + + /// §2.9 M2 end to end: shim_open_exclusive hands its token to its own waits. The unmount of + /// a missing disk ends at once, so the cancel lands in the 500 ms settle (or the unmount). + #[test] + fn shim_selftest_cancel_mid_open_is_halted() { + let _globals = shim_globals(); + let (r, wake) = { + let halt = Halt::new(); + let canceller = { + let halt = halt.clone(); + thread::spawn(move || { + thread::sleep(CANCEL_AFTER); + let at = Instant::now(); + halt.cancel(); + at + }) + }; + let r = MacScsiTransport::open(Path::new("/dev/freemkv-no-such-device"), &halt); + let returned = Instant::now(); + let cancelled_at = canceller.join().expect("canceller thread"); + (r, returned.saturating_duration_since(cancelled_at)) + }; + assert!( + matches!(r, Err(Error::Halted)), + "expected Halted, got {:?}", + r.err() + ); + assert!( + wake <= WAKE_BOUND, + "open returned {wake:?} after the cancel" + ); + assert!( + !OPEN.load(Ordering::Acquire), + "a cancelled open left OPEN held" + ); + } + + /// §2.9 M1: "`timeout_ms` is passed through to the shim", reaching the task unchanged. + /// Apple SCSITaskLib.h SetTimeoutDuration: "The timeout duration is counted in milliseconds." + #[test] + fn shim_selftest_timeout_ms_is_passed_through() { + let _globals = shim_globals(); + assert!( + !OPEN.swap(true, Ordering::Acquire), + "OPEN held outside SHIM_GLOBALS" + ); + assert_eq!(unsafe { shim_selftest_install_fake_device() }, 0); + let mut transport = MacScsiTransport { + _bsd_name: "selftest".into(), + last_progress: None, + }; + for timeout_ms in [1, 5_000, 10_000, 12_345, 60_000] { + let r = transport.execute(&[0u8; 6], DataDirection::None, &mut [], timeout_ms); + assert!(r.is_ok(), "fake task failed: {:?}", r.err()); + assert_eq!(unsafe { shim_selftest_last_timeout_ms() }, timeout_ms); + } + // SCSITaskLib.h SetTimeoutDuration: "A value of zero is equivalent to "Wait Forever"", + // so 0 must never reach the task; it becomes Linux SG_IO's 60 s default. + let r = transport.execute(&[0u8; 6], DataDirection::None, &mut [], 0); + assert!(r.is_ok(), "fake task failed: {:?}", r.err()); + assert_eq!(unsafe { shim_selftest_last_timeout_ms() }, 60_000); + drop(transport); + assert!(!OPEN.load(Ordering::Acquire), "drop released OPEN"); + } } diff --git a/src/scsi/macos_shim.c b/src/scsi/macos_shim.c index 0b407c7b..6a6be3db 100644 --- a/src/scsi/macos_shim.c +++ b/src/scsi/macos_shim.c @@ -19,6 +19,101 @@ extern char **environ; +// ── Cancellation (stop design §2.9 M2) ───────────────────────────────────── + +// shim_open_exclusive's result when the caller's cancel byte is set; Rust maps it to Halted. +#define SHIM_CANCELLED (-6) +// The longest a shim wait sleeps between cancel-byte checks (§2.9: "sliced at ≤ 20 ms"). +#define SHIM_WAIT_SLICE_MS 20 +// diskutil unmount budget; a wedged unmount is killed so open() still returns (§2.9). +#define SHIM_DISKUTIL_BUDGET_MS 20000 +// Settle time after the unmount, and between ObtainExclusiveAccess retries. +#define SHIM_SETTLE_MS 500 +// DiskArbitration claim wait, so a wedged DA can't hang open(). +#define SHIM_DA_CLAIM_MS 5000 + +void shim_close(void); + +// The cancel byte is the Rust Halt's AtomicBool; the Acquire load pairs with its Release (SS-23). +static int shim_cancelled(const volatile uint8_t *cancel) { + return cancel && __atomic_load_n(cancel, __ATOMIC_ACQUIRE) != 0; +} + +static uint64_t shim_now_ns(void) { + return clock_gettime_nsec_np(CLOCK_UPTIME_RAW); +} + +// The next wait until `end_ns`: at most one slice, in nanoseconds. +static uint64_t shim_slice_ns(uint64_t now_ns, uint64_t end_ns) { + uint64_t left = end_ns - now_ns; + uint64_t slice = (uint64_t)SHIM_WAIT_SLICE_MS * NSEC_PER_MSEC; + return left < slice ? left : slice; +} + +// Sleep `ms`. 0 once slept out, SHIM_CANCELLED on cancel. +static int sliced_sleep(uint32_t ms, const volatile uint8_t *cancel) { + uint64_t end = shim_now_ns() + (uint64_t)ms * NSEC_PER_MSEC; + for (;;) { + if (shim_cancelled(cancel)) return SHIM_CANCELLED; + uint64_t now = shim_now_ns(); + if (now >= end) return 0; + usleep((useconds_t)((shim_slice_ns(now, end) + NSEC_PER_USEC - 1) / NSEC_PER_USEC)); + } +} + +// Wait up to `ms` for `sem`. 0 signalled, 1 timed out, SHIM_CANCELLED on cancel. +static int sliced_sem_wait(dispatch_semaphore_t sem, uint32_t ms, const volatile uint8_t *cancel) { + uint64_t end = shim_now_ns() + (uint64_t)ms * NSEC_PER_MSEC; + for (;;) { + if (shim_cancelled(cancel)) return SHIM_CANCELLED; + uint64_t now = shim_now_ns(); + if (now >= end) return 1; + int64_t slice = (int64_t)shim_slice_ns(now, end); + // Apple dispatch/semaphore.h: "Returns zero on success, or non-zero if the timeout occurred." + if (dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, slice)) == 0) { + return 0; + } + } +} + +// Run `path argv` (no shell; stdout/stderr to /dev/null) and wait for it, killing it on +// budget or cancel. 0 exited, 1 budget spent, SHIM_CANCELLED, -1 spawn failed. +static int run_and_reap(const char *path, char *const argv[], uint32_t budget_ms, + const volatile uint8_t *cancel, pid_t *pid_out) { + posix_spawn_file_actions_t fa; + posix_spawn_file_actions_init(&fa); + posix_spawn_file_actions_addopen(&fa, STDOUT_FILENO, "/dev/null", O_WRONLY, 0); + posix_spawn_file_actions_addopen(&fa, STDERR_FILENO, "/dev/null", O_WRONLY, 0); + pid_t pid; + int spawned = posix_spawn(&pid, path, &fa, NULL, argv, environ); + posix_spawn_file_actions_destroy(&fa); + if (spawned != 0) return -1; + if (pid_out) *pid_out = pid; + uint64_t end = shim_now_ns() + (uint64_t)budget_ms * NSEC_PER_MSEC; + int status; + int outcome; + for (;;) { + // wait(2): "The WNOHANG option is used to indicate that the call should not block if + // there are no processes that wish to report status." r < 0 (ECHILD): already gone. + pid_t r = waitpid(pid, &status, WNOHANG); + if (r < 0 && errno == EINTR) continue; + if (r == pid || r < 0) return 0; + if (shim_cancelled(cancel)) { outcome = SHIM_CANCELLED; break; } + uint64_t now = shim_now_ns(); + if (now >= end) { outcome = 1; break; } + usleep((useconds_t)((shim_slice_ns(now, end) + NSEC_PER_USEC - 1) / NSEC_PER_USEC)); + } + // signal(3): "Except for the SIGKILL and SIGSTOP signals, the signal() function allows for + // a signal to be caught" — so the reap converges; still bounded (1 s) and sliced. + kill(pid, SIGKILL); + for (int i = 0; i < 1000 / SHIM_WAIT_SLICE_MS; i++) { + pid_t r = waitpid(pid, &status, WNOHANG); + if (r == pid || (r < 0 && errno != EINTR)) break; + usleep(SHIM_WAIT_SLICE_MS * 1000); + } + return outcome; +} + // ── Types ────────────────────────────────────────────────────────────────── typedef struct { @@ -357,7 +452,7 @@ static void da_claim_done(DADiskRef disk, DADissenterRef dissenter, void *ctx) { // Best-effort: claim the disk and register the mount-approval dissenter. // Returns 1 if claimed. The caller proceeds either way — ObtainExclusiveAccess // remains the hard gate; the claim is what keeps DA from remounting after it. -static int da_hold(const char *bsd_name) { +static int da_hold(const char *bsd_name, const volatile uint8_t *cancel) { // Hold the lock only for the g_da_bsd / g_handle field mutations below; it // is dropped before the bounded 5 s wait so legit claim waits aren't // serialized behind it (and re-taken just to publish g_handle.da_claimed). @@ -390,8 +485,8 @@ static int da_hold(const char *bsd_name) { // callback may still fire: keep our ref in da_pending for da_release, // which reaps it (and unclaims a late success) once callbacks are stopped. int claimed = 0; - int timed_out = dispatch_semaphore_wait(r->sem, - dispatch_time(DISPATCH_TIME_NOW, 5LL * NSEC_PER_SEC)) != 0; + int waited = sliced_sem_wait(r->sem, SHIM_DA_CLAIM_MS, cancel); + int timed_out = waited != 0; if (!timed_out) { claimed = r->ok; da_claim_result_release(r); @@ -404,7 +499,7 @@ static int da_hold(const char *bsd_name) { g_handle.da_pending = r; } pthread_mutex_unlock(&g_handle_lock); - return claimed; + return waited == SHIM_CANCELLED ? SHIM_CANCELLED : claimed; } static void da_noop(void *ctx) { (void)ctx; } @@ -447,7 +542,7 @@ static void da_release(void) { // ── Public API ──────────────────────────────────────────────────────────── -int shim_open_exclusive(const char *selector) { +int shim_open_exclusive(const char *selector, const volatile uint8_t *cancel) { kern_return_t kr; HRESULT hr; SInt32 score = 0; @@ -457,6 +552,12 @@ int shim_open_exclusive(const char *selector) { // mmc/plugin mid-setup. Released before the self-locking da_hold (5 s wait). pthread_mutex_lock(&g_handle_lock); + // A Stop before the open does nothing at all: no unmount is started. + if (shim_cancelled(cancel)) { + pthread_mutex_unlock(&g_handle_lock); + return SHIM_CANCELLED; + } + if (g_handle.exclusive && g_handle.scsi) { pthread_mutex_unlock(&g_handle_lock); return 0; @@ -484,62 +585,22 @@ int shim_open_exclusive(const char *selector) { strlcpy(bsd_name, selector, sizeof(bsd_name)); } - // Unmount via diskutil, invoked directly with posix_spawn (no shell) so - // the BSD device name can never be interpreted as shell syntax. A shell - // wrapper here (system()/sh -c) was a command-injection vector for an - // attacker-controlled device argument. Passing bsd_name as a discrete - // argv element also sidesteps the old buffer-truncation concern entirely. - // stdout/stderr go to /dev/null to keep diskutil chatter out of the - // caller's streams. + // Unmount via diskutil, invoked directly with posix_spawn (no shell) so the + // BSD device name is a discrete argv element and never shell syntax. A killed + // unmount is fine: ObtainExclusiveAccess below is the real gate (-5). if (bsd_name[0]) { - posix_spawn_file_actions_t fa; - posix_spawn_file_actions_init(&fa); - posix_spawn_file_actions_addopen(&fa, STDOUT_FILENO, "/dev/null", O_WRONLY, 0); - posix_spawn_file_actions_addopen(&fa, STDERR_FILENO, "/dev/null", O_WRONLY, 0); char *const argv[] = { "diskutil", "unmountDisk", "force", bsd_name, NULL }; - pid_t pid; - if (posix_spawn(&pid, "/usr/sbin/diskutil", &fa, NULL, argv, environ) == 0) { - // BOUNDED wait. A plain blocking waitpid() here hung the public - // scsi::open() forever whenever the unmount wedged — diskutil - // blocks indefinitely on a volume whose filesystem is stuck (a - // hung network mount, a fs process not answering the unmount - // notification), and there is no signal, timeout or cancellation - // reaching this frame. Poll with WNOHANG to a deadline, then - // SIGKILL and reap so no zombie is left behind. - // - // Continuing after a killed unmount is deliberate: - // ObtainExclusiveAccess below is the real gate, and it reports the - // still-mounted disc through the shim's -5 sentinel (mapped to - // Error::DeviceLocked) — a typed error the caller can act on, - // instead of a process that never returns. - const int poll_us = 50000; // 50 ms - const int max_polls = 400; // 400 x 50 ms = 20 s - int status; - int reaped = 0; - for (int i = 0; i <= max_polls; i++) { - pid_t r = waitpid(pid, &status, WNOHANG); - if (r == pid) { reaped = 1; break; } - // r < 0 means the child is already gone (ECHILD) — nothing to - // wait for, and looping would spin to the deadline. - if (r < 0) { reaped = 1; break; } - if (i == max_polls) break; - usleep(poll_us); - } - if (!reaped) { - kill(pid, SIGKILL); - // SIGKILL is uncatchable, so this reap converges; still poll - // rather than block, so the shim has no unbounded wait at all. - for (int i = 0; i < 100; i++) { - if (waitpid(pid, &status, WNOHANG) != 0) break; - usleep(10000); // 10 ms x 100 = 1 s - } - } + // On cancel run_and_reap has killed and reaped diskutil (§2.9 M2). + if (run_and_reap("/usr/sbin/diskutil", argv, SHIM_DISKUTIL_BUDGET_MS, cancel, NULL) + == SHIM_CANCELLED + || sliced_sleep(SHIM_SETTLE_MS, cancel) == SHIM_CANCELLED) { + if (svc) IOObjectRelease(svc); + pthread_mutex_unlock(&g_handle_lock); + return SHIM_CANCELLED; } - posix_spawn_file_actions_destroy(&fa); } - if (bsd_name[0]) usleep(500000); // Check the return before using the port. On failure IOMainPort leaves `mp` // untouched, so every IOKit call below would run against an uninitialised @@ -615,10 +676,11 @@ int shim_open_exclusive(const char *selector) { return -4; } + int cancelled = 0; for (int retry = 0; retry < 10; retry++) { kr = (*g_handle.scsi)->ObtainExclusiveAccess(g_handle.scsi); if (kr == kIOReturnSuccess) break; - usleep(500000); + if (sliced_sleep(SHIM_SETTLE_MS, cancel) == SHIM_CANCELLED) { cancelled = 1; break; } } if (kr != kIOReturnSuccess) { (*g_handle.scsi)->Release(g_handle.scsi); @@ -628,7 +690,7 @@ int shim_open_exclusive(const char *selector) { g_handle.mmc = NULL; g_handle.plugin = NULL; pthread_mutex_unlock(&g_handle_lock); - return -5; + return cancelled ? SHIM_CANCELLED : -5; } g_handle.exclusive = 1; @@ -648,7 +710,11 @@ int shim_open_exclusive(const char *selector) { // allocations fails it returns early (0 → no claim). The lock is deliberately // NOT held across da_hold's ~5 s self-locking claim wait, which would // serialize every open behind it. - if (bsd_name[0]) da_hold(bsd_name); + // A cancel during the claim wait undoes the whole open; da_release reaps the pending claim. + if (bsd_name[0] && da_hold(bsd_name, cancel) == SHIM_CANCELLED) { + shim_close(); + return SHIM_CANCELLED; + } return 0; } @@ -678,7 +744,8 @@ void shim_close(void) { int shim_execute(const unsigned char *cdb, unsigned char cdb_len, void *buf, unsigned int buf_len, int data_in, unsigned char *sense_out, unsigned int sense_len, - unsigned char *task_status_out, unsigned long long *transfer_count) { + unsigned char *task_status_out, unsigned long long *transfer_count, + unsigned int timeout_ms) { if (!g_handle.scsi) return -1; SCSITaskInterface **task = (*g_handle.scsi)->CreateSCSITask(g_handle.scsi); @@ -702,7 +769,9 @@ int shim_execute(const unsigned char *cdb, unsigned char cdb_len, kSCSIDataTransfer_NoDataTransfer); } - (*task)->SetTimeoutDuration(task, 30000); + // §2.9 M1: the caller's timeout_ms, not a fixed 30 s. SCSITaskLib.h SetTimeoutDuration: + // "The timeout duration is counted in milliseconds." + (*task)->SetTimeoutDuration(task, timeout_ms); SCSI_Sense_Data sense; memset(&sense, 0, sizeof(sense)); @@ -821,3 +890,92 @@ int shim_list_drives(ShimDriveInfo *out, int max_entries) { IOObjectRelease(iter); return count; } + +// ── shim_selftest hooks (stop design §5.1 LM1) ──────────────────────────── +// +// Entry points for macos.rs's `shim_selftest_*` tests. They drive the same static +// helpers shim_open_exclusive uses, so no drive is needed (qa release-tests, D6). + +__attribute__((visibility("hidden"))) int shim_selftest_wait_slice_ms(void) { return SHIM_WAIT_SLICE_MS; } + +__attribute__((visibility("hidden"))) int shim_selftest_sleep(unsigned int ms, const volatile uint8_t *cancel) { + return sliced_sleep(ms, cancel); +} + +// A fresh semaphore, signalled up front when `signalled` is set, waited on like the DA claim. +__attribute__((visibility("hidden"))) int shim_selftest_sem_wait(unsigned int ms, int signalled, const volatile uint8_t *cancel) { + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + if (!sem) return -1; + if (signalled) dispatch_semaphore_signal(sem); + int rc = sliced_sem_wait(sem, ms, cancel); + dispatch_release(sem); + return rc; +} + +__attribute__((visibility("hidden"))) int shim_selftest_run_and_reap(const char *path, const char *arg, unsigned int budget_ms, + const volatile uint8_t *cancel, int *pid_out) { + char *const argv[] = { (char *)path, (char *)arg, NULL }; + pid_t pid = 0; + int rc = run_and_reap(path, argv, budget_ms, cancel, &pid); + if (pid_out) *pid_out = (int)pid; + return rc; +} + +// 1 once `pid` is reaped. wait(2) ECHILD: "The process specified by pid does not exist or +// is not a child of the calling process". A zombie is reaped here and reported as 0. +__attribute__((visibility("hidden"))) int shim_selftest_reaped(int pid) { + int status; + errno = 0; + return waitpid((pid_t)pid, &status, WNOHANG) < 0 && errno == ECHILD; +} + +// A fake SCSITaskDeviceInterface that records the timeout shim_execute sets on its task. +static volatile UInt32 g_selftest_timeout_ms; + +static ULONG selftest_release(void *self) { (void)self; return 0; } +static IOReturn selftest_release_exclusive(void *self) { (void)self; return kIOReturnSuccess; } +static IOReturn selftest_set_cdb(void *task, UInt8 *cdb, UInt8 size) { + (void)task; (void)cdb; (void)size; return kIOReturnSuccess; +} +static IOReturn selftest_set_sg(void *task, SCSITaskSGElement *sg, UInt8 entries, + UInt64 count, UInt8 direction) { + (void)task; (void)sg; (void)entries; (void)count; (void)direction; return kIOReturnSuccess; +} +static IOReturn selftest_set_timeout(void *task, UInt32 ms) { + (void)task; g_selftest_timeout_ms = ms; return kIOReturnSuccess; +} +static IOReturn selftest_execute(void *task, SCSI_Sense_Data *sense, SCSITaskStatus *status, + UInt64 *count) { + (void)task; (void)sense; *status = kSCSITaskStatus_GOOD; *count = 0; return kIOReturnSuccess; +} + +static SCSITaskInterface g_selftest_task_vt = { + .Release = selftest_release, + .SetCommandDescriptorBlock = selftest_set_cdb, + .SetScatterGatherEntries = selftest_set_sg, + .SetTimeoutDuration = selftest_set_timeout, + .ExecuteTaskSync = selftest_execute, +}; +static SCSITaskInterface *g_selftest_task = &g_selftest_task_vt; + +static SCSITaskInterface **selftest_create_task(void *self) { (void)self; return &g_selftest_task; } + +static SCSITaskDeviceInterface g_selftest_device_vt = { + .Release = selftest_release, + .ReleaseExclusiveAccess = selftest_release_exclusive, + .CreateSCSITask = selftest_create_task, +}; +static SCSITaskDeviceInterface *g_selftest_device = &g_selftest_device_vt; + +// Install the fake as the open handle; shim_close tears it down. -1 if a handle is open. +__attribute__((visibility("hidden"))) int shim_selftest_install_fake_device(void) { + pthread_mutex_lock(&g_handle_lock); + if (g_handle.scsi) { pthread_mutex_unlock(&g_handle_lock); return -1; } + g_handle.scsi = &g_selftest_device; + g_handle.exclusive = 1; + g_selftest_timeout_ms = 0; + pthread_mutex_unlock(&g_handle_lock); + return 0; +} + +__attribute__((visibility("hidden"))) unsigned int shim_selftest_last_timeout_ms(void) { return g_selftest_timeout_ms; } diff --git a/src/scsi/mod.rs b/src/scsi/mod.rs index fae1cbe1..3100fa2c 100644 --- a/src/scsi/mod.rs +++ b/src/scsi/mod.rs @@ -375,6 +375,50 @@ pub(crate) fn parse_sense(sense: &[u8], sb_len_wr: u8) -> ScsiSense { } } +/// The sense-key specific progress indication in a sense buffer (§2.11), or `None` +/// when the sense key is not NOT READY / NO SENSE, SKSV is clear, or the buffer is +/// short. Fixed format (70h/71h): SKSV is byte 15 bit 7, the value bytes 16-17; +/// descriptor format (72h/73h): the sense-key specific descriptor, SKSV + bytes 5-6. +pub(crate) fn parse_sense_progress(sense: &[u8], sb_len_wr: u8) -> Option { + // SS-1 Table 18: "NO SENSE or NOT READY | Progress indication"; other sense keys + // give the field another meaning. + let n = (sb_len_wr as usize).min(sense.len()); + let sense = &sense[..n]; + let progress_key = |k: u8| matches!(k & 0x0F, SENSE_KEY_NO_SENSE | SENSE_KEY_NOT_READY); + match sense.first()? & 0x7F { + 0x70 | 0x71 => { + // SS-1 Table 27: byte 15 "SKSV", bytes 15-17 "SENSE KEY SPECIFIC"; SKSV set + // "indicates the SENSE KEY SPECIFIC field contains valid information". + if !progress_key(*sense.get(2)?) || sense.get(15)? & 0x80 == 0 { + return None; + } + Some(u16::from_be_bytes([*sense.get(16)?, *sense.get(17)?])) + } + 0x72 | 0x73 => { + if !progress_key(*sense.get(1)?) { + return None; + } + // SS-2 Table 12/17: descriptors follow the 8-byte header; the sense key + // specific descriptor is "DESCRIPTOR TYPE (02h)", "ADDITIONAL LENGTH (06h)". + let end = (8 + *sense.get(7)? as usize).min(sense.len()); + let mut at = 8; + while at + 2 <= end { + let (kind, len) = (sense[at], sense[at + 1] as usize); + if kind == 0x02 { + let d = sense.get(at..at + 2 + len)?; + if d.len() < 7 || d[4] & 0x80 == 0 { + return None; + } + return Some(u16::from_be_bytes([d[5], d[6]])); + } + at += 2 + len; + } + None + } + _ => None, + } +} + // ── SG_IO driver_status bits ──────────────────────────────────────────────── // DRIVER_SENSE (0x08): SG_IO driver_status bit meaning sense data was @@ -418,6 +462,14 @@ pub trait ScsiTransport: Send { fn max_transfer_bytes(&self) -> usize { 1 << 20 } + + /// The sense-key specific progress indication of the last command's sense data + /// (a NOT READY or NO SENSE answer with SKSV set), as a fraction of 65 536. + /// `wait_ready` counts a rising value as progress (stop design §2.11). Default: + /// no progress reported. + fn last_sense_progress(&self) -> Option { + None + } } // ── Platform-agnostic open / reset ────────────────────────────────────────── @@ -425,6 +477,15 @@ pub trait ScsiTransport: Send { /// Open a SCSI transport for the given device path. /// Selects the right backend for the current platform. pub fn open(device: &Path) -> Result> { + open_with(device, &crate::halt::Halt::new()) +} + +// `open` whose waits observe `halt`: the macOS shim's open waits are sliced and cancellable +// (stop design §2.9 M2); SG_IO and SPTI opens do not wait. +pub(crate) fn open_with(device: &Path, halt: &crate::halt::Halt) -> Result> { + #[cfg(not(target_os = "macos"))] + let _ = halt; + #[cfg(target_os = "linux")] { Ok(Box::new(linux::SgIoTransport::open(device)?)) @@ -432,7 +493,7 @@ pub fn open(device: &Path) -> Result> { #[cfg(target_os = "macos")] { - Ok(Box::new(macos::MacScsiTransport::open(device)?)) + Ok(Box::new(macos::MacScsiTransport::open(device, halt)?)) } #[cfg(target_os = "windows")] @@ -1678,3 +1739,149 @@ mod inquiry_tests { assert_eq!(r.raw[0], 0x05, "peripheral device type byte preserved"); } } + +#[cfg(test)] +mod sense_progress_tests { + use super::*; + use crate::spec::stop::{SS_1_SENSE_PROGRESS, SS_2_DESCRIPTOR_SENSE}; + + // Fixed format (70h): sense key, ASC/ASCQ, and the SKSV + progress bytes 15-17. + fn fixed(key: u8, sksv: bool, progress: u16) -> Vec { + let mut s = vec![0u8; 18]; + s[0] = 0x70; + s[2] = key; + s[7] = 10; + s[12] = 0x04; + s[13] = 0x01; + s[15] = if sksv { 0x80 } else { 0x00 }; + s[16..18].copy_from_slice(&progress.to_be_bytes()); + s + } + + // Descriptor format (72h) with `before` other descriptors ahead of the + // sense-key specific one (type 02h, length 06h). + fn descriptor(key: u8, sksv: bool, progress: u16, before: usize) -> Vec { + let mut s = vec![0x72, key, 0x04, 0x01, 0, 0, 0, 0]; + for _ in 0..before { + s.extend_from_slice(&[0x00, 0x0A, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + } + s.extend_from_slice(&[0x02, 0x06, 0, 0, if sksv { 0x80 } else { 0 }]); + s.extend_from_slice(&progress.to_be_bytes()); + s.push(0); + s[7] = (s.len() - 8) as u8; + s + } + + fn parse(s: &[u8]) -> Option { + parse_sense_progress(s, s.len() as u8) + } + + /// LD12e: fixed and descriptor formats; SKSV set/clear; NOT READY vs other keys; + /// short buffers → `None`. + #[test] + fn parse_sense_progress_fixed_and_descriptor() { + assert_eq!( + parse(&fixed(SENSE_KEY_NOT_READY, true, 0x1234)), + Some(0x1234) + ); + assert_eq!( + parse(&descriptor(SENSE_KEY_NOT_READY, true, 0x1234, 0)), + Some(0x1234) + ); + let f = fixed(SENSE_KEY_NOT_READY, true, 7); + for n in 0..f.len() { + assert_eq!(parse_sense_progress(&f, n as u8), None, "fixed, {n} bytes"); + } + let d = descriptor(SENSE_KEY_NOT_READY, true, 7, 0); + for n in 0..d.len() - 1 { + assert_eq!( + parse_sense_progress(&d, n as u8), + None, + "descriptor, {n} bytes" + ); + } + } + + /// SP1: corroborated by SS-1 (SKSV set to one "indicates the SENSE KEY SPECIFIC + /// field contains valid information"): SKSV clear → `None`, whatever bytes 16-17 hold. + #[test] + fn progress_ignored_when_sksv_clear() { + assert!( + SS_1_SENSE_PROGRESS + .text + .contains("(SKSV) bit set to one indicates") + ); + assert_eq!(parse(&fixed(SENSE_KEY_NOT_READY, false, 0xFFFF)), None); + assert_eq!( + parse(&descriptor(SENSE_KEY_NOT_READY, false, 0xFFFF, 0)), + None + ); + } + + /// SP2: corroborated by SS-1 Table 18 ("NO SENSE or NOT READY … Progress + /// indication"): the same bytes under another sense key are not progress. + #[test] + fn progress_only_for_not_ready_and_no_sense() { + assert!( + SS_1_SENSE_PROGRESS + .text + .starts_with("NO SENSE or NOT READY") + ); + assert_eq!(parse(&fixed(SENSE_KEY_NO_SENSE, true, 9)), Some(9)); + for key in [ + SENSE_KEY_MEDIUM_ERROR, + SENSE_KEY_ILLEGAL_REQUEST, + SENSE_KEY_UNIT_ATTENTION, + ] { + assert_eq!(parse(&fixed(key, true, 9)), None, "fixed key {key}"); + assert_eq!( + parse(&descriptor(key, true, 9, 0)), + None, + "descriptor key {key}" + ); + } + } + + /// SP3: corroborated by SS-1 ("a numerator that has 65 536 (10000h) as its + /// denominator"): big-endian, 0x8000 is half, 0xFFFF the maximum. + #[test] + fn progress_big_endian_fraction() { + assert!( + SS_1_SENSE_PROGRESS + .text + .contains("65 536 (10000h) as its denominator") + ); + assert_eq!( + parse(&fixed(SENSE_KEY_NOT_READY, true, 0x8000)), + Some(32768) + ); + assert_eq!( + parse(&fixed(SENSE_KEY_NOT_READY, true, 0xFFFF)), + Some(u16::MAX) + ); + let mut s = fixed(SENSE_KEY_NOT_READY, true, 0); + s[16] = 0x01; + assert_eq!(parse(&s), Some(0x0100), "byte 16 is the MSB"); + } + + /// SP4: corroborated by SS-2: the sense key specific descriptor (type 02h) is + /// found at any position in the descriptor list; a truncated one → `None`. + #[test] + fn descriptor_format_progress_descriptor() { + assert!( + SS_2_DESCRIPTOR_SENSE + .text + .contains("72h (current errors) and 73h") + ); + for before in 0..3 { + let d = descriptor(SENSE_KEY_NOT_READY, true, 0x0203, before); + assert_eq!(parse(&d), Some(0x0203), "{before} descriptors ahead"); + let mut deferred = d.clone(); + deferred[0] = 0x73; + assert_eq!(parse(&deferred), Some(0x0203)); + } + let mut cut = descriptor(SENSE_KEY_NOT_READY, true, 0x0203, 1); + cut.truncate(cut.len() - 3); + assert_eq!(parse(&cut), None, "truncated descriptor"); + } +} diff --git a/src/scsi/windows.rs b/src/scsi/windows.rs index db5c6c2a..3b0eadd0 100644 --- a/src/scsi/windows.rs +++ b/src/scsi/windows.rs @@ -147,6 +147,8 @@ pub struct SptiTransport { alignment_mask: usize, /// Reused aligned scratch for misaligned buffers; grows to the largest transfer seen. bounce: Vec, + /// The last command's sense-key specific progress indication (§2.11). + last_progress: Option, } // SptiTransport's fields are an isize HANDLE plus owned data: Send+Sync auto-derive; @@ -211,6 +213,7 @@ impl SptiTransport { max_transfer, alignment_mask: super::sanitize_alignment_mask(alignment_mask), bounce: Vec::new(), + last_progress: None, }) } } @@ -334,6 +337,10 @@ impl ScsiTransport for SptiTransport { self.max_transfer } + fn last_sense_progress(&self) -> Option { + self.last_progress + } + fn execute( &mut self, cdb: &[u8], @@ -341,6 +348,7 @@ impl ScsiTransport for SptiTransport { data: &mut [u8], timeout_ms: u32, ) -> Result { + self.last_progress = None; // Zero the data buffer for reads to prevent returning uninitialized data // if the driver doesn't fully update DataTransferLength. if direction == DataDirection::FromDevice { @@ -453,6 +461,7 @@ impl ScsiTransport for SptiTransport { // SenseInfoLength; pass the full K_SENSE_SIZE and let // parse_sense key off byte 0's response code (fixed vs descriptor). let parsed = super::parse_sense(&sptwb.sense, K_SENSE_SIZE as u8); + self.last_progress = super::parse_sense_progress(&sptwb.sense, K_SENSE_SIZE as u8); return Err(Error::ScsiError { opcode: cdb.first().copied().unwrap_or(0), status: sptwb.spt.ScsiStatus, diff --git a/src/sector/decrypting.rs b/src/sector/decrypting.rs index e03c926d..5992a2be 100644 --- a/src/sector/decrypting.rs +++ b/src/sector/decrypting.rs @@ -771,7 +771,7 @@ mod tests { // corruption anywhere in the other 6112 bytes pass undetected. assert_eq!( buf, - clear_aacs_unit(), + crate::aacs::content::cpi_cleared(clear_aacs_unit()), "the decrypted unit must equal the known plaintext byte-for-byte" ); } @@ -972,6 +972,6 @@ mod tests { .expect("file-grid read decrypts"); let mut want = clear_aacs_unit(); want.extend(clear_aacs_unit()); - assert_eq!(buf, want); + assert_eq!(buf, crate::aacs::content::cpi_cleared(want)); } } diff --git a/src/sector/prefetched.rs b/src/sector/prefetched.rs index fb75280d..ad710cdc 100644 --- a/src/sector/prefetched.rs +++ b/src/sector/prefetched.rs @@ -9,10 +9,10 @@ use crate::error::Result; use crate::event::{Event, EventKind}; -use crate::halt::{Halt, POLL_INTERVAL}; +use crate::halt::{DriveHolder, Halt, Recv, SendOutcome}; use crate::sector::SectorSource; -use crossbeam_channel::{Receiver, RecvTimeoutError, Sender, bounded}; -use std::thread::JoinHandle; +use crossbeam_channel::{Receiver, Sender, bounded}; +use std::time::Duration; /// Producer-thread event callback. Fires `BytesRead` after every /// successful batch read so the consumer side can update a UI @@ -43,8 +43,8 @@ pub struct PrefetchedSectorSource { /// cross-thread alloc/free was the dominant cost in the demux /// thread before this). recycle_tx: Sender>, - /// Joined on drop so producer cleanup runs deterministically. - producer: Option>, + /// The producer, a Drive holder (§2.5): joined on drop. + producer: Option>, /// Total sector count across all extents, computed once at /// construction (the sum of each extent's `sector_count`) and /// returned by [`capacity_sectors`]. Never updated by reads. @@ -59,6 +59,17 @@ pub struct PrefetchedSectorSource { producer_failed: bool, /// The reader's unmapped stream files, snapshotted before it moved to the producer. unmapped: Vec, + /// The op's token: once cancelled, the closed channel is a stop, not EOF (L096). + halt: Option, +} + +// Hand `item` to the consumer, waiting on a full channel; `false` once the consumer is +// gone or `halt` is cancelled (observed within a slice), so the producer returns. +fn send_or_stop(halt: &Halt, tx: &Sender, item: Batch) -> bool { + matches!( + halt.send_timeout(tx, item, Duration::MAX), + Ok(SendOutcome::Sent) + ) } impl PrefetchedSectorSource { @@ -126,6 +137,8 @@ impl PrefetchedSectorSource { let (tx, rx) = bounded::(PREFETCH_CHANNEL_DEPTH); let (recycle_tx, recycle_rx) = bounded::>(PREFETCH_CHANNEL_DEPTH + 1); let batch_bytes = batch_sectors as usize * 2048; + // A never-cancelled stand-in keeps one halt-aware code path without a token. + let wait = halt.clone().unwrap_or_default(); // Seed the recycle pool so the producer has a buffer on the first // iteration; otherwise the first recycle_rx.recv() blocks forever. @@ -133,122 +146,118 @@ impl PrefetchedSectorSource { let _ = recycle_tx.send(vec![0u8; batch_bytes]); } - let producer = std::thread::Builder::new() - .name("freemkv-prefetch".into()) - .spawn(move || { - // catch_unwind so a panic (decrypt source, read path, event_fn) isn't - // mistaken for clean EOF: a dropped `tx` alone would finalize a - // TRUNCATED mux as success. Locals are thread-local, so this is sound. - let body = std::panic::AssertUnwindSafe(|| { - let mut ext_idx = 0usize; - let mut offset: u32 = 0; - let mut bytes_read_total: u64 = 0; - while ext_idx < extents.len() { - if halt.as_ref().map(|h| h.is_cancelled()).unwrap_or(false) { - return; - } - let extent = &extents[ext_idx]; - // AACS aligned units anchor at THIS extent's start LBA, so gate - // relative to it, not absolute disc LBA 0. No-op for - // non-decrypting / CSS / None sources. - reader.set_unit_base(extent.start_lba); - let remaining = extent.sector_count.saturating_sub(offset); - if remaining == 0 { - ext_idx += 1; - offset = 0; - continue; - } - // AACS units are SECTOR_ALIGNMENT (3) sectors; decrypt processes only - // full units, so a tail below one unit can't decrypt — error, don't emit - // encrypted bytes. (`remaining > 0` above, so `< align` IS the short tail; `!is_multiple_of` was redundant.) - if remaining < unit_align as u32 { - let _ = tx.send(Err(crate::error::Error::ExtentNotUnitAligned.into())); - return; - } - let mut sectors = remaining.min(batch_sectors as u32) as u16; - // Trim to a whole number of units. A trim to 0 here means the - // batch window landed on a sub-unit boundary (not the - // trailing-tail case, rejected above); clamp to one unit. - if sectors >= unit_align { - sectors -= sectors % unit_align; - } else { - sectors = unit_align; - } - let bytes = sectors as usize * 2048; - // Re-poll halt every POLL_INTERVAL: a pure-AtomicBool Halt - // doesn't disconnect the channel, so a blocking recv() would - // never re-reach the cancel check. Mirrors BytePrefetcher. - let mut buf = loop { - match recycle_rx.recv_timeout(POLL_INTERVAL) { - Ok(b) => break b, - Err(RecvTimeoutError::Timeout) => { - if halt.as_ref().map(|h| h.is_cancelled()).unwrap_or(false) { - return; - } - } - // Consumer dropped both channels. - Err(RecvTimeoutError::Disconnected) => return, - } - }; - // Sound resize (was `unsafe set_len` guarded only by capacity): - // public `into_channels` lets a caller recycle a cap-only Vec, so - // set_len could expose uninit memory (UB) — GHSA-j8ww-f5fg-9pmh. - buf.resize(bytes, 0); - // `start_lba + offset` derives from untrusted extent - // data — saturate rather than wrap/panic on a - // hostile start_lba near u32::MAX. - let lba = extent.start_lba.saturating_add(offset); - match reader.read_sectors(lba, sectors, &mut buf[..bytes], false) { - Ok(n) => { - // A short read must not desync the stream: advance by - // sectors actually read, and reject a non-whole-sector - // count (belt-and-braces; FileSectorSource read_exact's). - if n % 2048 != 0 { - let _ = tx.send(Err( - crate::error::Error::ExtentNotUnitAligned.into() - )); - return; - } - let sectors_read = (n / 2048) as u32; - // A zero-byte read isn't EOF (extents still have - // `remaining`) and would spin forever; send a terminal - // sentinel instead of a clean EOF that reports success. - if sectors_read == 0 { - let _ = - tx.send(Err(crate::error::Error::SourceTerminated.into())); - return; - } - buf.truncate(n); - bytes_read_total = bytes_read_total.saturating_add(n as u64); - if let Some(ref f) = event_fn { - f(Event { - kind: EventKind::BytesRead { - bytes: bytes_read_total, - total: bytes_total_extents, - }, - }); - } - if tx.send(Ok(buf)).is_err() { - return; // consumer dropped - } - offset = offset.saturating_add(sectors_read); - } - Err(e) => { - let _ = tx.send(Err(e.into())); + #[cfg(test)] + assert!( + crate::halt::DRIVE_HOLDER_TEST_LOCK.try_lock().is_err(), + "a test that spawns a prefetcher (a Drive holder) must hold DRIVE_HOLDER_TEST_LOCK" + ); + let producer = crate::halt::spawn_drive_holder("prefetch", move || { + // catch_unwind so a panic (decrypt source, read path, event_fn) isn't + // mistaken for clean EOF: a dropped `tx` alone would finalize a + // TRUNCATED mux as success. Locals are thread-local, so this is sound. + let body = std::panic::AssertUnwindSafe(|| { + let mut ext_idx = 0usize; + let mut offset: u32 = 0; + let mut bytes_read_total: u64 = 0; + while ext_idx < extents.len() { + if wait.is_cancelled() { + return; + } + let extent = &extents[ext_idx]; + // AACS aligned units anchor at THIS extent's start LBA, so gate + // relative to it, not absolute disc LBA 0. No-op for + // non-decrypting / CSS / None sources. + reader.set_unit_base(extent.start_lba); + let remaining = extent.sector_count.saturating_sub(offset); + if remaining == 0 { + ext_idx += 1; + offset = 0; + continue; + } + // AACS units are SECTOR_ALIGNMENT (3) sectors; decrypt processes only + // full units, so a tail below one unit can't decrypt — error, don't emit + // encrypted bytes. (`remaining > 0` above, so `< align` IS the short tail; `!is_multiple_of` was redundant.) + if remaining < unit_align as u32 { + let e = crate::error::Error::ExtentNotUnitAligned.into(); + send_or_stop(&wait, &tx, Err(e)); + return; + } + let mut sectors = remaining.min(batch_sectors as u32) as u16; + // Trim to a whole number of units. A trim to 0 here means the + // batch window landed on a sub-unit boundary (not the + // trailing-tail case, rejected above); clamp to one unit. + if sectors >= unit_align { + sectors -= sectors % unit_align; + } else { + sectors = unit_align; + } + let bytes = sectors as usize * 2048; + // Halt-aware: a cancel does not disconnect the channel, so a + // plain recv() would never re-reach the check. Disconnected = + // the consumer dropped both channels. + let Ok(Recv::Item(mut buf)) = wait.recv_timeout(&recycle_rx, Duration::MAX) + else { + return; + }; + // Sound resize (was `unsafe set_len` guarded only by capacity): + // public `into_channels` lets a caller recycle a cap-only Vec, so + // set_len could expose uninit memory (UB) — GHSA-j8ww-f5fg-9pmh. + buf.resize(bytes, 0); + // `start_lba + offset` derives from untrusted extent + // data — saturate rather than wrap/panic on a + // hostile start_lba near u32::MAX. + let lba = extent.start_lba.saturating_add(offset); + match reader.read_sectors(lba, sectors, &mut buf[..bytes], false) { + Ok(n) => { + // A short read must not desync the stream: advance by + // sectors actually read, and reject a non-whole-sector + // count (belt-and-braces; FileSectorSource read_exact's). + if n % 2048 != 0 { + let e = crate::error::Error::ExtentNotUnitAligned.into(); + send_or_stop(&wait, &tx, Err(e)); return; } + let sectors_read = (n / 2048) as u32; + // A zero-byte read isn't EOF (extents still have + // `remaining`) and would spin forever; send a terminal + // sentinel instead of a clean EOF that reports success. + if sectors_read == 0 { + let e = crate::error::Error::SourceTerminated.into(); + send_or_stop(&wait, &tx, Err(e)); + return; + } + buf.truncate(n); + bytes_read_total = bytes_read_total.saturating_add(n as u64); + if let Some(ref f) = event_fn { + f(Event { + kind: EventKind::BytesRead { + bytes: bytes_read_total, + total: bytes_total_extents, + }, + }); + } + if !send_or_stop(&wait, &tx, Ok(buf)) { + return; // consumer dropped, or stopped + } + offset = offset.saturating_add(sectors_read); + } + Err(e) => { + send_or_stop(&wait, &tx, Err(e.into())); + return; } } - // Drop tx implicitly — consumer sees RecvError → EOF. - }); - if std::panic::catch_unwind(body).is_err() { - // Panicked mid-stream — surface a typed error so the demux - // doesn't read the dropped channel as clean EOF and truncate. - // Ignore send failure: consumer already gone, nothing to report. - let _ = tx.send(Err(crate::error::Error::DemuxThreadPanicked.into())); } - }) - .map_err(|e| crate::error::Error::IoError { source: e })?; + // Drop tx implicitly — consumer sees RecvError → EOF. + }); + if std::panic::catch_unwind(body).is_err() { + // Panicked mid-stream — surface a typed error so the demux + // doesn't read the dropped channel as clean EOF and truncate. + // Ignore send failure: consumer already gone, nothing to report. + let e = crate::error::Error::DemuxThreadPanicked.into(); + send_or_stop(&wait, &tx, Err(e)); + } + }) + .map_err(|e| crate::error::Error::IoError { source: e })?; Ok(Self { rx, @@ -257,6 +266,7 @@ impl PrefetchedSectorSource { total_sectors, producer_failed: false, unmapped, + halt, }) } @@ -264,17 +274,18 @@ impl PrefetchedSectorSource { /// and pushes drained ones back through `recycle_tx`. Returns `(forward_rx, recycle_tx, /// shell)`; the shell holds the producer's `JoinHandle` (drop it to join) plus /// `total_sectors`. - pub fn into_channels(self) -> (Receiver, Sender>, PrefetchShell) { - // MOVE the fields out, never clone: pre-1.0.0 code cloned + `forget`-ed - // `self`, leaking endpoints that defeated disconnection shutdown and - // hung `PrefetchShell::drop`'s `join()`. `ManuallyDrop` avoids that. - let me = std::mem::ManuallyDrop::new(self); - // SAFETY: `me` is `ManuallyDrop` so it drops none of these fields; - // each `ptr::read` moves exactly one field out exactly once, so - // there are no double-frees and no aliasing. - let producer = unsafe { std::ptr::read(&me.producer) }; - let rx = unsafe { std::ptr::read(&me.rx) }; - let recycle = unsafe { std::ptr::read(&me.recycle_tx) }; + pub fn into_channels(mut self) -> (Receiver, Sender>, PrefetchShell) { + // Same dead-channel swap `Drop for PrefetchedSectorSource` uses below: leaves `self` + // holding only harmless placeholders, so no `unsafe`/`ManuallyDrop`/`ptr::read`, and + // no double-drop (real endpoints already moved out via `mem::replace`/`Option::take`). + let (dead_tx, dead_rx) = bounded::(0); + drop(dead_tx); + let rx = std::mem::replace(&mut self.rx, dead_rx); + let (dead_send, dead_recv) = bounded::>(0); + drop(dead_recv); + let recycle = std::mem::replace(&mut self.recycle_tx, dead_send); + let producer = self.producer.take(); + // `self` drops here: dead endpoints no-op, `producer` is `None` (no join). (rx, recycle, PrefetchShell { producer }) } } @@ -283,7 +294,15 @@ impl PrefetchedSectorSource { /// producer thread join handle so dropping the shell joins the /// producer, even though the channels have been peeled off. pub struct PrefetchShell { - producer: Option>, + producer: Option>, +} + +// Every test that spawns a producer holds this (the halt module's Drive-holder test lock). +#[cfg(test)] +pub(crate) fn holder_test_lock() -> std::sync::MutexGuard<'static, ()> { + crate::halt::DRIVE_HOLDER_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()) } impl Drop for PrefetchShell { @@ -365,9 +384,13 @@ impl SectorSource for PrefetchedSectorSource { self.producer_failed = true; Err(crate::error::Error::from(e)) } - // Channel closed. Clean EOF only if the producer never signalled a - // failure — else `Ok(0)` would let fill_extents mistake a dead - // source for a short read and zero-fill the rest as "complete". + // Channel closed. A stop first (L096, LP17): a halted producer returns + // silently, and `Ok(0)` would read as a short, complete source. + Err(_) if self.halt.as_ref().is_some_and(Halt::is_cancelled) => { + Err(crate::error::Error::Halted) + } + // Clean EOF only if the producer never signalled a failure — else `Ok(0)` + // lets fill_extents zero-fill a dead source's rest as "complete". Err(_) if self.producer_failed => Err(crate::error::Error::SourceTerminated), Err(_) => Ok(0), } @@ -521,6 +544,7 @@ mod tests { // at construction and still forwarded afterwards. #[test] fn prefetched_source_forwards_unmapped_stream_files() { + let _serial = serial(); use crate::sector::bus_removal::test_support::{Reports, m2ts1, unmapped_paths}; let ext = vec![crate::disc::Extent { start_lba: 0, @@ -534,6 +558,7 @@ mod tests { // must join the producer cleanly. #[test] fn drop_undrained_source_joins_cleanly() { + let _serial = serial(); with_watchdog(Duration::from_secs(10), || { let extents = vec![Extent { start_lba: 0, @@ -557,6 +582,7 @@ mod tests { // disconnection so `PrefetchShell` drop (join) returns promptly. #[test] fn into_channels_drop_releases_producer() { + let _serial = serial(); within(10, || { let src = PrefetchedSectorSource::new(EndlessZeroSource, big_extent(), 3, None) .expect("spawn"); @@ -570,11 +596,70 @@ mod tests { }); } + // L106 (safe dead-channel/`Option::take` rewrite, no `unsafe`): dropping `shell` must + // release the producer's Drive-holder slot within the settle bound below — a leaked + // thread would never let the count return to `before`. + #[test] + fn into_channels_then_drop_leaks_no_drive_holder() { + let _serial = serial(); + let before = crate::halt::live_drive_holders(); + within(10, || { + let src = PrefetchedSectorSource::new(EndlessZeroSource, big_extent(), 3, None) + .expect("spawn"); + let (rx, recycle_tx, shell) = src.into_channels(); + drop(rx); + drop(recycle_tx); + drop(shell); // joins: producer thread ends, its holder guard drops. + }); + assert!( + holders_settle_to(before, Duration::from_secs(2)), + "into_channels + drop must release the producer's drive-holder slot exactly once" + ); + } + + // No-double-drop is structural now (E0040 bars an explicit second `Drop::drop`). This + // checks channels handed back by `into_channels` still carry real batches end-to-end — + // the safe rewrite must not have swapped in the dead placeholder channels by mistake. + #[test] + fn into_channels_channels_still_deliver_batches() { + let _serial = serial(); + with_watchdog(Duration::from_secs(10), || { + let start = 40u32; + let count = 9u32; // three units + let extents = vec![Extent { + start_lba: start, + sector_count: count, + }]; + let src = PatternSource { capacity: 1000 }; + let pf = PrefetchedSectorSource::new(src, extents, 3, None).expect("spawn"); + let (rx, recycle_tx, shell) = pf.into_channels(); + let mut got = Vec::new(); + while let Ok(batch) = rx.recv() { + let buf = batch.expect("no read error expected"); + got.extend_from_slice(&buf); + let _ = recycle_tx.send(buf); + } + drop(recycle_tx); + drop(shell); + assert_eq!(got.len(), (count as usize) * 2048, "all sectors delivered"); + for i in 0..count { + let tag = ((start + i) & 0xff) as u8; + let off = i as usize * 2048; + assert!( + got[off..off + 2048].iter().all(|b| *b == tag), + "sector {i} (lba {}) content mismatch after into_channels", + start + i + ); + } + }); + } + // Same property via the halt path: cancel the token, then the producer // must exit and the shell join must complete (drain on a background // thread so blocked sends make progress toward the halt check). #[test] fn halt_releases_producer() { + let _serial = serial(); within(10, || { let halt = Halt::new(); let src = @@ -597,10 +682,127 @@ mod tests { }); } + // Every test that spawns the producer (a Drive holder, §2.5) serialises on + // this, so a holder-count assertion never sees another test's producer. + fn serial() -> std::sync::MutexGuard<'static, ()> { + super::holder_test_lock() + } + + // Wait (bounded) for the live Drive-holder count to reach `n`. + fn holders_settle_to(n: usize, bound: Duration) -> bool { + let t = std::time::Instant::now(); + while crate::halt::live_drive_holders() != n { + if t.elapsed() > bound { + return false; + } + std::thread::sleep(Duration::from_millis(2)); + } + true + } + + /// LP7 (§2.5 "Drive-holding threads"): the producer is a Drive holder; a cancel + /// while it is blocked on a full channel ends it within a slice (holders back + /// to 0) even though the consumer keeps the channel open, and drop joins it. + #[test] + fn sector_prefetcher_stop_with_producer_blocked() { + let _serial = serial(); + let before = crate::halt::live_drive_holders(); + let halt = Halt::new(); + let pf = + PrefetchedSectorSource::new(EndlessZeroSource, big_extent(), 3, Some(halt.clone())) + .expect("spawn"); + assert_eq!( + crate::halt::live_drive_holders(), + before + 1, + "the producer runs as a Drive holder" + ); + // Undrained: the forward channel fills and the producer blocks sending. + std::thread::sleep(Duration::from_millis(100)); + halt.cancel(); + let ended = holders_settle_to(before, Duration::from_secs(1)); + within(10, move || drop(pf)); + assert!( + ended, + "a cancel must end the blocked producer within a slice" + ); + assert_eq!( + crate::halt::live_drive_holders(), + before, + "joined before return" + ); + } + + /// LP8 (both Drop sites): after a cancel, dropping the source, or the shell with + /// the channels still held, returns within 1 s. Guard for the direct drop. + #[test] + fn sector_prefetcher_drop_after_cancel_returns() { + let _serial = serial(); + let halt = Halt::new(); + let pf = + PrefetchedSectorSource::new(EndlessZeroSource, big_extent(), 3, Some(halt.clone())) + .expect("spawn"); + std::thread::sleep(Duration::from_millis(50)); + halt.cancel(); + within(1, move || drop(pf)); + + let halt = Halt::new(); + let pf = + PrefetchedSectorSource::new(EndlessZeroSource, big_extent(), 3, Some(halt.clone())) + .expect("spawn"); + let (rx, recycle_tx, shell) = pf.into_channels(); + std::thread::sleep(Duration::from_millis(50)); + halt.cancel(); + within(1, move || drop(shell)); + drop((rx, recycle_tx)); + } + + /// LP17 (L096, §2.5 "Halt is not EOF"): once the halt is cancelled, the closed + /// channel is `Err(Halted)`, never the clean-EOF `Ok(0)`; queued batches still + /// drain first. The un-halted close stays `Ok(0)` (below). + #[test] + fn prefetcher_halt_is_not_eof() { + let _serial = serial(); + with_watchdog(Duration::from_secs(10), || { + let halt = Halt::new(); + let mut pf = + PrefetchedSectorSource::new(EndlessZeroSource, big_extent(), 3, Some(halt.clone())) + .expect("spawn"); + let mut buf = vec![0u8; 3 * 2048]; + assert_eq!(pf.read_sectors(0, 3, &mut buf, false).unwrap(), 3 * 2048); + halt.cancel(); + let end = loop { + match pf.read_sectors(0, 3, &mut buf, false) { + Ok(n) if n > 0 => continue, + other => break other, + } + }; + let err = end.expect_err("a stop mid-title must not read as a short, complete source"); + assert!(matches!(err, crate::error::Error::Halted), "{err:?}"); + }); + } + + /// LP17, the other half: a producer that finishes un-halted still ends in `Ok(0)`. + #[test] + fn prefetcher_unhalted_close_is_eof() { + let _serial = serial(); + with_watchdog(Duration::from_secs(10), || { + let ext = vec![Extent { + start_lba: 0, + sector_count: 6, + }]; + let halt = Halt::new(); + let mut pf = + PrefetchedSectorSource::new(EndlessZeroSource, ext, 3, Some(halt)).expect("spawn"); + let (_, last) = drain_direct(&mut pf, 3, 8); + assert_eq!(last.unwrap(), 0); + }); + } + /// `batch_sectors == 0` is rejected rather than spawning a thread /// that spins forever emitting empty batches. #[test] fn zero_batch_rejected() { + let _serial = serial(); let err = PrefetchedSectorSource::new(EndlessZeroSource, big_extent(), 0, None); assert!(err.is_err(), "zero batch_sectors must be rejected"); } @@ -609,6 +811,7 @@ mod tests { // divide-by-zero panic (`remaining % 0`) misreported as DemuxThreadPanicked. #[test] fn zero_unit_align_rejected() { + let _serial = serial(); let res = PrefetchedSectorSource::new_with_events( EndlessZeroSource, big_extent(), @@ -628,6 +831,7 @@ mod tests { // dropped instead of recycled, so the 4th call deadlocked. #[test] fn direct_reads_past_pool_depth_do_not_deadlock() { + let _serial = serial(); with_watchdog(Duration::from_secs(10), || { // 24 sectors = 8 aligned units; batch of 3 sectors gives 8 // sequential batches, well past the 3-buffer pool depth. @@ -656,6 +860,7 @@ mod tests { // bar + stall watchdog depend on. #[test] fn event_fn_fires_bytes_read_per_batch() { + let _serial = serial(); with_watchdog(Duration::from_secs(10), || { // 24 sectors = 8 aligned units; batch of 3 gives 8 batches. let extents = vec![Extent { @@ -721,6 +926,7 @@ mod tests { // full units, then surfaces a typed error on the tail. #[test] fn non_multiple_of_three_extent_errors_on_tail() { + let _serial = serial(); with_watchdog(Duration::from_secs(10), || { // 8 sectors = 2 full units (6 sectors) + 2 leftover. let extents = vec![Extent { @@ -754,6 +960,7 @@ mod tests { // verify every sector of the extent is delivered. #[test] fn short_read_does_not_desync_stream() { + let _serial = serial(); with_watchdog(Duration::from_secs(10), || { // 9 sectors = 3 full units. batch of 9 means the first // request is for 9 sectors; ShortFirstSource hands back @@ -889,6 +1096,7 @@ mod tests { /// `total_sectors` — "the sum of each extent's sector_count". #[test] fn capacity_sectors_sums_all_extents() { + let _serial = serial(); with_watchdog(Duration::from_secs(10), || { let extents = vec![ Extent { @@ -923,6 +1131,7 @@ mod tests { // summed sector_count exceeds u32. #[test] fn capacity_sectors_clamps_on_overflow() { + let _serial = serial(); with_watchdog(Duration::from_secs(10), || { let extents = vec![ Extent { @@ -959,6 +1168,7 @@ mod tests { // reorder/merge them. #[test] fn producer_walks_extents_in_order_at_correct_lbas() { + let _serial = serial(); with_watchdog(Duration::from_secs(10), || { let calls = Arc::new(Mutex::new(Vec::new())); let extents = vec![ @@ -995,6 +1205,7 @@ mod tests { // that decrypt would leave partially encrypted. batch=5 -> trimmed to 3. #[test] fn batch_trimmed_to_whole_units() { + let _serial = serial(); with_watchdog(Duration::from_secs(10), || { let calls = Arc::new(Mutex::new(Vec::new())); // 9 sectors total = three 3-sector units. @@ -1030,6 +1241,7 @@ mod tests { // aligned batch); the trailing-tail guard only fires on sub-unit leftovers. #[test] fn unit_aligned_extent_delivers_all_and_eofs() { + let _serial = serial(); with_watchdog(Duration::from_secs(10), || { // 12 sectors = exactly four 3-sector units. let extents = vec![Extent { @@ -1052,6 +1264,7 @@ mod tests { // with its ErrorKind surviving the channel round-trip (typed, not blanket-wrapped). #[test] fn reader_error_propagates_with_kind() { + let _serial = serial(); with_watchdog(Duration::from_secs(10), || { let extents = vec![Extent { start_lba: 0, @@ -1091,6 +1304,7 @@ mod tests { #[test] fn inner_source_quitting_early_is_not_reported_as_end_of_stream() { + let _serial = serial(); with_watchdog(Duration::from_secs(10), || { let extents = vec![Extent { start_lba: 0, @@ -1128,6 +1342,7 @@ mod tests { // a sector and hand decrypt a partial unit. #[test] fn non_sector_multiple_read_rejected() { + let _serial = serial(); with_watchdog(Duration::from_secs(10), || { let extents = vec![Extent { start_lba: 0, @@ -1152,6 +1367,7 @@ mod tests { // `buf.len()` (would desync the stream). #[test] fn direct_read_too_small_buffer_errors() { + let _serial = serial(); with_watchdog(Duration::from_secs(10), || { let extents = vec![Extent { start_lba: 0, @@ -1175,6 +1391,7 @@ mod tests { // by the 4th call. #[test] fn too_small_buffer_repeated_does_not_deadlock_pool() { + let _serial = serial(); with_watchdog(Duration::from_secs(10), || { // Extent with enough sectors that the producer never reaches // EOF during the test — we need it to keep producing batches. @@ -1207,6 +1424,7 @@ mod tests { // in the offset bookkeeping. #[test] fn delivered_bytes_match_source_exactly() { + let _serial = serial(); with_watchdog(Duration::from_secs(10), || { let start = 40u32; let count = 9u32; // three units @@ -1237,6 +1455,7 @@ mod tests { // so `tx` drops and the consumer sees RecvError -> Ok(0). #[test] fn empty_extents_eof_immediately() { + let _serial = serial(); with_watchdog(Duration::from_secs(10), || { let pf = PrefetchedSectorSource::new(EndlessZeroSource, Vec::new(), 3, None).expect("spawn"); @@ -1253,6 +1472,7 @@ mod tests { // batch and without stalling. #[test] fn zero_length_extent_is_skipped() { + let _serial = serial(); with_watchdog(Duration::from_secs(10), || { let calls = Arc::new(Mutex::new(Vec::new())); let extents = vec![ @@ -1293,6 +1513,7 @@ mod tests { // trim-within-batch path, distinct control flow from the 8-sector case. #[test] fn four_sector_extent_errors_on_one_sector_tail() { + let _serial = serial(); with_watchdog(Duration::from_secs(10), || { let extents = vec![Extent { start_lba: 0, @@ -1316,6 +1537,7 @@ mod tests { // pool-depth regression that also crosses extent boundaries. #[test] fn many_extents_drain_without_deadlock() { + let _serial = serial(); with_watchdog(Duration::from_secs(15), || { // 10 extents of 3 sectors each = 30 sectors total, well past // the 3-buffer pool, and 10 extent transitions. @@ -1378,6 +1600,7 @@ mod tests { // wedged-USB-bridge arm). #[test] fn bad_sector_across_channel_is_not_a_transport_failure() { + let _serial = serial(); with_watchdog(Duration::from_secs(10), || { let sense = crate::scsi::ScsiSense { sense_key: 0x03, @@ -1408,6 +1631,7 @@ mod tests { // sweep keeps aborting instead of zero-filling against a dead bus. #[test] fn transport_failure_across_channel_still_classifies() { + let _serial = serial(); with_watchdog(Duration::from_secs(10), || { let err = read_one_err(crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE, None); assert!( diff --git a/src/spec/mod.rs b/src/spec/mod.rs index d572a307..99306c58 100644 --- a/src/spec/mod.rs +++ b/src/spec/mod.rs @@ -2,7 +2,7 @@ //! evidence) that govern libfreemkv's behaviour. Text only: no logic. //! //! One submodule per design, each with its own ID prefix: [`keys`] holds `KS-n` -//! (AACS, UDF, libaacs corroboration, evidence). IDs are sequential per prefix and +//! (AACS, UDF, libaacs corroboration, evidence), [`stop`] holds `SS-n`. IDs are sequential per prefix and //! never reused or renumbered. Const names are `__`. //! //! `text` is the passage as rendered in the source (radix subscripts as `₂`/`₁₆`, @@ -15,6 +15,7 @@ //! [`QuoteKind::Informative`] rows; otherwise "corroborated by" or "per evidence". pub mod keys; +pub mod stop; /// Where a quote's text comes from; binding on how a citing site may word it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -48,7 +49,7 @@ pub struct SpecQuote { } /// Every quote group, one per submodule. New designs append their group. -pub const ALL: &[&[&SpecQuote]] = &[keys::ALL]; +pub const ALL: &[&[&SpecQuote]] = &[keys::ALL, stop::ALL]; /// Every quote in [`ALL`], group by group. pub fn quotes() -> impl Iterator { diff --git a/src/spec/stop.rs b/src/spec/stop.rs new file mode 100644 index 00000000..15d77fd0 --- /dev/null +++ b/src/spec/stop.rs @@ -0,0 +1,200 @@ +//! `SS-n`: the quotes behind the Stop design's external behaviour (SCSI, POSIX, RFC, +//! OS and language docs). IDs SS-1…SS-25 are allocated by the Stop design §5.9; +//! SS-16…SS-19, SS-24 and SS-25 are retired and never reused. Rows land with the +//! step that first cites them. + +use super::{QuoteKind, SpecQuote}; + +const SEAGATE: &str = "Seagate SCSI Commands Reference Manual, 100293068 Rev. J (October 2016)"; +const SEAGATE_URL: &str = "https://www.seagate.com/files/staticfiles/support/docs/manual/Interface%20manuals/100293068j.pdf"; +const MMC6: &str = + "T10/1836-D SCSI Multi-Media Commands - 6 (MMC-6), Revision 2g, 11 December 2009"; +const MMC6_URL: &str = "https://www.13thmonkey.org/documentation/SCSI/mmc6r02g.pdf"; +const LIBAACS_MMC: &str = "libaacs (VideoLAN) @55be92be, src/libaacs/mmc.c"; +const LIBAACS_MMC_URL: &str = + "https://code.videolan.org/videolan/libaacs/-/blob/55be92be/src/libaacs/mmc.c"; + +// SS-1, SS-2, SS-4: the Seagate manual reproduces the SPC text but is not SPC +// itself, so these are Corroboration (J-5.5-6: the reviewer re-kinds per row). + +pub const SS_1_SENSE_PROGRESS: SpecQuote = SpecQuote { + id: "SS-1", + kind: QuoteKind::Corroboration, + source: SEAGATE, + section: "SPC §2.4.1.1.4.4 Progress indication sense key specific data (Table 18, Table 21); \ + §2.4.1.2 Fixed format sense data (Table 27), as reproduced in the Seagate manual", + locator: "PDF p.51-52, p.56-57", + url: SEAGATE_URL, + text: "NO SENSE or NOT READY … Progress indication … If the sense key is NO SENSE or NOT \ + READY, the SENSE KEY SPECIFIC field shall be as shown in table 21. … The PROGRESS \ + INDICATION field is a percent complete indication in which the returned value is a \ + numerator that has 65 536 (10000h) as its denominator. … A sense-key specific valid \ + (SKSV) bit set to one indicates the SENSE KEY SPECIFIC field contains valid \ + information as defined in this manual.", +}; + +pub const SS_2_DESCRIPTOR_SENSE: SpecQuote = SpecQuote { + id: "SS-2", + kind: QuoteKind::Corroboration, + source: SEAGATE, + section: "SPC §2.4.1.1.1 Descriptor format sense data (Table 12); §2.4.1.1.4.1 Sense key \ + specific sense data descriptor (Table 17), as reproduced in the Seagate manual", + locator: "PDF p.47, p.50", + url: SEAGATE_URL, + text: "The descriptor format sense data for response codes 72h (current errors) and 73h \ + (deferred errors) is defined in table 12. … The sense key specific sense data \ + descriptor (see table 17) provides additional information about the exception \ + condition.", +}; + +pub const SS_3_READINESS_ERRORS: SpecQuote = SpecQuote { + id: "SS-3", + kind: QuoteKind::Normative, + source: MMC6, + section: "Annex F §F.3.3 Readiness Errors, Table F.3", + locator: "PDF p.695 (printed 646-647)", + url: MMC6_URL, + text: "In the event that a command requires a level of readiness that does not currently \ + exist, the Drive should be terminated with CHECK CONDITION status and sense bytes \ + SK/ASC/ASCQ should be selected from those shown in Table F.3. … 2 04 01 LOGICAL UNIT \ + IS IN PROCESS OF BECOMING READY 2 04 02 LOGICAL UNIT NOT READY, INITIALIZING CMD. \ + REQUIRED … 2 30 00 INCOMPATIBLE MEDIUM INSTALLED … 2 3A 00 MEDIUM NOT PRESENT", +}; + +pub const SS_4_TEST_UNIT_READY: SpecQuote = SpecQuote { + id: "SS-4", + kind: QuoteKind::Corroboration, + source: SEAGATE, + section: "SPC §3.53 TEST UNIT READY command, as reproduced in the Seagate manual", + locator: "PDF p.230", + url: SEAGATE_URL, + text: "The TEST UNIT READY command (see table 202) provides a means to check if the logical \ + unit is ready. … If the logical unit is unable to become operational or is in a \ + state such that an application client action (e.g., START UNIT command) is required \ + to make the logical unit ready, the command shall be terminated with CHECK CONDITION \ + status, with the sense key set to NOT READY.", +}; + +pub const SS_5_PREVENT_ALLOW: SpecQuote = SpecQuote { + id: "SS-5", + kind: QuoteKind::Normative, + source: MMC6, + section: "§6.13.2 PREVENT ALLOW MEDIUM REMOVAL, The CDB and its Parameters, Table 329", + locator: "PDF p.396 (printed 348)", + url: MMC6_URL, + text: "The Persistent and Prevent bits are used to independently select values for these \ + states. See Table 329. … 0 0 Prevent State shall be cleared (Unlocked) 0 1 Prevent \ + State shall be set (Locked)", +}; + +pub const SS_6_START_STOP_LOEJ: SpecQuote = SpecQuote { + id: "SS-6", + kind: QuoteKind::Normative, + source: MMC6, + section: "§6.42.2.6 LoEj and Start, Table 633", + locator: "PDF p.623 (printed 575)", + url: MMC6_URL, + text: "When Power Conditions field is zero and FL is zero, the meanings of LoEj and Start \ + are defined in Table 633. … 0 1 Start the disc and make ready for access 1 0 Eject \ + the disc if permitted.", +}; + +pub const SS_7_AGID_INVALIDATE: SpecQuote = SpecQuote { + id: "SS-7", + kind: QuoteKind::Evidence, + source: LIBAACS_MMC, + section: "_mmc_report_key, _mmc_invalidate_agid, _mmc_report_agid (no public MMC key-format \ + table)", + locator: "mmc.c:114-135, 202-230 @55be92be", + url: LIBAACS_MMC_URL, + text: "cmd[10] = (agid << 6) | (format & 0x3f); … return _mmc_report_key(mmc, agid, 0, 0, \ + MMC_REPORT_KEY_AACS_INVALIDATE_AGID, buf, sizeof(buf)); … *agid = (buf[7] & 0xff) \ + >> 6;", +}; + +const RUST_STD: &str = "The Rust Standard Library, core::sync::atomic (Rust 1.98.1)"; +const ORDERING_URL: &str = "https://doc.rust-lang.org/std/sync/atomic/enum.Ordering.html"; + +const POSIX: &str = "The Open Group Base Specifications Issue 8 (IEEE Std 1003.1-2024), XSH"; +const FSYNC_URL: &str = "https://pubs.opengroup.org/onlinepubs/9799919799/functions/fsync.html"; + +pub const SS_12_FSYNC_FDATASYNC: SpecQuote = SpecQuote { + id: "SS-12", + kind: QuoteKind::Normative, + source: POSIX, + section: "fsync() DESCRIPTION; fdatasync() DESCRIPTION", + locator: "pubs.opengroup.org functions/fsync.html and functions/fdatasync.html", + url: FSYNC_URL, + text: "The fsync() function shall request that all data for the open file descriptor named \ + by fildes is to be transferred to the storage device associated with the file \ + described by fildes. … The fsync() function shall not return until the system has \ + completed that action or until an error is detected. … The fdatasync() function \ + shall force all currently queued I/O operations associated with the file indicated \ + by file descriptor fildes to the synchronized I/O completion state.", +}; + +pub const SS_13_SYNC_FILE_RANGE: SpecQuote = SpecQuote { + id: "SS-13", + kind: QuoteKind::Normative, + source: "Linux man-pages, sync_file_range(2)", + section: "DESCRIPTION, Warning and Some details", + locator: "man7.org sync_file_range(2)", + url: "https://man7.org/linux/man-pages/man2/sync_file_range.2.html", + text: "None of these operations writes out the file's metadata. … This system call does \ + not flush disk write caches and thus does not provide any data integrity on systems \ + with volatile disk write caches. … SYNC_FILE_RANGE_WAIT_BEFORE | \ + SYNC_FILE_RANGE_WRITE | SYNC_FILE_RANGE_WAIT_AFTER This is a write-for-data-integrity \ + operation that will ensure that all pages in the specified range which were dirty \ + when sync_file_range() was called are committed to disk.", +}; + +pub const SS_14_F_FULLFSYNC: SpecQuote = SpecQuote { + id: "SS-14", + kind: QuoteKind::Normative, + source: "Apple xnu, fcntl(2) manual page (bsd/man/man2/fcntl.2)", + section: "DESCRIPTION, F_FULLFSYNC", + locator: "xnu bsd/man/man2/fcntl.2 (apple-oss-distributions, main); man 2 fcntl on macOS", + url: "https://github.com/apple-oss-distributions/xnu/blob/main/bsd/man/man2/fcntl.2", + text: "Does the same thing as fsync(2) then asks the drive to flush all buffered data to \ + the permanent storage device (arg is ignored). … This is currently implemented on \ + HFS, MS-DOS (FAT), Universal Disk Format (UDF) and APFS file systems.", +}; + +pub const SS_15_FLUSH_FILE_BUFFERS: SpecQuote = SpecQuote { + id: "SS-15", + kind: QuoteKind::Normative, + source: "Microsoft Learn, Win32 API: FlushFileBuffers function (fileapi.h)", + section: "Remarks", + locator: "learn.microsoft.com nf-fileapi-flushfilebuffers", + url: "https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-flushfilebuffers", + text: "The FlushFileBuffers function writes all the buffered information for a specified \ + file to the device or pipe.", +}; + +pub const SS_23_RELEASE_ACQUIRE: SpecQuote = SpecQuote { + id: "SS-23", + kind: QuoteKind::Normative, + source: RUST_STD, + section: "enum Ordering, variants Release and Acquire", + locator: "doc.rust-lang.org std::sync::atomic::Ordering; library/core/src/sync/atomic.rs", + url: ORDERING_URL, + text: "In particular, all previous writes become visible to all threads that perform an \ + Acquire (or stronger) load of this value. … In particular, all subsequent loads will \ + see data written before the store.", +}; + +/// Every `SS-n` quote, in ID order. +pub const ALL: &[&SpecQuote] = &[ + &SS_1_SENSE_PROGRESS, + &SS_2_DESCRIPTOR_SENSE, + &SS_3_READINESS_ERRORS, + &SS_4_TEST_UNIT_READY, + &SS_5_PREVENT_ALLOW, + &SS_6_START_STOP_LOEJ, + &SS_7_AGID_INVALIDATE, + &SS_12_FSYNC_FDATASYNC, + &SS_13_SYNC_FILE_RANGE, + &SS_14_F_FULLFSYNC, + &SS_15_FLUSH_FILE_BUFFERS, + &SS_23_RELEASE_ACQUIRE, +]; diff --git a/src/test_util.rs b/src/test_util.rs index 0921b2d7..0421f425 100644 --- a/src/test_util.rs +++ b/src/test_util.rs @@ -8,6 +8,7 @@ //! per aligned unit on each file's own grid (KS-1, KS-2, KS-3). //! - [`CountingSource`]: a [`SectorSource`] wrapper that logs every read. //! - [`decrypt_unit`]: the aligned-unit decrypt, for tests that check ciphertext. +//! - [`FakeTransport`]: a scripted drive with a state model for Stop tests (stop §5.0). use crate::aacs::content::{ALIGNED_UNIT_LEN, encrypt_unit}; use crate::aacs::mkb::AacsVersion; @@ -373,6 +374,10 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) { crate::aacs::content::decrypt_unit(unit, unit_key) } +#[path = "test_util_fake.rs"] +mod fake; +pub use fake::{FakeCdb, FakeHandle, FakeMode, FakeTransport}; + #[cfg(test)] #[path = "test_util_tests.rs"] mod tests; diff --git a/src/test_util_fake.rs b/src/test_util_fake.rs new file mode 100644 index 00000000..c0817368 --- /dev/null +++ b/src/test_util_fake.rs @@ -0,0 +1,422 @@ +//! [`FakeTransport`]: a scripted [`ScsiTransport`] with a drive state model, for Stop +//! tests (stop design §5.0). Durations are scaled: a CDB's `timeout_ms` is divided by +//! [`FakeTransport::scale`] (default 1000, so a 60 s READ times out at 60 ms). +//! +//! It keeps the tray and AGID ledger a real drive would, logs every CDB, counts live +//! handles, and **panics on any CDB that reaches it after the watched token is +//! cancelled** unless the §2.4 allow-list (shared with `Drive::exec_cleanup`) or a +//! test's [`FakeTransport::allow_after_cancel`] admits it. + +use crate::drive::allow::{self, CleanupCtx, Ledger}; +use crate::error::{Error, Result}; +use crate::halt::Halt; +use crate::scsi::{DataDirection, ScsiResult, ScsiSense, ScsiTransport}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard}; +use std::time::{Duration, Instant}; + +type Pred = Box bool + Send>; + +/// How the fake answers a CDB a rule matches. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FakeMode { + /// Answer after `d` (wall time, not scaled) as the default serving would. Past the + /// CDB's scaled timeout it times out instead, like a real transport. + Complete(Duration), + /// Block until [`FakeHandle::release`], or time out at the CDB's scaled timeout. + Stall, + /// A transport fault: status 0xFF, no sense (a dead bus). + Fault, + /// CHECK CONDITION with this sense; `progress` is the sense-key specific progress + /// indication reported through [`ScsiTransport::last_sense_progress`]. + Sense { + sense: ScsiSense, + progress: Option, + }, +} + +/// One CDB as the fake received it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FakeCdb { + pub cdb: Vec, + pub timeout_ms: u32, + /// Whether the watched token was already cancelled when it arrived. + pub after_cancel: bool, +} + +struct Rule { + pred: Pred, + mode: FakeMode, + times: Option, +} + +struct State { + log: Vec, + rules: Vec, + image: Option>, + profile: u16, + scale: u32, + watch: Option, + allow: Vec, + cancel_on: Vec<(Pred, Halt)>, + ledger: Ledger, + live: usize, + released: u64, +} + +struct Shared { + state: Mutex, + wake: Condvar, +} + +impl Shared { + fn lock(&self) -> MutexGuard<'_, State> { + self.state.lock().unwrap_or_else(|p| p.into_inner()) + } +} + +/// A scripted transport; build it, keep its [`FakeHandle`], and hand it to +/// `Drive::from_transport` / `Drive::from_transport_with`. +pub struct FakeTransport { + shared: Arc, + inner: Option>, + last_progress: Option, +} + +/// The test's view of a [`FakeTransport`]: its CDB log, ledger and live count, and +/// the release for stalled CDBs. Cheap to clone. +#[derive(Clone)] +pub struct FakeHandle(Arc); + +impl FakeTransport { + /// A fake that answers every CDB GOOD with a zeroed buffer. + pub fn new() -> (Self, FakeHandle) { + let shared = Arc::new(Shared { + state: Mutex::new(State { + log: Vec::new(), + rules: Vec::new(), + image: None, + profile: 0x0040, + scale: 1000, + watch: None, + allow: Vec::new(), + cancel_on: Vec::new(), + ledger: Ledger::default(), + live: 1, + released: 0, + }), + wake: Condvar::new(), + }); + let t = FakeTransport { + shared: shared.clone(), + inner: None, + last_progress: None, + }; + (t, FakeHandle(shared)) + } + + /// Serve READ(10) and READ CAPACITY from `image` (2048-byte sectors). + pub fn with_image(self, image: Vec) -> Self { + self.shared.lock().image = Some(image); + self + } + + /// Delegate every CDB no rule matches to `inner` (e.g. the AKE handshake fixture). + pub fn with_inner(mut self, inner: Box) -> Self { + self.inner = Some(inner); + self + } + + /// The GET CONFIGURATION current profile (default 0x0040, BD-ROM). + pub fn profile(self, profile: u16) -> Self { + self.shared.lock().profile = profile; + self + } + + /// Divide every CDB timeout by `divisor` (default 1000). + pub fn scale(self, divisor: u32) -> Self { + self.shared.lock().scale = divisor.max(1); + self + } + + /// Answer CDBs matching `pred` with `mode`; the first matching rule wins. + pub fn rule(self, pred: impl Fn(&[u8]) -> bool + Send + 'static, mode: FakeMode) -> Self { + self.push_rule(Box::new(pred), mode, None) + } + + /// As [`rule`](Self::rule), for the next `times` matching CDBs only. + pub fn rule_n( + self, + pred: impl Fn(&[u8]) -> bool + Send + 'static, + mode: FakeMode, + times: usize, + ) -> Self { + self.push_rule(Box::new(pred), mode, Some(times)) + } + + fn push_rule(self, pred: Pred, mode: FakeMode, times: Option) -> Self { + self.shared.lock().rules.push(Rule { pred, mode, times }); + self + } + + /// Panic on any CDB arriving after `halt` is cancelled that §2.4 does not admit. + pub fn watch(self, halt: &Halt) -> Self { + self.shared.lock().watch = Some(halt.clone()); + self + } + + /// Also admit post-cancel CDBs matching `pred` (a critical section, `finish(Eject)`: + /// contexts the fake cannot see from below the Drive). + pub fn allow_after_cancel(self, pred: impl Fn(&[u8]) -> bool + Send + 'static) -> Self { + self.shared.lock().allow.push(Box::new(pred)); + self + } + + /// Cancel `halt` as soon as a CDB matching `pred` has been answered. + pub fn cancel_on(self, pred: impl Fn(&[u8]) -> bool + Send + 'static, halt: &Halt) -> Self { + self.shared + .lock() + .cancel_on + .push((Box::new(pred), halt.clone())); + self + } +} + +impl FakeHandle { + /// Every CDB received so far, in order. + pub fn log(&self) -> Vec { + self.0.lock().log.clone() + } + + /// The CDB bytes received so far. + pub fn cdbs(&self) -> Vec> { + self.0.lock().log.iter().map(|c| c.cdb.clone()).collect() + } + + /// How many received CDBs match `pred`. + pub fn count(&self, pred: impl Fn(&[u8]) -> bool) -> usize { + self.0.lock().log.iter().filter(|c| pred(&c.cdb)).count() + } + + /// Let every CDB stalled so far complete. + pub fn release(&self) { + self.0.lock().released += 1; + self.0.wake.notify_all(); + } + + /// Live `FakeTransport`s built from this handle (0 once the Drive dropped it). + pub fn live_handles(&self) -> usize { + self.0.lock().live + } + + /// Threads holding a Drive (`spawn_drive_holder`) still running, process-wide. + pub fn drive_holders(&self) -> usize { + crate::halt::live_drive_holders() + } + + /// The tray lock as the fake drive sees it. + pub fn tray_locked(&self) -> bool { + self.0.lock().ledger.tray_locked + } + + /// AGIDs the fake drive holds (bit `n` = AGID `n`). + pub fn agids(&self) -> u8 { + self.0.lock().ledger.agids + } + + /// Wait (up to `limit`) until at least `n` CDBs matching `pred` arrived. + pub fn wait_for(&self, n: usize, pred: impl Fn(&[u8]) -> bool, limit: Duration) -> bool { + let end = Instant::now() + limit; + while Instant::now() < end { + if self.count(&pred) >= n { + return true; + } + std::thread::sleep(Duration::from_millis(1)); + } + false + } +} + +impl Drop for FakeTransport { + fn drop(&mut self) { + self.shared.lock().live -= 1; + } +} + +fn check_condition(opcode: u8, sense: ScsiSense) -> Error { + Error::ScsiError { + opcode, + status: 2, + sense: Some(sense), + } +} + +fn transport_failure(opcode: u8) -> Error { + Error::ScsiError { + opcode, + status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE, + sense: None, + } +} + +impl FakeTransport { + // The default drive: an image for READ(10) / READ CAPACITY, a profile, GOOD + // with zeros for everything else. + fn serve_default(&self, cdb: &[u8], data: &mut [u8]) -> Result { + data.fill(0); + let st = self.shared.lock(); + match (cdb[0], &st.image) { + (crate::scsi::SCSI_READ_10, Some(img)) if cdb.len() >= 9 => { + let lba = u32::from_be_bytes([cdb[2], cdb[3], cdb[4], cdb[5]]) as usize; + let n = u16::from_be_bytes([cdb[7], cdb[8]]) as usize; + let (start, len) = (lba * 2048, n * 2048); + if start + len > img.len() || data.len() < len { + let lba_out_of_range = ScsiSense { + sense_key: crate::scsi::SENSE_KEY_ILLEGAL_REQUEST, + asc: 0x21, + ascq: 0x00, + }; + return Err(check_condition(cdb[0], lba_out_of_range)); + } + data[..len].copy_from_slice(&img[start..start + len]); + } + (crate::scsi::SCSI_READ_CAPACITY, Some(img)) if data.len() >= 8 => { + let last = (img.len() / 2048).saturating_sub(1) as u32; + data[..4].copy_from_slice(&last.to_be_bytes()); + data[4..8].copy_from_slice(&2048u32.to_be_bytes()); + } + (crate::scsi::SCSI_GET_CONFIGURATION, _) if data.len() >= 8 => { + data[6..8].copy_from_slice(&st.profile.to_be_bytes()); + } + _ => {} + } + Ok(ScsiResult { + status: 0, + bytes_transferred: data.len(), + sense: [0u8; 32], + }) + } + + fn serve( + &mut self, + cdb: &[u8], + dir: DataDirection, + data: &mut [u8], + t: u32, + ) -> Result { + match self.inner.as_mut() { + Some(inner) => inner.execute(cdb, dir, data, t), + None => self.serve_default(cdb, data), + } + } + + // Block until released (a new `release` generation) or `limit` passes. + fn stall(&self, limit: Duration) -> bool { + let end = Instant::now() + limit; + let mut st = self.shared.lock(); + let gen0 = st.released; + while st.released == gen0 { + let left = end.saturating_duration_since(Instant::now()); + if left.is_zero() { + return false; + } + st = self + .shared + .wake + .wait_timeout(st, left) + .unwrap_or_else(|p| p.into_inner()) + .0; + } + true + } +} + +impl ScsiTransport for FakeTransport { + fn execute( + &mut self, + cdb: &[u8], + dir: DataDirection, + data: &mut [u8], + timeout_ms: u32, + ) -> Result { + self.last_progress = None; + let (mode, limit) = { + let mut st = self.shared.lock(); + let after_cancel = st.watch.as_ref().is_some_and(Halt::is_cancelled); + st.log.push(FakeCdb { + cdb: cdb.to_vec(), + timeout_ms, + after_cancel, + }); + if after_cancel + && !allow::allowed_after_cancel(cdb, st.ledger, CleanupCtx::Plain) + && !st.allow.iter().any(|p| p(cdb)) + { + drop(st); + // Already unwinding (a Drive dropped by a failing test): refuse, don't abort. + if std::thread::panicking() { + return Err(transport_failure(cdb[0])); + } + panic!("FakeTransport: CDB {cdb:02x?} after a cancel, outside the §2.4 allow-list"); + } + let limit = Duration::from_micros(u64::from(timeout_ms) * 1000 / u64::from(st.scale)); + let mut mode = None; + for r in st.rules.iter_mut() { + if r.times != Some(0) && (r.pred)(cdb) { + if let Some(n) = r.times.as_mut() { + *n -= 1; + } + mode = Some(r.mode); + break; + } + } + (mode, limit) + }; + let r = match mode { + None => self.serve(cdb, dir, data, timeout_ms), + Some(FakeMode::Complete(d)) if d > limit => { + std::thread::sleep(limit); + Err(transport_failure(cdb[0])) + } + Some(FakeMode::Complete(d)) => { + std::thread::sleep(d); + self.serve(cdb, dir, data, timeout_ms) + } + Some(FakeMode::Stall) => match self.stall(limit) { + true => self.serve(cdb, dir, data, timeout_ms), + false => Err(transport_failure(cdb[0])), + }, + Some(FakeMode::Fault) => Err(transport_failure(cdb[0])), + Some(FakeMode::Sense { sense, progress }) => { + self.last_progress = progress; + Err(check_condition(cdb[0], sense)) + } + }; + let mut st = self.shared.lock(); + note_ledger(&mut st.ledger, cdb, data, r.is_ok()); + for (pred, halt) in &st.cancel_on { + if pred(cdb) { + halt.cancel(); + } + } + r + } + + fn last_sense_progress(&self) -> Option { + self.last_progress + } +} + +// The fake drive's own tray and AGID state, from the CDBs it answered. +fn note_ledger(l: &mut Ledger, cdb: &[u8], resp: &[u8], ok: bool) { + match cdb.first() { + Some(&allow::PREVENT_ALLOW) if ok => l.tray_locked = cdb.get(4).is_some_and(|b| b & 1 == 1), + Some(&allow::REPORT_KEY) if cdb.len() >= 11 => { + if let Some(agid) = allow::invalidated_agid(cdb) { + l.agids &= !(1 << agid); + } else if cdb[10] & 0x3F == 0 && ok && resp.len() > 7 { + l.agids |= 1 << (resp[7] >> 6); + } + } + _ => {} + } +} diff --git a/src/udf.rs b/src/udf.rs index cb60b306..fd3f83ed 100644 --- a/src/udf.rs +++ b/src/udf.rs @@ -1659,7 +1659,9 @@ impl<'a, S: SectorSource + ?Sized> BufferedSectorReader<'a, S> { impl BufferedSectorReader<'_, S> { /// Pre-read a contiguous range of sectors into the sliding cache. /// Used to bulk-load the UDF metadata partition so subsequent reads are instant. - pub(crate) fn prefetch(&mut self, start_lba: u32, count: u32) { + /// A failed batch ends the prefetch (the reads fall back to the sliding window); + /// a Stop is returned, so the scan never reads on past it (§2.8). + pub(crate) fn prefetch(&mut self, start_lba: u32, count: u32) -> crate::error::Result<()> { // Cap to 8192 sectors (16 MiB) so a disc-controlled ad_len cannot // drive a multi-hundred-MiB allocation before any sectors are read. let count = count.min(8192); @@ -1670,31 +1672,37 @@ impl BufferedSectorReader<'_, S> { let total = count as usize * 2048; self.cache.resize(total, 0); let mut offset = 0u32; + let mut halted = false; while offset < count { let batch = (count - offset).min(self.batch as u32) as u16; let buf_off = offset as usize * 2048; - if self - .inner - .read_sectors( - start_lba + offset, - batch, - &mut self.cache[buf_off..buf_off + batch as usize * 2048], - true, - ) - .is_err() - { - break; + match self.inner.read_sectors( + start_lba + offset, + batch, + &mut self.cache[buf_off..buf_off + batch as usize * 2048], + true, + ) { + Ok(_) => offset += batch as u32, + Err(e) => { + halted = matches!(e, crate::error::Error::Halted); + break; + } } - offset += batch as u32; } self.cache_start = start_lba; self.cache_sectors = offset; + if halted { + return Err(crate::error::Error::Halted); + } + Ok(()) } // Pre-read multiple sector ranges into the permanent per-sector HashMap cache (bulk-loads // AACS/MPLS/CLPI/META before scanning), capped at MAX_PREFETCH_SECTORS to bound RAM against // a crafted UDF. - pub(crate) fn prefetch_ranges(&mut self, ranges: &[(u32, u32)]) { + /// A Stop ends it with `Halted` between (or within) ranges; any other failed + /// batch skips the rest of that range, as before. + pub(crate) fn prefetch_ranges(&mut self, ranges: &[(u32, u32)]) -> crate::error::Result<()> { // 2048 bytes/sector → 512 Ki sectors ≈ 1 GiB of permanent cache. const MAX_PREFETCH_SECTORS: u64 = 512 * 1024; let mut tmp = vec![0u8; self.batch as usize * 2048]; @@ -1712,19 +1720,20 @@ impl BufferedSectorReader<'_, S> { hb.tick(done, total); let batch = (count - offset).min(self.batch as u32) as u16; let bytes = batch as usize * 2048; - if self + match self .inner .read_sectors(start + offset, batch, &mut tmp[..bytes], true) - .is_err() { - break; + Ok(_) => {} + Err(crate::error::Error::Halted) => return Err(crate::error::Error::Halted), + Err(_) => break, } for i in 0..batch as u32 { if cached >= MAX_PREFETCH_SECTORS { // Cache cap hit: stop seeding the permanent HashMap. // Remaining LBAs are still served by the sliding-window // read path below, just without the bulk pre-load. - return; + return Ok(()); } let s = i as usize * 2048; self.prefetched @@ -1735,6 +1744,7 @@ impl BufferedSectorReader<'_, S> { done += batch as u64; } } + Ok(()) } } @@ -3503,7 +3513,7 @@ mod tests { let mut inner = CountReader::new(); { let mut br = BufferedSectorReader::new(&mut inner, 3); - br.prefetch(100, 8); // 8 sectors in batches of 3 → 3 commands + br.prefetch(100, 8).unwrap(); // 8 sectors in batches of 3 → 3 commands let mut buf = [0u8; 2048]; for lba in [100u32, 103, 107] { br.read_sectors(lba, 1, &mut buf, true) @@ -3529,7 +3539,7 @@ mod tests { // would hand back bytes from beyond the loaded data. let mut inner = CountReader::new(); let mut br = BufferedSectorReader::new(&mut inner, 8); - br.prefetch(100, 8); + br.prefetch(100, 8).unwrap(); let mut buf = [0u8; 2048]; br.read_sectors(108, 1, &mut buf, true) .expect("the sector past the window is read from the drive"); @@ -3544,7 +3554,7 @@ mod tests { let mut inner = CountReader::new(); { let mut br = BufferedSectorReader::new(&mut inner, 2); - br.prefetch_ranges(&[(200, 5), (300, 2)]); + br.prefetch_ranges(&[(200, 5), (300, 2)]).unwrap(); let mut buf = [0u8; 2048]; for lba in [200u32, 202, 204, 300, 301] { br.read_sectors(lba, 1, &mut buf, true) @@ -3645,7 +3655,7 @@ mod tests { let mut inner = MapReader::new(); let mut br = BufferedSectorReader::new(&mut inner, 32); // Pass a count that would allocate ~512 MiB if uncapped (262144 sectors). - br.prefetch(0, 262_144); + br.prefetch(0, 262_144).unwrap(); // The cache must be no larger than the cap: 8192 sectors × 2048 bytes. assert!( br.cache.len() <= 8192 * 2048, @@ -3662,7 +3672,7 @@ mod tests { let mut br = BufferedSectorReader::new(&mut inner, 60); // start + count = 0xFFFF_FFC0 + 200 > u32::MAX: the second batch // iteration evaluates 0xFFFF_FFC0 + 60. - br.prefetch(0xFFFF_FFC0, 200); + br.prefetch(0xFFFF_FFC0, 200).unwrap(); // Nothing read (MapReader serves no sector here), but crucially the // walk must never form an LBA above u32::MAX. assert!( @@ -3679,7 +3689,7 @@ mod tests { fn prefetch_ranges_near_u32_max_does_not_overflow() { let mut inner = MapReader::new(); let mut br = BufferedSectorReader::new(&mut inner, 60); - br.prefetch_ranges(&[(0xFFFF_FFF0, 512)]); + br.prefetch_ranges(&[(0xFFFF_FFF0, 512)]).unwrap(); // Every key the permanent cache holds must be a real LBA, i.e. inside // the declared range — never a wrapped low sector. for &lba in br.prefetched.keys() { diff --git a/src/unlock_bridge.rs b/src/unlock_bridge.rs index 8ab57abc..bb8572ed 100644 --- a/src/unlock_bridge.rs +++ b/src/unlock_bridge.rs @@ -23,8 +23,75 @@ pub(crate) fn unlocker_name(drive_id: &crate::identity::DriveId) -> Option<&'sta fu::unlocker_name(&to_fu_drive_id(drive_id)) } -/// Adapt libfreemkv's `ScsiTransport` to the unlock crate's transport contract. -struct ScsiAdapter<'a>(&'a mut dyn crate::scsi::ScsiTransport); +/// The unlock crate's transport over a [`Drive`](crate::drive::Drive) (stop design +/// §2.3): every CDB goes through the Drive's token, so after a Stop the unlocker sees +/// a dead bus (status 0xFF, no sense) and aborts through its transport-fault path. +pub(crate) struct ScsiAdapter<'a> { + drive: &'a mut crate::drive::Drive, + /// Open critical spans: while > 0 CDBs run even after a cancel (§2.4 row 4). + critical: u32, +} + +impl<'a> ScsiAdapter<'a> { + pub(crate) fn new(drive: &'a mut crate::drive::Drive) -> Self { + ScsiAdapter { drive, critical: 0 } + } +} + +// A Stop refusal: the dead-bus shape every unlocker already aborts on. +fn refused() -> fu::scsi::ScsiError { + fu::scsi::ScsiError { + status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE, + sense: None, + } +} + +fn to_lib_dir(dir: fu::scsi::DataDirection) -> crate::scsi::DataDirection { + match dir { + fu::scsi::DataDirection::None => crate::scsi::DataDirection::None, + fu::scsi::DataDirection::FromDevice => crate::scsi::DataDirection::FromDevice, + fu::scsi::DataDirection::ToDevice => crate::scsi::DataDirection::ToDevice, + } +} + +// libfreemkv's result → the unlock crate's. `Halted` is a refusal (0xFF). +fn to_fu_result( + r: crate::error::Result, +) -> fu::scsi::Result { + match r { + Ok(r) => Ok(fu::scsi::ScsiResult { + status: r.status, + bytes_transferred: r.bytes_transferred, + sense: r.sense, + }), + Err(crate::error::Error::Halted) => Err(refused()), + // Transport Err covers both real faults and a normal CHECK CONDITION + // status; preserve status+sense so AACS's wedge guard and diagnosis can + // tell a cert rejection from a dead bus (sense_key@2, asc@12, ascq@13). + Err(e) => { + // ScsiError/DiscRead carry real status+sense via extract_scsi_context; + // any other variant is a non-SCSI transport/IO fault (dead bus, not a + // drive rejection) — surface transport-failure status so unlock bails. + let (status, sense) = match &e { + crate::error::Error::ScsiError { .. } | crate::error::Error::DiscRead { .. } => { + crate::drive::extract_scsi_context(&e) + } + _ => (crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE, None), + }; + let sense_buf = sense.map(|s| { + let mut b = [0u8; 32]; + b[2] = s.sense_key & 0x0F; + b[12] = s.asc; + b[13] = s.ascq; + b + }); + Err(fu::scsi::ScsiError { + status, + sense: sense_buf, + }) + } + } +} impl fu::scsi::ScsiTransport for ScsiAdapter<'_> { fn execute( @@ -34,44 +101,46 @@ impl fu::scsi::ScsiTransport for ScsiAdapter<'_> { data: &mut [u8], timeout_ms: u32, ) -> fu::scsi::Result { - let d = match dir { - fu::scsi::DataDirection::None => crate::scsi::DataDirection::None, - fu::scsi::DataDirection::FromDevice => crate::scsi::DataDirection::FromDevice, - fu::scsi::DataDirection::ToDevice => crate::scsi::DataDirection::ToDevice, - }; - match self.0.execute(cdb, d, data, timeout_ms) { - Ok(r) => Ok(fu::scsi::ScsiResult { - status: r.status, - bytes_transferred: r.bytes_transferred, - sense: r.sense, - }), - // Transport Err covers both real faults and a normal CHECK CONDITION - // status; preserve status+sense so AACS's wedge guard and diagnosis can - // tell a cert rejection from a dead bus (sense_key@2, asc@12, ascq@13). - Err(e) => { - // ScsiError/DiscRead carry real status+sense via extract_scsi_context; - // any other variant is a non-SCSI transport/IO fault (dead bus, not a - // drive rejection) — surface transport-failure status so unlock bails. - let (status, sense) = match &e { - crate::error::Error::ScsiError { .. } - | crate::error::Error::DiscRead { .. } => { - crate::drive::extract_scsi_context(&e) - } - _ => (crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE, None), - }; - let sense_buf = sense.map(|s| { - let mut b = [0u8; 32]; - b[2] = s.sense_key & 0x0F; - b[12] = s.asc; - b[13] = s.ascq; - b - }); - Err(fu::scsi::ScsiError { - status, - sense: sense_buf, - }) - } + let d = to_lib_dir(dir); + to_fu_result(if self.critical > 0 { + self.drive.exec_uncancellable(cdb, d, data, timeout_ms) + } else { + self.drive.exec(cdb, d, data, timeout_ms) + }) + } + + fn pause(&mut self, d: std::time::Duration) -> fu::scsi::Result<()> { + self.drive.pause(d).map_err(|_| refused()) + } + + fn begin_critical(&mut self) -> fu::scsi::Result<()> { + if self.drive.is_halted() { + return Err(refused()); } + self.critical += 1; + Ok(()) + } + + fn end_critical(&mut self) { + self.critical = self.critical.saturating_sub(1); + } + + fn execute_cleanup( + &mut self, + cdb: &[u8], + dir: fu::scsi::DataDirection, + data: &mut [u8], + timeout_ms: u32, + ) -> fu::scsi::Result { + let ctx = if self.critical > 0 { + crate::drive::CleanupCtx::Critical + } else { + crate::drive::CleanupCtx::Plain + }; + to_fu_result( + self.drive + .exec_cleanup(cdb, to_lib_dir(dir), data, timeout_ms, ctx), + ) } } @@ -111,13 +180,13 @@ type Dispatch = ( /// unlocker is an unlocker, so this loop knows nothing about any of them. fn run( unlockers: Vec>, - scsi: &mut dyn crate::scsi::ScsiTransport, + drive: &mut crate::drive::Drive, drive_id: &crate::identity::DriveId, kind: fu::DiscKind, ) -> Dispatch { let id = to_fu_drive_id(drive_id); let ctx = fu::UnlockCtx::new(&id, kind); - let mut adapter = ScsiAdapter(scsi); + let mut adapter = ScsiAdapter::new(drive); for u in &unlockers { match u.unlock(&mut adapter, &ctx) { Ok(None) => continue, // not this one's drive @@ -133,10 +202,10 @@ fn run( /// no certs. Each removes bus encryption at the drive and reads the OEM Volume /// ID best-effort. pub(crate) fn run_features( - scsi: &mut dyn crate::scsi::ScsiTransport, + drive: &mut crate::drive::Drive, drive_id: &crate::identity::DriveId, ) -> Dispatch { - run(firmware_unlockers(), scsi, drive_id, fu::DiscKind::Unknown) + run(firmware_unlockers(), drive, drive_id, fu::DiscKind::Unknown) } /// The FIRMWARE/drive unlockers, in dispatch order. These key off the DRIVE and @@ -180,12 +249,12 @@ pub(crate) fn is_drive_unlocker(name: &str) -> bool { /// construction, the one place certs enter) and the CSS/DVD route. `kind` /// selects which self-applies; the other declines. pub(crate) fn run_bus( - scsi: &mut dyn crate::scsi::ScsiTransport, + drive: &mut crate::drive::Drive, drive_id: &crate::identity::DriveId, kind: fu::DiscKind, host_certs: &[fu::HostCert], ) -> Dispatch { - run(disc_unlockers(host_certs.to_vec()), scsi, drive_id, kind) + run(disc_unlockers(host_certs.to_vec()), drive, drive_id, kind) } // The unlocker names, in dispatch order, for the user-facing unlocker matrix. @@ -219,9 +288,9 @@ mod tests { } } - fn adapt(e: impl FnMut() -> crate::error::Error + Send) -> fu::scsi::ScsiError { - let mut t = ErrTransport(e); - let mut adapter = ScsiAdapter(&mut t); + fn adapt(e: impl FnMut() -> crate::error::Error + Send + 'static) -> fu::scsi::ScsiError { + let mut d = crate::drive::Drive::from_transport_for_test(Box::new(ErrTransport(e))); + let mut adapter = ScsiAdapter::new(&mut d); let mut buf = [0u8; 0]; adapter .execute(&[0u8; 12], fu::scsi::DataDirection::None, &mut buf, 1_000) @@ -316,4 +385,46 @@ mod tests { assert_eq!(err.status, 0xFF); assert!(err.sense.is_none()); } + + /// LD14: `begin_critical` after a cancel is refused (0xFF); a CDB inside a + /// critical section entered before the cancel still runs; `pause` is refused on a + /// cancel; the clean-up path admits the ALLOW for a tray this Drive locked. + #[test] + fn adapter_critical_and_pause() { + use crate::test_util::FakeTransport; + let tur = [0u8; 6]; + let h = crate::halt::Halt::new(); + let (t, fake) = FakeTransport::new(); + let t = t.watch(&h).allow_after_cancel(|c| c[0] == 0x00); + let mut d = crate::drive::Drive::from_transport_with(Box::new(t), &h); + d.lock_tray(); + let mut a = ScsiAdapter::new(&mut d); + a.begin_critical().expect("not cancelled yet"); + h.cancel(); + let mut buf = [0u8; 0]; + assert!( + a.execute(&tur, fu::scsi::DataDirection::None, &mut buf, 5_000) + .is_ok() + ); + a.end_critical(); + let e = a + .execute(&tur, fu::scsi::DataDirection::None, &mut buf, 5_000) + .expect_err("refused outside the span"); + assert_eq!((e.status, e.sense), (0xFF, None)); + let e = a.begin_critical().expect_err("no span after the cancel"); + assert_eq!(e.status, 0xFF); + let t0 = std::time::Instant::now(); + let e = a + .pause(std::time::Duration::from_secs(10)) + .expect_err("pause refused"); + assert_eq!(e.status, 0xFF); + assert!(t0.elapsed() < std::time::Duration::from_secs(1)); + let allow = [0x1E, 0, 0, 0, 0, 0]; + assert!( + a.execute_cleanup(&allow, fu::scsi::DataDirection::None, &mut buf, 5_000) + .is_ok() + ); + assert_eq!(fake.count(|c| c[0] == 0x00), 1, "one TUR, inside the span"); + assert_eq!(fake.count(|c| c == allow), 1, "one ALLOW"); + } } diff --git a/src/whole_disc.rs b/src/whole_disc.rs index 99725f64..45b726d1 100644 --- a/src/whole_disc.rs +++ b/src/whole_disc.rs @@ -405,18 +405,12 @@ fn probe_units(units: u64) -> Vec { /// uses at most one key. Never the pool size: one held key on a two-unit disc is /// not single-CPS. fn single_cps_key_slot(disc: &crate::Disc, keys: &DecryptKeys, map: &AacsKeyMap) -> Option { - use crate::aacs::mkb::AacsVersion; - let aacs = disc.aacs.as_ref()?; if disc.format == crate::DiscFormat::Fmts || map.ranges().iter().any(|r| r.3 != Phase::All) { return None; } - let version = if aacs.version >= 2 { - AacsVersion::V20 - } else { - AacsVersion::V10 - }; - let ukf = crate::aacs::inf::parse_unit_key_ro(&aacs.uk_ro, version)?; - if ukf.encrypted_keys.len() != 1 { + // KS-14 [BD] §3.9.3: "Num_of_CPS_Unit … indicates the number of CPS Units on the + // disc"; K-8: via `parse_title_keys`, so an HD DVD VTKF counts too (KS-27, evidence). + if disc.declared_cps_units()? != 1 { return None; } match (map.key_indices(), keys) { diff --git a/src/whole_disc_tests.rs b/src/whole_disc_tests.rs index c3d5feb6..e12d8ddd 100644 --- a/src/whole_disc_tests.rs +++ b/src/whole_disc_tests.rs @@ -146,13 +146,19 @@ fn write_through( std::fs::read(&dest).map_err(|e| Error::IoError { source: e }) } -fn assert_plain(img_plain: &[u8], bytes: &[u8]) { +// `bytes` equals the image's plaintext, where each decrypted unit (`specs`) reads with +// CPI 00₂ in every source packet (KS-5; KU design §5.4) and every other sector is verbatim. +fn assert_plain(img_plain: &[u8], bytes: &[u8], specs: &[FileSpec]) { assert_eq!(bytes.len(), img_plain.len()); - for (lba, (got, want)) in bytes - .chunks(SECTOR) - .zip(img_plain.chunks(SECTOR)) - .enumerate() - { + let mut want = img_plain.to_vec(); + for (start, n, _, enc) in specs { + for u in enc.iter().filter(|&&u| u < n / 3) { + let o = (start + u * 3) as usize * SECTOR; + let unit = crate::aacs::content::cpi_cleared(want[o..o + 6144].to_vec()); + want[o..o + 6144].copy_from_slice(&unit); + } + } + for (lba, (got, want)) in bytes.chunks(SECTOR).zip(want.chunks(SECTOR)).enumerate() { assert!(got == want, "sector {lba} not decrypted as expected"); } } @@ -168,7 +174,7 @@ fn every_unplayed_file_is_keyed_and_the_image_decrypts_across_batches() { assert!(planned.unproven.is_empty()); let want = src.plain.clone(); let bytes = write_through(img(2200, &specs), keys, &files, planned).unwrap(); - assert_plain(&want, &bytes); + assert_plain(&want, &bytes, &specs); } /// Back-to-back unplayed files in different CPS units each get their own key. @@ -181,7 +187,7 @@ fn adjacent_files_in_different_cps_units_each_get_their_own_key() { assert_eq!(planned.map.entry_for(600).map(|e| e.0), Some(1)); assert_eq!(planned.map.entry_for(630).map(|e| e.0), Some(2)); let bytes = write_through(img(1200, &specs), keys, &files, planned).unwrap(); - assert_plain(&img(1200, &specs).plain, &bytes); + assert_plain(&img(1200, &specs).plain, &bytes, &specs); } /// Ciphertext no held key opens refuses the plan: multi-CPS pool, and a single key. @@ -717,3 +723,76 @@ fn unit_aligned_forwards_unmapped_stream_files() { use crate::sector::bus_removal::test_support::{Reports, assert_forwards, m2ts1}; assert_forwards(UnitAligned::new(Reports(vec![m2ts1()]), Vec::new())); } + +/// LK21 (K-13), per spec — KS-5 [BD] §3.10.2: CPI "shall be set to 00₂ if the data is not +/// encrypted"; corroborated by KS-22 (libaacs clears it per source packet). A decrypted +/// image says so in every packet, so re-scanning it finds only clear pieces and asks nothing. +#[test] +fn decrypt_clears_cpi_on_every_source_packet() { + use crate::spec::keys::{KS_5_CPI, KS_22_LIBAACS_VERIFY_TS}; + assert!(KS_5_CPI.text.contains("00₂ if the data is not encrypted")); + assert!(KS_22_LIBAACS_VERIFY_TS.text.contains("buf[i] &= ~0xc0;")); + let specs = [(300, 30, K0, ALL), (600, 30, K1, ALL)]; + let files = one_file_each(&specs); + let mut keys = pool(&[K0, K1]); + let planned = plan(&mut img(1200, &specs), &mut keys, &files, &rule(None)).unwrap(); + let bytes = write_through(img(1200, &specs), keys, &files, planned).unwrap(); + for &(start, n, _, _) in &specs { + for lba in (start..start + n).step_by(3) { + let o = lba as usize * SECTOR; + for i in (0..6144).step_by(192) { + assert_eq!(bytes[o + i] & 0xC0, 0, "LBA {lba}, packet byte {i}: CPI"); + } + } + } + // Re-scan the decrypted image: every piece reads Clear, and no source is asked. + let asked = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen = asked.clone(); + let fetch = KeyFetch::unit_only(std::sync::Arc::new(move |_| { + seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(Vec::new()) + })); + let rescan_rule = KeyRule { + format: crate::ContentFormat::BdTs, + single: None, + fetch: Some(&fetch), + halt: None, + }; + let mut decrypted = Img { + plain: bytes.clone(), + data: bytes, + probe_fail: None, + reads: Vec::new(), + }; + let replanned = plan(&mut decrypted, &mut pool(&[K0, K1]), &files, &rescan_rule); + assert!(replanned.is_ok(), "{:?}", replanned.err()); + assert_eq!( + asked.load(std::sync::atomic::Ordering::SeqCst), + 0, + "requests" + ); +} + +/// K-8: the declared count is read through `parse_title_keys`, so an HD DVD title-key +/// file counts too. Per spec KS-14 ("Num_of_CPS_Unit … the number of CPS Units on the +/// disc") for BD; HD DVD has no public book, per evidence KS-27. +#[test] +fn single_cps_reads_hd_dvd_title_keys_too() { + use crate::spec::keys::{KS_14_UNIT_KEY_BLOCK, KS_27_HDDVD_EVIDENCE}; + assert!( + KS_14_UNIT_KEY_BLOCK + .text + .contains("indicates the number of CPS Units") + ); + assert_eq!(KS_27_HDDVD_EVIDENCE.kind, crate::spec::QuoteKind::Evidence); + // A VTKF with one available Title Key Entry (AV_FLG set in slot 0). + let mut vtkf = vec![0u8; 2480]; + vtkf[..12].copy_from_slice(crate::aacs::inf::VTKF_MAGIC); + vtkf[0x80] = 0x80; + let mut disc = aacs_disc(1); + disc.format = crate::DiscFormat::HdDvd; + disc.content_format = crate::ContentFormat::MpegPs; + disc.aacs.as_mut().unwrap().uk_ro = vtkf; + let empty = AacsKeyMap::from_ranges(Vec::new()); + assert_eq!(single_cps_key_slot(&disc, &pool(&[K0]), &empty), Some(0)); +} diff --git a/tests/spec_quotes.rs b/tests/spec_quotes.rs index c5b0ced5..cb9f689b 100644 --- a/tests/spec_quotes.rs +++ b/tests/spec_quotes.rs @@ -147,3 +147,98 @@ fn libaacs_quotes_match_source() { ); } } + +const SITES: &str = include_str!("spec_sites.txt"); + +// The body of the one `fn ` in `src`, braces matched outside `//` comments and +// string/char literals. Err when the fn is missing or defined twice. +fn fn_body<'a>(src: &'a str, name: &str) -> Result<&'a str, String> { + let starts: Vec = [format!("fn {name}("), format!("fn {name}<")] + .iter() + .flat_map(|pat| src.match_indices(pat.as_str()).map(|(i, _)| i)) + .collect(); + let [start] = starts[..] else { + return Err(format!("fn {name}: {} definitions", starts.len())); + }; + let bytes = src.as_bytes(); + let (mut i, mut depth, mut open) = (start, 0usize, None); + while i < bytes.len() { + match bytes[i] { + b'/' if bytes.get(i + 1) == Some(&b'/') => { + i += src[i..].find('\n').unwrap_or(src.len() - i); + } + b'"' => { + i += 1; + while i < bytes.len() && bytes[i] != b'"' { + i += if bytes[i] == b'\\' { 2 } else { 1 }; + } + } + b'\'' if bytes.get(i + 2) == Some(&b'\'') => i += 2, + b'{' => { + open.get_or_insert(i); + depth += 1; + } + b'}' if open.is_some() => { + depth -= 1; + if depth == 0 { + return Ok(&src[open.unwrap()..=i]); + } + } + _ => {} + } + i += 1; + } + Err(format!("fn {name}: unbalanced body")) +} + +/// KU design §3.6.1 rule 11: every listed spec-governed site names its quote ID. +#[test] +fn spec_governed_sites_cite_the_spec() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let ids: HashSet<&str> = spec::quotes().map(|q| q.id).collect(); + let mut rows = 0; + for line in SITES + .lines() + .filter(|l| !l.starts_with('#') && !l.is_empty()) + { + let cols: Vec<&str> = line.split('\t').collect(); + let [file, name, id] = cols[..] else { + panic!("site row is `\\t\\t`: {line:?}"); + }; + assert!(ids.contains(id), "{file} {name}: {id} is not a spec quote"); + let src = std::fs::read_to_string(root.join(file)).expect(file); + let body = fn_body(&src, name).unwrap_or_else(|e| panic!("{file}: {e}")); + assert!(cites(body, id), "{file} fn {name} does not cite {id}"); + rows += 1; + } + assert!(rows > 0, "no spec-governed sites listed"); +} + +// Does `body` name `id` as a whole ID? `KS-2` must not match inside `KS-22` or `XKS-2`. +fn cites(body: &str, id: &str) -> bool { + body.match_indices(id).any(|(at, _)| { + let before = body[..at].chars().next_back(); + let after = body[at + id.len()..].chars().next(); + !before.is_some_and(|c| c.is_alphanumeric() || c == '_') + && !after.is_some_and(|c| c.is_ascii_digit()) + }) +} + +/// The site checker itself: a cited ID passes; a missing ID, a missing fn, a braced +/// comment or string, and a twice-defined fn are all caught. +#[test] +fn spec_site_check_finds_the_body() { + let src = "fn a() { // KS-1 { unbalanced in a comment\n let s = \"}\"; let c = '{'; }\n\ + fn b() { if x { KS-2 } }\nfn b2() {}\n"; + assert!(fn_body(src, "a").unwrap().contains("KS-1")); + assert!(fn_body(src, "b").unwrap().contains("KS-2")); + assert!(!fn_body(src, "b2").unwrap().contains("KS-2")); + assert!(fn_body(src, "c").is_err()); + assert!(fn_body("fn d() {}\nfn d() {}", "d").is_err()); + // Word boundaries: `KS-2` is not cited by `KS-22`, and is by `KS-2,` / `(KS-2)`. + assert!(!cites("fn e() { // KS-22 only }", "KS-2")); + assert!(!cites("fn e() { // XKS-2 }", "KS-2")); + assert!(cites("fn e() { // KS-22, KS-2, KS-3 }", "KS-2")); + assert!(cites("fn e() { // (KS-2) }", "KS-2")); + assert!(cites(fn_body(src, "b").unwrap(), "KS-2")); +} diff --git a/tests/spec_quotes.txt b/tests/spec_quotes.txt index 0ac8f9db..dddca5bc 100644 --- a/tests/spec_quotes.txt +++ b/tests/spec_quotes.txt @@ -10,7 +10,7 @@ KS-2 An Aligned Unit consists of 32 MPEG source packets: Each MPEG source packet KS-3 The final 6128 bytes of each Aligned Unit is encrypted using the Block Key and AES-128CBCE. A new CBC cipher chain is started for each Aligned Unit (see Figure 3-7). KS-4 The first 16 bytes of each Aligned Unit is used as the seed for calculating the Block Key. Calculation method for the Block key is described in Figure 3-8. KS-5 Copy_permission_indicator shall be set to 11₂ if the data is encrypted, or shall be set to 00₂ if the data is not encrypted. If the Licensed Player encounters the packet with Copy_permission_indicator set to 10₂ or 01₂, the data shall be considered encrypted. -KS-6 Table 3-34 TP_extra_header: Syntax No. of bits Mnemonic TP_extra_header { Copy_permission_indicator 2 uimsbf Arrival_time_stamp 30 uimsbf } // cited later by SG9 (KU-L1) and the §3.6.2 CPI-clear site +KS-6 Table 3-34 TP_extra_header: Syntax No. of bits Mnemonic TP_extra_header { Copy_permission_indicator 2 uimsbf Arrival_time_stamp 30 uimsbf } KS-7 Each physical sector in an Aligned Unit shall be allocated contiguously on the BD-ROM disc. KS-8 All the extents of each Clip AV stream file shall be allocated with ascending order in physical layer. KS-9 Within the Stereoscopic Interleaved file, segments of the two Clip AV stream files are recorded alternately. The boundary of these segments shall be always aligned to Aligned Unit boundary, so that there is no impact on the encryption scheme of the Stereoscopic Interleaved file. @@ -26,7 +26,7 @@ KS-18 If the Bus Encryption Enabled (BEE) flag in the Content Certificate is set KS-19 Unless otherwise specified, the Initialization Vector used at the beginning of a CBC encryption or decryption chain is a constant, iv0, which is: 0BA0F8DDFEA61FB3D8DF9F566A050F78₁₆ KS-20 The 30 least significant bits of this field shall be interpreted as a 30-bit unsigned binary number specifying the length of the extent in bytes. Unless otherwise specified, the length shall be an integral multiple of the logical block size. The 2 most significant bits shall be interpreted as a 2-bit unsigned binary number specifying the type of the extent as described in figure 4/42. If the 30 least significant bits are set to ZERO, the two most significant bits shall also be set to ZERO. KS-21 Value Interpretation / 0 Extent recorded and allocated / 1 Extent not recorded but allocated / 2 Extent not recorded and not allocated / 3 The extent is the next extent of allocation descriptors (see 4/12) / Figure 42 - Extent interpretation -KS-22 static int _verify_ts(uint8_t *buf) { int i; for (i = 0; i < ALIGNED_UNIT_LEN; i += 192) { if (BD_UNLIKELY(buf[i + 4] != 0x47)) { return 0; } /* Clear copy_permission_indicator bits */ buf[i] &= ~0xc0; } return 1; } // cited later by SG9 (KU-L1) and the §3.6.2 CPI-clear site +KS-22 static int _verify_ts(uint8_t *buf) { int i; for (i = 0; i < ALIGNED_UNIT_LEN; i += 192) { if (BD_UNLIKELY(buf[i + 4] != 0x47)) { return 0; } /* Clear copy_permission_indicator bits */ buf[i] &= ~0xc0; } return 1; } KS-23 crypto_error = crypto_aes128e(aacs->uk->uk[curr_uk].key, out_buf, key); if (crypto_error) { LOG_CRYPTO_ERROR(DBG_AACS, "unit key derivation failed", crypto_error); return 1; } for (a = 0; a < 16; a++) { key[a] ^= out_buf[a]; /* here out_buf is plain data from in_buf */ } // test-only reference KS-24 /* plain ? */ if (!(buf[0] & 0xc0)) { // TP_extra_header Copy_permission_indicator == 0, unit is not encrypted return 1; } // test-only reference KS-25 AACS 2.x (UHD): the 64-byte Unit_Key_RO.inf stride and the FMTS IndividualSegment.tbl / .fmts model have no public book. They are pinned by the qa UHD fixture and aacs-unit-grid.md §2. @@ -34,3 +34,15 @@ KS-26 FMTS anchor contract: the decode server returns the whole set only for an KS-27 Please note: The AACS Specifications books for HD DVD and CBHD have been removed from this AACS website due to inactivity. KS-28 File-anchored grid on real discs: 32/32 sync on the file grid vs 0–1/32 on the disc-LBA grid (aacs-unit-grid.md §2 table). This corroborates KS-1. KS-29 The Licensed Drive reads Volume ID (Volume_ID) from the media and calculates a message authentication code (Dm) from the Volume ID and the Bus Key (BK) calculated in step 26 in Section 4.3. // test-only reference +SS-1 Table 18 Sense key specific field definitions Sense Key Sense Key Specific Field Definition Reference ILLEGAL REQUEST Field pointer 2.4.1.1.4.2 HARDWARE ERROR, MEDIUM ERROR, or RECOVERED ERROR Actual retry count 2.4.1.1.4.3 NO SENSE or NOT READY Progress indication 2.4.1.1.4.4 … 2.4.1.1.4.4 Progress indication sense key specific data If the sense key is NO SENSE or NOT READY, the SENSE KEY SPECIFIC field shall be as shown in table 21. … PROGRESS INDICATION field The PROGRESS INDICATION field is a percent complete indication in which the returned value is a numerator that has 65 536 (10000h) as its denominator. The progress indication shall be based upon the total operation. … SKSV (Sense-key Specific Valid) bit 1 A sense-key specific valid (SKSV) bit set to one indicates the SENSE KEY SPECIFIC field contains valid information as defined in this manual. +SS-2 The descriptor format sense data for response codes 72h (current errors) and 73h (deferred errors) is defined in table 12. … The sense key specific sense data descriptor (see table 17) provides additional information about the exception condition. The format and content of the sense-key specific data depends on the value in the SENSE KEY field (see 2.4.1.1.1). +SS-3 In the event that a command requires a level of readiness that does not currently exist, the Drive should be terminated with CHECK CONDITION status and sense bytes SK/ASC/ASCQ should be selected from those shown in Table F.3. Table F.3 — Readiness Errors SK ASC ASCQ Description 2 04 00 LOGICAL UNIT NOT READY, CAUSE NOT REPORTABLE 2 04 01 LOGICAL UNIT IS IN PROCESS OF BECOMING READY 2 04 02 LOGICAL UNIT NOT READY, INITIALIZING CMD. REQUIRED 2 04 03 LOGICAL UNIT NOT READY, MANUAL INTERVENTION REQUIRED 2 04 04 LOGICAL UNIT NOT READY, FORMAT IN PROGRESS 2 04 07 LOGICAL UNIT NOT READY, OPERATION IN PROGRESS 2 04 08 LOGICAL UNIT NOT READY, LONG WRITE IN PROGRESS 2 0C 07 WRITE ERROR RECOVERY NEEDED 2 0C 0F DEFECTS IN ERROR WINDOW 2 30 00 INCOMPATIBLE MEDIUM INSTALLED 2 30 01 CANNOT READ MEDIUM – UNKNOWN FORMAT 2 or 5 30 02 CANNOT READ MEDIUM – INCOMPATIBLE FORMAT 2 30 03 CLEANING CARTRIDGE INSTALLED 2 or 5 30 04 CANNOT WRITE MEDIUM – UNKNOWN FORMAT 2 or 5 30 05 CANNOT WRITE MEDIUM – INCOMPATIBLE FORMAT 2 or 5 30 06 CANNOT FORMAT MEDIUM – INCOMPATIBLE MEDIUM 2 30 07 CLEANING FAILURE 2 or 5 30 11 CANNOT WRITE MEDIUM – UNSUPPORTED MEDIUM VERSION 2 3A 00 MEDIUM NOT PRESENT 2 3A 01 MEDIUM NOT PRESENT – TRAY CLOSED 2 3A 02 MEDIUM NOT PRESENT – TRAY OPEN +SS-4 The TEST UNIT READY command (see table 202) provides a means to check if the logical unit is ready. This is not a request for a self-test. If the logical unit is able to accept an appropriate medium-access command without returning CHECK CONDITION status, this command shall return a GOOD status. If the logical unit is unable to become operational or is in a state such that an application client action (e.g., START UNIT command) is required to make the logical unit ready, the command shall be terminated with CHECK CONDITION status, with the sense key set to NOT READY. +SS-5 The Drive maintains two separate Prevent states: Prevent and Persistent Prevent as described in 4.1.7. The Persistent and Prevent bits are used to independently select values for these states. See Table 329. Table 329 — State Selection Persistent Prevent Meaning 0 0 Prevent State shall be cleared (Unlocked) 0 1 Prevent State shall be set (Locked) 1 0 Persistent Prevent State shall be cleared (Persistent Allow) 1 1 Persistent Prevent State shall be set (Persistent Prevent) +SS-6 When Power Conditions field is zero and FL is zero, the meanings of LoEj and Start are defined in Table 633. Table 633 — LoEj and Start Meanings when Power Conditions = 0 LoEj Start Operation 0 0 Stop the disc 0 1 Start the disc and make ready for access 1 0 Eject the disc if permitted. It is not an error If no media is present. See 6.13, PREVENT ALLOW MEDIUM REMOVAL Command 1 1 Load the disc, Start the disc and make ready for access. It is not an error if no media is present. +SS-7 static int _mmc_report_key(MMC *mmc, uint8_t agid, uint32_t addr, uint8_t blocks, uint8_t format, uint8_t *buf, uint16_t len) { uint8_t cmd[MMC_CDB_SIZE]; memset(cmd, 0, sizeof(cmd)); BD_DEBUG(DBG_MMC, "MMC report key...\n"); cmd[0] = MMC_REPORT_KEY; cmd[2] = (addr >> 24) & 0xff; cmd[3] = (addr >> 16) & 0xff; cmd[4] = (addr >> 8) & 0xff; cmd[5] = addr & 0xff; cmd[6] = blocks; cmd[7] = MMC_KEY_CLASS_AACS; cmd[8] = (len >> 8) & 0xff; cmd[9] = len & 0xff; cmd[10] = (agid << 6) | (format & 0x3f); return device_send_cmd(mmc->dev, cmd, buf, 0, len); } … static int _mmc_invalidate_agid(MMC *mmc, uint8_t agid) { uint8_t buf[2]; memset(buf, 0, sizeof(buf)); return _mmc_report_key(mmc, agid, 0, 0, MMC_REPORT_KEY_AACS_INVALIDATE_AGID, buf, sizeof(buf)); } static void _mmc_invalidate_agids(MMC *mmc) { int agid; /* invalidate all agids */ for (agid = 0; agid < 4; agid++) { _mmc_invalidate_agid(mmc, agid); } } static int _mmc_report_agid(MMC *mmc, uint8_t *agid) { uint8_t buf[8]; memset(buf, 0, sizeof(buf)); int result = _mmc_report_key(mmc, 0, 0, 0, MMC_REPORT_KEY_AACS_AGID, buf, sizeof(buf)); if (result) { *agid = (buf[7] & 0xff) >> 6; } return result; } +SS-12 The fsync() function shall request that all data for the open file descriptor named by fildes is to be transferred to the storage device associated with the file described by fildes. The nature of the transfer is implementation-defined. The fsync() function shall not return until the system has completed that action or until an error is detected. The fdatasync() function shall force all currently queued I/O operations associated with the file indicated by file descriptor fildes to the synchronized I/O completion state. The functionality shall be equivalent to fsync() with the symbol _POSIX_SYNCHRONIZED_IO defined, with the exception that all I/O operations shall be completed as defined for synchronized I/O data integrity completion. +SS-13 This system call is extremely dangerous and should not be used in portable programs. None of these operations writes out the file's metadata. Therefore, unless the application is strictly performing overwrites of already-instantiated disk blocks, there are no guarantees that the data will be available after a crash. There is no user interface to know if a write is purely an overwrite. On filesystems using copy-on-write semantics (e.g., btrfs) an overwrite of existing allocated blocks is impossible. When writing into preallocated space, many filesystems also require calls into the block allocator, which this system call does not sync out to disk. This system call does not flush disk write caches and thus does not provide any data integrity on systems with volatile disk write caches. Some details SYNC_FILE_RANGE_WAIT_BEFORE and SYNC_FILE_RANGE_WAIT_AFTER will detect any I/O errors or ENOSPC conditions and will return these to the caller. Useful combinations of the flags bits are: SYNC_FILE_RANGE_WAIT_BEFORE | SYNC_FILE_RANGE_WRITE Ensures that all pages in the specified range which were dirty when sync_file_range() was called are placed under write-out. This is a start-write-for-data-integrity operation. SYNC_FILE_RANGE_WRITE Start write-out of all dirty pages in the specified range which are not presently under write-out. This is an asynchronous flush-to-disk operation. This is not suitable for data integrity operations. SYNC_FILE_RANGE_WAIT_BEFORE (or SYNC_FILE_RANGE_WAIT_AFTER) Wait for completion of write-out of all pages in the specified range. This can be used after an earlier SYNC_FILE_RANGE_WAIT_BEFORE | SYNC_FILE_RANGE_WRITE operation to wait for completion of that operation, and obtain its result. SYNC_FILE_RANGE_WAIT_BEFORE | SYNC_FILE_RANGE_WRITE | SYNC_FILE_RANGE_WAIT_AFTER This is a write-for-data-integrity operation that will ensure that all pages in the specified range which were dirty when sync_file_range() was called are committed to disk. +SS-14 Does the same thing as fsync(2) then asks the drive to flush all buffered data to the permanent storage device (arg is ignored). As this drains the entire queue of the device and acts as a barrier, data that had been fsync'd on the same device before is guaranteed to be persisted when this call returns. This is currently implemented on HFS, MS-DOS (FAT), Universal Disk Format (UDF) and APFS file systems. The operation may take quite a while to complete. Certain FireWire drives have also been known to ignore the request to flush their buffered data. +SS-15 Typically the WriteFile and WriteFileEx functions write data to an internal buffer that the operating system writes to a disk or communication pipe on a regular basis. The FlushFileBuffers function writes all the buffered information for a specified file to the device or pipe. +SS-23 When coupled with a store, all previous operations become ordered before any load of this value with Acquire (or stronger) ordering. In particular, all previous writes become visible to all threads that perform an Acquire (or stronger) load of this value. When coupled with a load, if the loaded value was written by a store operation with Release (or stronger) ordering, then all subsequent operations become ordered after that store. In particular, all subsequent loads will see data written before the store. diff --git a/tests/spec_sites.txt b/tests/spec_sites.txt new file mode 100644 index 00000000..a1c2b427 --- /dev/null +++ b/tests/spec_sites.txt @@ -0,0 +1,28 @@ +# Spec-governed code sites (KU design §3.6.1 rule 11): `\t\t`, one row per +# (site, quote). `spec_governed_sites_cite_the_spec` checks that the body of `fn ` in +# `` names ``. KU rows are KS-n; Stop adds its SS-n rows here. +src/aacs/content.rs clear_copy_permission_indicator KS-5 +src/aacs/content.rs clear_copy_permission_indicator KS-6 +src/aacs/content.rs clear_copy_permission_indicator KS-22 +src/decrypt.rs apply_aacs_map KS-3 +src/decrypt.rs apply_aacs_map KS-5 +src/decrypt.rs apply_aacs_map KS-25 +src/decrypt.rs apply_aacs_map KS-26 +src/mux/resolve.rs probe_fmts_index_keys KS-25 +src/mux/resolve.rs probe_fmts_index_keys KS-26 +src/disc/mod.rs declared_cps_units KS-14 +src/whole_disc.rs single_cps_key_slot KS-14 +src/whole_disc.rs single_cps_key_slot KS-27 +src/sector/bus_removal.rs debus_read KS-18 +src/scsi/mod.rs parse_sense_progress SS-1 +src/scsi/mod.rs parse_sense_progress SS-2 +src/drive/mod.rs wait_ready_with SS-1 +src/drive/mod.rs wait_ready_with SS-3 +src/drive/mod.rs wait_ready_with SS-4 +src/drive/allow.rs is_allow SS-5 +src/drive/mod.rs lock_tray SS-5 +src/drive/mod.rs unlock_tray SS-5 +src/drive/allow.rs is_eject SS-6 +src/drive/mod.rs eject SS-6 +src/drive/mod.rs start_unit SS-6 +src/drive/mod.rs note_ledger SS-7