From d6396005b80f2a80dfa9003bea46ea7f2ebea62f Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:22:29 -0700 Subject: [PATCH] scsi, lpcm: MMC-6 disc presence and tray handling; LPCM decoded to plain PCM Disc presence is tri-state per MMC-6, an empty drive fails fast, a cleaning cartridge reads as no disc, and the tray is unlocked only when we locked it. BD and DVD LPCM decode to plain PCM, with whole-frame DVD blocks and the BD layout carried through M2TS. --- CHANGELOG.md | 11 + src/disc/dvd_audio_probe.rs | 187 +++++-- src/drive/linux.rs | 83 ++-- src/drive/mod.rs | 578 ++++++++++++++++++--- src/drive/windows.rs | 39 +- src/error.rs | 4 +- src/identity.rs | 165 ++++-- src/lib.rs | 5 +- src/mux/codec/lpcm.rs | 967 +++++++++++++++++++++++++++--------- src/mux/driver.rs | 258 +++++++++- src/mux/header_gate.rs | 54 +- src/mux/m2ts.rs | 315 +++++++++++- src/mux/mkv.rs | 46 +- src/mux/ps.rs | 10 +- src/pes.rs | 7 +- src/scsi/fd_handoff.rs | 149 +++++- src/scsi/linux.rs | 323 +++++++----- src/scsi/macos.rs | 18 +- src/scsi/macos_shim.c | 79 +-- src/scsi/mod.rs | 410 ++++++++++++++- src/scsi/windows.rs | 162 ++---- tests/scsi_recovery.rs | 87 +--- 22 files changed, 3098 insertions(+), 859 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10832c52..35c348dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,21 @@ - `css::CrackOutcome` is `#[non_exhaustive]` and gains `Halted` and `Unreadable(Error)`; it no longer derives `Clone`. - `Disc::decrypt_keys_for_title` takes a halt token and returns `Result`. - `aacs::content::decrypt_bus` is no longer public. +- `scsi::linux::SgIoTransport`'s `fd` and `fd_recovery` fields are private. +- `drive_has_disc` (Linux, Windows) classifies TEST UNIT READY sense (derived from MMC-6 Table F.3): `Ok(false)` only for NOT READY 3Ah (medium not present) and 30/03, 30/07 (cleaning cartridge); other NOT READY states, including 04/01 (becoming ready, MMC-6 §6.22.3), are `Ok(true)`; UNIT ATTENTION is re-polled up to 4 times; other sense is `Err`. macOS still answers from IOKit media presence. +- `Drive::wait_ready` rides out transport failures until a run of 2+ has lasted 5 s past the first one's completion, sends one START UNIT on NOT READY 04/02, fails at once on NOT READY 30h (incompatible medium) with that sense, and returns the 3Ah sense after 10 consecutive MEDIUM NOT PRESENT answers with no 04/01 seen. +- `list_drives` on Linux without sysfs keeps an sg node unless INQUIRY says it is not optical or open() fails with no device (ENOENT/ENXIO/ENODEV) or no permission (EACCES/EPERM), so a wedged or busy drive is still listed. +- Dropping a transport or `Drive` unlocks the tray only if it locked it. +- `Error::ImageTruncated` displays as `E: have/want`. + +### Removed + +- `SptiTransport::reset` (Windows; unused). ### Added - `AudioChannels` gains 3.0, 3.1, 4.1, 6.0 and 7.0. +- `DiscPresence` (`Present` / `Absent` / `Settling`) and `disc_presence(path)`: the tri-state answer `drive_has_disc` collapses (`Settling` counts as a disc). - `Disc::inputs_with_samples` fills `DiscInputs::samples` from the main feature. ### Fixed diff --git a/src/disc/dvd_audio_probe.rs b/src/disc/dvd_audio_probe.rs index d3610fdb..c9951820 100644 --- a/src/disc/dvd_audio_probe.rs +++ b/src/disc/dvd_audio_probe.rs @@ -80,62 +80,104 @@ fn max_substream_channels(data: &[u8]) -> Option { /// stream (in IFO order), picks the physical `0x8x` sub-stream whose probed channel count /// equals the declared count, never reusing a claimed sub-stream, and writes its PID (`0xBD00 | /// sub_id`) back onto the `Stream::Audio`. Conservative: only reassigns when a better match -/// exists. Returns the number of streams whose PID was changed. +/// exists or keeping the ordinal sub would share a PID. Sharing is left (and logged) only when +/// there are more AC-3 streams than sub-streams. Returns the number of changed PIDs. pub fn remap_audio_pids(streams: &mut [Stream], probed: &BTreeMap) -> usize { if probed.is_empty() { return 0; } - // Sub-streams already claimed by a remapped (or matching) earlier stream, - // so two declared streams never collide on one physical sub-stream. - let mut claimed: Vec = Vec::new(); + let ac3: Vec = streams + .iter() + .enumerate() + .filter(|(_, s)| matches!(s, Stream::Audio(a) if a.codec == crate::disc::Codec::Ac3)) + .map(|(i, _)| i) + .collect(); + let info = |s: &Stream| match s { + Stream::Audio(a) => (a.channels.count(), (a.pid & 0x00FF) as u8), + _ => (0, 0), + }; + // Ordinal subs before any re-route: candidates for a displaced stream in pass 3. + let ordinal: Vec = ac3.iter().map(|&i| info(&streams[i]).1).collect(); let mut changed = 0usize; - - for s in streams.iter_mut() { - let Stream::Audio(a) = s else { continue }; - if a.codec != crate::disc::Codec::Ac3 { - continue; + // Each physical sub-stream is claimed at most once, so two declared streams + // never collide on one PID. Pass 1: streams already on a matching sub-stream stay. + let mut claimed: Vec = Vec::new(); + let mut pending: Vec = Vec::new(); + for &i in &ac3 { + let (declared, current) = info(&streams[i]); + if probed.get(¤t) == Some(&declared) && !claimed.contains(¤t) { + claimed.push(current); + } else { + pending.push(i); } - let declared = a.channels.count(); - // The sub-id this stream currently routes by (low byte of its PID). - let current_sub = (a.pid & 0x00FF) as u8; - - // If the stream's current physical sub-stream already matches its - // declared channel count, keep it and claim it. - if probed.get(¤t_sub) == Some(&declared) { - claimed.push(current_sub); - continue; - } - - // Otherwise find an unclaimed physical sub-stream whose REAL channel - // count equals the declared count. + } + // Pass 2: route the rest to an unclaimed sub-stream with the declared count. + let mut unmatched: Vec = Vec::new(); + for i in pending { + let (declared, _) = info(&streams[i]); let pick = probed .iter() .find(|(sub, ch)| **ch == declared && !claimed.contains(*sub)) .map(|(sub, _)| *sub); - - if let Some(sub) = pick { - let new_pid = 0xBD00 | sub as u16; - if new_pid != a.pid { - tracing::debug!( - target: "freemkv::scan", - old_pid = a.pid, - new_pid, - declared_channels = declared, - "dvd: re-routed AC-3 audio to physical sub-stream matching channel count" - ); - a.pid = new_pid; - changed += 1; + match pick { + Some(sub) => { + claimed.push(sub); + changed += set_sub(&mut streams[i], sub); } - claimed.push(sub); + None => unmatched.push(i), + } + } + // Pass 3: no physical match — keep the ordinal sub when still free, else take a + // free one rather than share a PID. + let mut displaced: Vec = Vec::new(); + for i in unmatched { + let (_, current) = info(&streams[i]); + if claimed.contains(¤t) { + displaced.push(i); } else { - // No physical match — leave the ordinal assignment, but claim its - // current sub so later streams don't steal a slot it may still use. - claimed.push(current_sub); + claimed.push(current); + } + } + for i in displaced { + // Prefer a sub-stream that physically carries data, then a vacated ordinal one. + let free = probed + .keys() + .copied() + .chain(ordinal.iter().copied()) + .find(|sub| !claimed.contains(sub)); + match free { + Some(sub) => { + claimed.push(sub); + changed += set_sub(&mut streams[i], sub); + } + None => tracing::warn!( + target: "freemkv::scan", + pid = 0xBD00 | u16::from(info(&streams[i]).1), + "dvd: no free AC-3 sub-stream; audio track shares a PID with another" + ), } } changed } +// Route an AC-3 stream to physical sub-stream `sub`; returns 1 when its PID changed. +fn set_sub(s: &mut Stream, sub: u8) -> usize { + let Stream::Audio(a) = s else { return 0 }; + let new_pid = 0xBD00 | sub as u16; + if new_pid == a.pid { + return 0; + } + tracing::debug!( + target: "freemkv::scan", + old_pid = a.pid, + new_pid, + declared_channels = a.channels.count(), + "dvd: re-routed AC-3 audio to physical sub-stream matching channel count" + ); + a.pid = new_pid; + 1 +} + /// Probe the first feature extent of a DVD title through a (decrypted) sector /// source and re-route its AC-3 audio PIDs to the physically-correct /// sub-streams. A bounded, best-effort scan: any read error or empty probe @@ -379,6 +421,73 @@ mod tests { ); } + /// A stream already sitting on its matching sub-stream must not share it with + /// an earlier stream that was re-routed there: 0x80=5.1, 0x81=2.0, both + /// declared 2.0 must stay on distinct sub-streams. + #[test] + fn remap_keep_current_respects_claimed() { + let mut probed = BTreeMap::new(); + probed.insert(0x80u8, 6u8); + probed.insert(0x81u8, 2u8); + let mut streams = vec![ + ac3_stream(0xBD80, AudioChannels::Stereo), + ac3_stream(0xBD81, AudioChannels::Stereo), + ]; + remap_audio_pids(&mut streams, &probed); + let pids: Vec = streams + .iter() + .filter_map(|s| match s { + Stream::Audio(a) => Some(a.pid), + _ => None, + }) + .collect(); + assert_eq!(pids, vec![0xBD80, 0xBD81], "no two tracks on one PID"); + } + + /// A displaced stream (its ordinal sub taken by a re-route) prefers an unclaimed + /// sub-stream that physically exists over a vacated ordinal one with no data. + #[test] + fn remap_displaced_stream_prefers_a_probed_sub() { + let mut probed = BTreeMap::new(); + probed.insert(0x80u8, 2u8); + probed.insert(0x81u8, 6u8); + let mut streams = vec![ + ac3_stream(0xBD82, AudioChannels::Surround51), + ac3_stream(0xBD81, AudioChannels::Mono), + ]; + remap_audio_pids(&mut streams, &probed); + let pids: Vec = streams + .iter() + .filter_map(|s| match s { + Stream::Audio(a) => Some(a.pid), + _ => None, + }) + .collect(); + assert_eq!( + pids, + vec![0xBD81, 0xBD80], + "displaced -> probed 0x80, not empty 0x82" + ); + } + + /// More AC-3 streams than sub-streams: nothing is free, so the extra stream keeps + /// its (shared) ordinal PID — logged, never re-routed onto a random sub. + #[test] + fn remap_exhausted_subs_leave_the_ordinal_pid() { + let mut probed = BTreeMap::new(); + probed.insert(0x80u8, 2u8); + let mut streams = vec![ + ac3_stream(0xBD80, AudioChannels::Stereo), + ac3_stream(0xBD80, AudioChannels::Stereo), + ]; + assert_eq!(remap_audio_pids(&mut streams, &probed), 0); + assert!( + streams + .iter() + .all(|s| matches!(s, Stream::Audio(a) if a.pid == 0xBD80)) + ); + } + /// Empty probe (unreadable / scrambled VOB) is a no-op — the ordinal /// assignment survives so behaviour never regresses below today's. #[test] diff --git a/src/drive/linux.rs b/src/drive/linux.rs index 6966aa11..7e0772b3 100644 --- a/src/drive/linux.rs +++ b/src/drive/linux.rs @@ -4,13 +4,8 @@ use crate::drive::DeviceResolution; use crate::error::{Error, Result}; use crate::identity::DriveId; -/// SCSI peripheral device type 5 = MMC / optical (CD/DVD/BD), held in the -/// low 5 bits of INQUIRY byte 0 (the high 3 bits are the peripheral -/// qualifier, masked off here). -const SCSI_PERIPHERAL_TYPE_OPTICAL: u8 = 0x05; - -/// Discover optical drives by enumerating `/dev/sg*` SCSI-generic nodes, -/// opening each, running INQUIRY, and keeping only devices whose +/// Discover optical drives: `/dev/sg*` nodes sysfs reports as type 5 (all +/// nodes if sysfs is unreadable), each opened and kept only if its INQUIRY /// peripheral device type is optical (MMC, type 0x05). /// /// Devices where `scsi::open` or `DriveId::from_drive` fail are silently @@ -18,15 +13,10 @@ const SCSI_PERIPHERAL_TYPE_OPTICAL: u8 = 0x05; /// shouldn't abort discovery of the others). pub fn find_drives() -> Vec<(String, DriveId)> { let mut drives = Vec::new(); - for name in enumerate_sg_names() { - let path = format!("/dev/{name}"); - if !std::path::Path::new(&path).exists() { - continue; - } + for path in candidate_paths() { if let Ok(mut transport) = crate::scsi::open(std::path::Path::new(&path)) && let Ok(id) = DriveId::from_drive(transport.as_mut()) - && !id.raw_inquiry.is_empty() - && (id.raw_inquiry[0] & 0x1F) == SCSI_PERIPHERAL_TYPE_OPTICAL + && id.is_optical() { drives.push((path, id)); } @@ -34,27 +24,29 @@ pub fn find_drives() -> Vec<(String, DriveId)> { drives } -// Enumerate `sg*` names via `/sys/class/scsi_generic/` (exact present-device list); fall back -// to a bounded `sg0..15` probe if sysfs is unreadable. -fn enumerate_sg_names() -> Vec { - let mut names = Vec::new(); - if let Ok(entries) = std::fs::read_dir("/sys/class/scsi_generic") { - for entry in entries.flatten() { - let name = entry.file_name().to_string_lossy().to_string(); - if name.starts_with("sg") { - names.push(name); - } - } - } else { - for i in 0..16 { - let name = format!("sg{i}"); - if std::path::Path::new(&format!("/dev/{name}")).exists() { - names.push(name); - } - } - } - names.sort(); - names +/// Candidate optical `/dev/sg*` paths: from sysfs alone (nothing opened), or, +/// when sysfs is unreadable, nodes whose INQUIRY reports an optical device. +pub(crate) fn candidate_paths() -> Vec { + let (names, type_filtered) = crate::scsi::linux::enumerate_sg_names(); + let paths = names + .into_iter() + .map(|name| format!("/dev/{name}")) + .filter(|path| std::path::Path::new(path).exists()); + optical_candidates(paths, type_filtered, |path| { + crate::scsi::open(std::path::Path::new(path)) + .and_then(|mut t| crate::scsi::inquiry(t.as_mut())) + .is_ok_and(|r| crate::scsi::is_optical_peripheral(&r.raw)) + }) +} + +// Keep `paths` as-is if sysfs already type-filtered them, else only those +// `is_optical` confirms. +fn optical_candidates( + paths: impl Iterator, + type_filtered: bool, + mut is_optical: impl FnMut(&str) -> bool, +) -> Vec { + paths.filter(|p| type_filtered || is_optical(p)).collect() } /// Resolve a device path to its raw `/dev/sg*` SCSI-generic node. @@ -100,3 +92,24 @@ pub fn resolve_device(path: &str) -> Result<(String, DeviceResolution)> { } Ok((path.to_string(), DeviceResolution::Direct)) } + +#[cfg(test)] +mod tests { + use super::*; + + // Unfiltered sg nodes (sysfs unreadable) must be INQUIRY-checked before a + // Drive::open; sysfs-filtered ones are not probed at all. + #[test] + fn optical_candidates_probes_only_when_sysfs_did_not_filter() { + let paths = || ["/dev/sg0", "/dev/sg1"].map(String::from).into_iter(); + let mut probed = Vec::new(); + let kept = optical_candidates(paths(), true, |p| { + probed.push(p.to_string()); + false + }); + assert_eq!(kept, ["/dev/sg0", "/dev/sg1"]); + assert!(probed.is_empty(), "type-filtered paths need no INQUIRY"); + let kept = optical_candidates(paths(), false, |p| p == "/dev/sg1"); + assert_eq!(kept, ["/dev/sg1"], "non-optical sg0 dropped"); + } +} diff --git a/src/drive/mod.rs b/src/drive/mod.rs index acb3bf25..3bc0d50e 100644 --- a/src/drive/mod.rs +++ b/src/drive/mod.rs @@ -106,6 +106,9 @@ pub struct Drive { oem_vid: Option<[u8; 16]>, /// True once `init()` has run (whether or not an unlocker matched). init_ran: bool, + /// `lock_tray` was called and no `unlock_tray` since: only then does Drop + /// send ALLOW (never clear a lock another process holds). + tray_locked: bool, /// Lazily-computed registry-match name for `platform_name()`'s `&str` /// return before `init()` has run. matched_name_cache: std::sync::OnceLock, @@ -163,6 +166,7 @@ impl Drive { 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(), @@ -228,6 +232,7 @@ impl Drive { 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(), @@ -315,7 +320,9 @@ impl Drive { /// Shared cleanup — called by Drop (and thus by close). fn cleanup(&mut self) { - self.unlock_tray(); + if self.tray_locked { + self.unlock_tray(); + } } /// Whether an unlocker claims this drive by identity (i.e. it can be @@ -353,6 +360,11 @@ impl Drive { // 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"); + // (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 buf = [0u8; 0]; @@ -371,11 +383,41 @@ impl Drive { return Ok(()); } Err(Error::Halted) => return Err(Error::Halted), - // A dead bus (transport failure / disconnected drive) will never spin - // up — surface it immediately instead of polling a phantom for 30 s. A - // plain not-ready TUR failure falls through and keeps the loop going. - Err(e) if e.is_scsi_transport_failure() => return Err(e), - Err(_) => {} + // 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. + 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 { + return Err(e); + } + } + Err(e) => { + failing = None; + let sense = e.scsi_sense().map(|s| (s.sense_key, s.asc, s.ascq)); + 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. + 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. + 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. + Some((crate::scsi::SENSE_KEY_NOT_READY, 0x04, 0x02)) if !start_sent => { + start_sent = true; + self.start_unit()?; + } + _ => {} + } + } } // Halt-aware backoff: the flag can also flip DURING the 500 ms // gap, which is where most of the 30 s is actually spent. @@ -627,7 +669,8 @@ impl Drive { } /// Query a specific GET CONFIGURATION feature by code. - /// Returns the feature data (without the 8-byte header), or None if not available. + /// Returns the feature descriptor (without the 8-byte header), or None if the + /// drive does not report that feature. pub fn get_config_feature(&mut self, feature_code: u16) -> Option> { let cdb = [ crate::scsi::SCSI_GET_CONFIGURATION, @@ -652,15 +695,8 @@ impl Drive { 5_000, ) .ok()?; - // Clamp the transport-reported count to the buffer length: a - // misbehaving driver/bridge could report more bytes than the - // buffer holds, which would panic the slice. - let end = r.bytes_transferred.min(buf.len()); - if end > 8 { - Some(buf[8..end].to_vec()) - } else { - None - } + crate::scsi::gc_feature_descriptor(&buf, r.bytes_transferred, feature_code) + .map(<[u8]>::to_vec) } /// Read REPORT KEY RPC state (region playback control). @@ -1072,6 +1108,7 @@ impl Drive { 0x00, ]; let mut buf = [0u8; 0]; + self.tray_locked = true; if let Err(e) = self.scsi .as_mut() @@ -1092,6 +1129,7 @@ impl Drive { 0x00, ]; let mut buf = [0u8; 0]; + 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. @@ -1123,6 +1161,21 @@ impl Drive { Ok(()) } + // 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<()> { + 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) { + Err(Error::Halted) => Err(Error::Halted), + Err(e) => { + tracing::warn!(target: "freemkv::drive", phase = "wait_ready", error_code = e.code(), "START UNIT rejected"); + Ok(()) + } + Ok(_) => Ok(()), + } + } + /// Soft power-cycle the drive mechanism WITHOUT ejecting: spin the disc /// down (`START STOP UNIT`, START=0, **LOEJ=0**) then back up (START=1). /// This clears the BU40N/Initio fast-fail *wedge* state that a run of @@ -1263,19 +1316,11 @@ pub fn find_drive() -> Option { .collect::>(); #[cfg(not(target_os = "macos"))] - let candidates = discover_drives() - .into_iter() - .map(|(path, _)| path) - .collect::>(); + let candidates = platform::candidate_paths(); select_drive_with_media(candidates, |path| { match Drive::open(std::path::Path::new(path)) { - Ok(drive) - if !drive.drive_id.raw_inquiry.is_empty() - && (drive.drive_id.raw_inquiry[0] & 0x1F) == 0x05 => - { - Some(drive) - } + Ok(drive) if drive.drive_id.is_optical() => Some(drive), Ok(drive) => { tracing::debug!( target: "freemkv::drive", @@ -1298,11 +1343,6 @@ pub fn find_drive() -> Option { }) } -#[cfg(not(target_os = "macos"))] -fn discover_drives() -> Vec<(String, DriveId)> { - platform::find_drives() -} - // Prefer a drive with media, else the first that opened. Only one drive is open // at a time (macOS allows one live handle): the fallback is released before the // next open and reopened by path. Split from find_drive for unit tests. @@ -1338,6 +1378,10 @@ fn select_drive_with_media

( // recovered-error reporting (PER/TB set, DTE/PS cleared), other bits kept. // None if too short or not the error-recovery page. fn build_error_recovery_select_payload(sense: &[u8]) -> Option> { + // SPC-4 §7.5.5: the mode parameter list is Mode Data Length + 2 bytes; the + // transfer count may over-report (ignored residue) and pad with zeros. + let mode_data_len = usize::from(u16::from_be_bytes([*sense.first()?, *sense.get(1)?])); + let sense = &sense[..sense.len().min(mode_data_len + 2)]; if sense.len() < MODE10_HEADER_LEN { return None; } @@ -1373,6 +1417,14 @@ pub(crate) fn decode_read_capacity(buf: &[u8; 8], bytes_transferred: usize) -> R last_lba.checked_add(1).ok_or(Error::DiscCapacityOverflow) } +// Consecutive MEDIUM NOT PRESENT (3Ah) TURs, with no 04/01 seen, after which +// wait_ready reports the empty drive (~5 s of polling). +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); + // 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<()> { @@ -1553,6 +1605,7 @@ mod command_tests { // With an 8-byte block descriptor between header and page, the function // must locate the page at header+desc, not a fixed offset. let mut sense = vec![0u8; MODE10_HEADER_LEN + 8 + 12]; + sense[1] = (MODE10_HEADER_LEN + 8 + 12 - 2) as u8; // mode data length sense[7] = 8; // block descriptor length let po = MODE10_HEADER_LEN + 8; sense[po] = MODE_PAGE_ERROR_RECOVERY; @@ -1580,6 +1633,21 @@ mod command_tests { assert!(build_error_recovery_select_payload(&bad).is_none()); } + // A bridge that ignores residue reports the whole 252-byte allocation; the + // SELECT parameter list must stop at Mode Data Length + 2 (SPC-4 §7.5.5), or + // the trailing zeros are sent as bogus page-0 descriptors and rejected. + #[test] + fn error_recovery_payload_stops_at_mode_data_length() { + let mut sense = mode_sense_error_recovery(0, 0x05, false); + sense[1] = (MODE10_HEADER_LEN + 12 - 2) as u8; + sense.resize(252, 0); + let out = build_error_recovery_select_payload(&sense).expect("valid page"); + assert_eq!(out.len(), MODE10_HEADER_LEN + 12, "parameter list length"); + // A Mode Data Length that cuts the page header short is malformed. + sense[1] = (MODE10_HEADER_LEN + 2 - 2) as u8; + assert!(build_error_recovery_select_payload(&sense).is_none()); + } + // Boundary for the 3-byte page header guard (page_off + 3 > sense.len()): // exactly-enough length must be accepted, not rejected by a `>=` mutation. #[test] @@ -1587,6 +1655,7 @@ mod command_tests { // No block descriptors: page starts right after the 8-byte header. // Page needs exactly 3 bytes (code, length, flags) -> total 11. let mut sense = vec![0u8; MODE10_HEADER_LEN + 3]; + sense[1] = (MODE10_HEADER_LEN + 3 - 2) as u8; // mode data length let po = MODE10_HEADER_LEN; sense[po] = MODE_PAGE_ERROR_RECOVERY; sense[po + 1] = 0x00; // page length field (unused by this function) @@ -2771,23 +2840,28 @@ mod command_tests { #[test] fn get_config_feature_strips_8_byte_header() { - // GET CONFIGURATION reply has an 8-byte Feature Header (MMC-6 - // §5.2.2). get_config_feature returns buf[8..end]. Provide a - // 12-byte reply → returns the 4 payload bytes. - let mut payload = vec![0u8; 8]; - payload.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]); + // GET CONFIGURATION reply: 8-byte Feature Header (MMC-6 §5.3.1), then the + // descriptor. Returns the descriptor only, bounded by its Additional + // Length even when the transport reports the whole zero-padded buffer. + let mut payload = vec![0, 0, 0, 12, 0, 0, 0, 0]; + payload.extend_from_slice(&[0x01, 0x0D, 0x00, 0x04, 0xDE, 0xAD, 0xBE, 0xEF]); + payload.resize(256, 0); let mut d = drive_with(payload); assert_eq!( d.get_config_feature(0x010D), - Some(vec![0xDE, 0xAD, 0xBE, 0xEF]) + Some(vec![0x01, 0x0D, 0x00, 0x04, 0xDE, 0xAD, 0xBE, 0xEF]) ); } #[test] - fn get_config_feature_at_exactly_8_bytes_returns_none() { - // end == 8 means header only, no descriptor → None (the `end > 8` - // guard). Boundary against an off-by-one that would return an - // empty Vec instead of None. + fn get_config_feature_absent_feature_returns_none() { + // RT=10b for an unsupported feature: header only (Data Length 4), even if + // the transport over-reports the transfer as the full 256 bytes. + let mut header_only = vec![0, 0, 0, 4, 0, 0, 0, 0]; + header_only.resize(256, 0); + let mut d = drive_with(header_only); + assert_eq!(d.get_config_feature(0x010D), None); + // Exactly 8 bytes transferred is also header-only. let mut d = drive_with(vec![0u8; 8]); assert_eq!(d.get_config_feature(0x0000), None); } @@ -2904,10 +2978,10 @@ mod command_tests { ); } - // A dead bus (transport failure) will never spin up, so wait_ready must - // surface it AT ONCE rather than poll a phantom for the full ~30 s. + // A dead bus (transport failures in a row) will never spin up, so wait_ready + // must surface it once the ~5 s budget is spent, not poll a phantom for ~30 s. #[test] - fn wait_ready_breaks_immediately_on_a_dead_bus() { + fn wait_ready_breaks_out_on_a_dead_bus() { struct DeadBus; impl ScsiTransport for DeadBus { fn execute( @@ -2932,8 +3006,341 @@ mod command_tests { "a dead bus must surface the transport failure, not DeviceNotReady: {r:?}" ); assert!( - t0.elapsed() < std::time::Duration::from_secs(5), - "a dead bus must break out at once, not run the ~30 s poll" + t0.elapsed() < std::time::Duration::from_secs(8), + "a dead bus must break out after the budget, not run the ~30 s poll" + ); + } + + // One bus hiccup (DID_TIME_OUT, then the Linux fd-reopen gap) during spin-up + // must not abort the poll: the next TUR on the recovered fd succeeds. + #[test] + fn wait_ready_rides_out_a_transient_transport_failure() { + struct Hiccup(usize); + impl ScsiTransport for Hiccup { + fn execute( + &mut self, + cdb: &[u8], + _dir: DataDirection, + _data: &mut [u8], + _timeout_ms: u32, + ) -> Result { + self.0 += 1; + match self.0 { + 1 => Err(Error::ScsiError { + opcode: cdb[0], + status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE, + sense: None, + }), + 2 => Err(Error::DeviceNotFound { + path: "/dev/sg0".into(), + }), + _ => Ok(ScsiResult { + status: 0, + bytes_transferred: 0, + sense: [0u8; 32], + }), + } + } + } + let mut d = Drive::from_transport_for_test(Box::new(Hiccup(0))); + let r = d.wait_ready(); + assert!( + r.is_ok(), + "a transient transport failure must not abort wait_ready: {r:?}" + ); + } + + // Fails with a transport error for the first `fails` TURs, each taking + // `delay`, then answers GOOD. + struct FlakyBus { + fails: usize, + delay: std::time::Duration, + } + impl ScsiTransport for FlakyBus { + fn execute( + &mut self, + cdb: &[u8], + _dir: DataDirection, + _data: &mut [u8], + _timeout_ms: u32, + ) -> Result { + std::thread::sleep(self.delay); + if self.fails == 0 { + return Ok(ScsiResult { + status: 0, + bytes_transferred: 0, + sense: [0u8; 32], + }); + } + self.fails -= 1; + Err(Error::ScsiError { + opcode: cdb[0], + status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE, + sense: None, + }) + } + } + + // The dead-bus budget is TIME, not a count: several quick transport + // failures within it (a bridge reset storm) must still ride through. + #[test] + fn wait_ready_tolerates_quick_transport_failures_within_the_budget() { + let mut d = Drive::from_transport_for_test(Box::new(FlakyBus { + fails: 4, + delay: std::time::Duration::ZERO, + })); + let r = d.wait_ready(); + assert!(r.is_ok(), "4 fast failures (~2 s) are within budget: {r:?}"); + } + + // TUR answers NOT READY with `sense` until a START STOP UNIT (START=1) + // arrives, then `after_start` more times, then GOOD. Counts the STARTs. + struct NeedsStart { + sense: crate::scsi::ScsiSense, + after_start: usize, + started: bool, + starts: Arc>>>, + } + impl ScsiTransport for NeedsStart { + fn execute( + &mut self, + cdb: &[u8], + _dir: DataDirection, + _data: &mut [u8], + _timeout_ms: u32, + ) -> Result { + let good = Ok(ScsiResult { + status: 0, + bytes_transferred: 0, + sense: [0u8; 32], + }); + if cdb[0] == SCSI_START_STOP_UNIT { + self.starts.lock().unwrap().push(cdb.to_vec()); + self.started = true; + return good; + } + if self.started && self.after_start == 0 { + return good; + } + if self.started { + self.after_start -= 1; + } + Err(Error::ScsiError { + opcode: cdb[0], + status: crate::scsi::SCSI_STATUS_CHECK_CONDITION, + sense: Some(self.sense), + }) + } + } + + fn needs_start(asc: u8, ascq: u8, after_start: usize) -> (Drive, Arc>>>) { + let starts = Arc::new(Mutex::new(Vec::new())); + let t = NeedsStart { + sense: crate::scsi::ScsiSense { + sense_key: crate::scsi::SENSE_KEY_NOT_READY, + asc, + ascq, + }, + after_start, + started: false, + starts: starts.clone(), + }; + (Drive::from_transport_for_test(Box::new(t)), starts) + } + + // 02/04/02 (initializing command required): nothing spins the unit up unless + // wait_ready sends START UNIT, once, never ejecting, then keeps polling. + #[test] + fn wait_ready_sends_one_start_unit_on_initializing_command_required() { + let (mut d, starts) = needs_start(0x04, 0x02, 2); + let r = d.wait_ready(); + assert!( + r.is_ok(), + "START UNIT must bring a stopped unit ready: {r:?}" + ); + let starts = starts.lock().unwrap(); + assert_eq!(starts.len(), 1, "exactly one START UNIT: {starts:?}"); + assert_eq!(starts[0][4], 0x01, "START=1, LoEj=0"); + } + + // 02/30/xx (incompatible or blank medium) never becomes ready: surface it at + // once with its sense, not DeviceNotReady after ~30 s of polling. + #[test] + fn wait_ready_fails_fast_on_incompatible_medium() { + let (mut d, starts) = needs_start(0x30, 0x00, usize::MAX); + let t0 = std::time::Instant::now(); + let r = d.wait_ready(); + assert!( + matches!(&r, Err(e) if e.scsi_sense().is_some_and(|s| s.asc == 0x30)), + "incompatible medium must surface its sense: {r:?}" + ); + assert!( + t0.elapsed() < std::time::Duration::from_secs(2), + "{:?}", + t0.elapsed() + ); + assert!(starts.lock().unwrap().is_empty(), "no START for 30h"); + } + + // Answers each TUR from a script of NOT READY (asc, ascq) pairs, then GOOD. + struct ScriptedNotReady( + std::collections::VecDeque<(u8, u8)>, + Arc, + ); + impl ScsiTransport for ScriptedNotReady { + fn execute( + &mut self, + cdb: &[u8], + _dir: DataDirection, + _data: &mut [u8], + _timeout_ms: u32, + ) -> Result { + self.1.fetch_add(1, Ordering::Relaxed); + let Some((asc, ascq)) = self.0.pop_front() else { + return Ok(ScsiResult { + status: 0, + bytes_transferred: 0, + sense: [0u8; 32], + }); + }; + Err(Error::ScsiError { + opcode: cdb[0], + status: crate::scsi::SCSI_STATUS_CHECK_CONDITION, + sense: Some(crate::scsi::ScsiSense { + sense_key: crate::scsi::SENSE_KEY_NOT_READY, + asc, + ascq, + }), + }) + } + } + + fn scripted(script: &[(u8, u8)]) -> (Drive, Arc) { + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let t = ScriptedNotReady(script.iter().copied().collect(), calls.clone()); + (Drive::from_transport_for_test(Box::new(t)), calls) + } + + // An empty drive (3Ah on every TUR, never 04/01) will not become ready: + // surface MEDIUM NOT PRESENT after a bounded run, not after ~30 s. + #[test] + fn wait_ready_fails_fast_on_an_empty_drive() { + let (mut d, calls) = scripted(&[(0x3A, 0x00); 100]); + let r = d.wait_ready(); + assert!( + matches!(&r, Err(e) if e.scsi_sense().is_some_and(|s| s.asc == 0x3A)), + "{r:?}" + ); + assert_eq!(calls.load(Ordering::Relaxed), 10, "bounded 3Ah run"); + } + + // Once the drive has said 04/01 (a disc is being identified), 3Ah is not + // final: keep polling rather than give up on the 3Ah run. + #[test] + fn wait_ready_keeps_polling_3a_after_becoming_ready() { + let mut script = vec![(0x04, 0x01)]; + script.extend([(0x3A, 0x00); 11]); + let (mut d, _calls) = scripted(&script); + let r = d.wait_ready(); + assert!(r.is_ok(), "{r:?}"); + } + + // One DID_TIME_OUT TUR eats its whole 5 s timeout; that single hiccup must + // not spend the dead-bus budget before the next TUR can succeed. + #[test] + fn wait_ready_rides_out_one_slow_transport_failure() { + struct SlowHiccup(bool); + impl ScsiTransport for SlowHiccup { + fn execute( + &mut self, + cdb: &[u8], + _dir: DataDirection, + _data: &mut [u8], + _timeout_ms: u32, + ) -> Result { + if std::mem::replace(&mut self.0, false) { + std::thread::sleep(std::time::Duration::from_millis(5_100)); + return Err(Error::ScsiError { + opcode: cdb[0], + status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE, + sense: None, + }); + } + Ok(ScsiResult { + status: 0, + bytes_transferred: 0, + sense: [0u8; 32], + }) + } + } + let mut d = Drive::from_transport_for_test(Box::new(SlowHiccup(true))); + let r = d.wait_ready(); + assert!(r.is_ok(), "one timed-out TUR is a hiccup: {r:?}"); + } + + // An unplugged drive (DeviceNotFound on every TUR, the Linux fd<0 state once + // the reopen fails) is a dead bus and must fail fast, not poll for ~30 s. + #[test] + fn wait_ready_fails_fast_on_an_unplugged_drive() { + struct Unplugged; + impl ScsiTransport for Unplugged { + fn execute( + &mut self, + _cdb: &[u8], + _dir: DataDirection, + _data: &mut [u8], + _timeout_ms: u32, + ) -> Result { + Err(Error::DeviceNotFound { + path: "/dev/sg0".into(), + }) + } + } + let mut d = Drive::from_transport_for_test(Box::new(Unplugged)); + let t0 = std::time::Instant::now(); + let r = d.wait_ready(); + assert!(matches!(r, Err(Error::DeviceNotFound { .. })), "{r:?}"); + assert!( + t0.elapsed() < std::time::Duration::from_secs(8), + "unplug must fail fast, took {:?}", + t0.elapsed() + ); + } + + // Slow failing TURs: the verdict comes when the run has lasted the 5 s budget + // past the first failure's completion. With 1.2 s failures + 0.5 s backoff + // that is exactly the 4th failure, so any count rule (2, 3, ...) shows up. + #[test] + fn wait_ready_bounds_slow_transport_failures_by_elapsed_time() { + struct SlowDeadBus(Arc); + impl ScsiTransport for SlowDeadBus { + fn execute( + &mut self, + cdb: &[u8], + _dir: DataDirection, + _data: &mut [u8], + _timeout_ms: u32, + ) -> Result { + self.0.fetch_add(1, Ordering::Relaxed); + std::thread::sleep(std::time::Duration::from_millis(1_200)); + Err(Error::ScsiError { + opcode: cdb[0], + status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE, + sense: None, + }) + } + } + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut d = Drive::from_transport_for_test(Box::new(SlowDeadBus(calls.clone()))); + let r = d.wait_ready(); + assert!( + matches!(&r, Err(e) if e.is_scsi_transport_failure()), + "{r:?}" + ); + assert_eq!( + calls.load(Ordering::Relaxed), + 4, + "gives up on the first failure completing >= 5 s after the first" ); } @@ -3194,6 +3601,33 @@ mod command_tests { assert_eq!(&c[2..4], &[0x12, 0x34], "read speed big-endian"); } + // Dropping a drive must not clear a tray lock it never took: find_drive opens + // and drops candidates, and another process may hold the PREVENT. + #[test] + fn drop_unlocks_the_tray_only_if_this_drive_locked_it() { + let seq = |f: &dyn Fn(&mut Drive)| { + let cdbs = Arc::new(Mutex::new(Vec::new())); + let mut d = Drive::from_transport_for_test(Box::new(SequenceTransport { + cdbs: cdbs.clone(), + ok: true, + })); + f(&mut d); + cdbs.lock().unwrap().clear(); + drop(d); + cdbs.lock().unwrap().clone() + }; + assert!(seq(&|_| {}).is_empty(), "never locked: Drop sends nothing"); + let after_lock = seq(&|d| d.lock_tray()); + assert_eq!(after_lock.len(), 1, "locked: Drop unlocks {after_lock:?}"); + assert_eq!(after_lock[0][0], SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL); + assert_eq!(after_lock[0][4], 0x00, "ALLOW"); + let unlocked = seq(&|d| { + d.lock_tray(); + d.unlock_tray(); + }); + assert!(unlocked.is_empty(), "already unlocked: {unlocked:?}"); + } + #[test] fn lock_tray_sends_prevent_with_removal_bit_set() { let RecordingHarness { @@ -3220,29 +3654,37 @@ mod command_tests { assert_eq!(c[4], 0x00, "PREVENT bit clear (unlocked)"); } - // SET CD SPEED and PREVENT/ALLOW MEDIUM REMOVAL are best-effort tray/speed - // control: a drive that REJECTS them must be warned about, never fail the - // rip. Each returns () on a transport error and still issues its CDB. + // SET CD SPEED and PREVENT/ALLOW MEDIUM REMOVAL are best-effort: a CHECK + // CONDITION or a transport failure still issues the CDB, never fails the + // rip, and a failed unlock is warned about (a stuck PREVENT is a real symptom). #[test] - fn set_speed_and_tray_control_swallow_a_drive_rejection() { - let RecordingHarness { - drive: mut d, - cdb, - timeouts: _to, - } = recording(TransportOutcome::Scsi(0x02, None)); - // None of these may panic or propagate the ScsiError. - d.set_speed(0x1234); - assert_eq!( - cdb.lock().unwrap()[0], - crate::scsi::SCSI_SET_CD_SPEED, - "SET CD SPEED still issued despite the rejection" - ); - d.lock_tray(); - assert_eq!(cdb.lock().unwrap()[0], SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL); - assert_eq!(cdb.lock().unwrap()[4], 0x01, "PREVENT bit still set"); - d.unlock_tray(); - assert_eq!(cdb.lock().unwrap()[0], SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL); - assert_eq!(cdb.lock().unwrap()[4], 0x00, "ALLOW bit still clear"); + fn set_speed_and_tray_control_swallow_rejection_and_transport_failure() { + for status in [0x02, crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE] { + let RecordingHarness { + drive: mut d, + cdb, + timeouts: _to, + } = recording(TransportOutcome::Scsi(status, None)); + d.set_speed(0x1234); + assert_eq!( + cdb.lock().unwrap()[0], + crate::scsi::SCSI_SET_CD_SPEED, + "SET CD SPEED still issued despite status {status:#x}" + ); + d.lock_tray(); + assert_eq!(cdb.lock().unwrap()[0], SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL); + assert_eq!(cdb.lock().unwrap()[4], 0x01, "PREVENT bit still set"); + let ((), events) = crate::testlog::capture(|| d.unlock_tray()); + assert_eq!(cdb.lock().unwrap()[0], SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL); + assert_eq!(cdb.lock().unwrap()[4], 0x00, "ALLOW bit still clear"); + let warned = events.iter().any(|e| { + e.level == tracing::Level::WARN && e.field("phase") == Some("unlock_tray") + }); + assert!( + warned, + "failed unlock must warn (status {status:#x}): {events:?}" + ); + } } #[test] diff --git a/src/drive/windows.rs b/src/drive/windows.rs index aa9ee526..bd1036f0 100644 --- a/src/drive/windows.rs +++ b/src/drive/windows.rs @@ -5,10 +5,6 @@ use crate::error::Result; use crate::identity::DriveId; use std::path::Path; -/// SCSI peripheral device type 5 = MMC / optical, in the low 5 bits of -/// INQUIRY byte 0. -const SCSI_PERIPHERAL_TYPE_OPTICAL: u8 = 0x05; - /// Discover optical drives. Probes `\\.\CdRom0..15` first; only if none /// are found does it fall back to scanning drive letters `D..Z`. Each /// candidate is opened, INQUIRY'd, and kept only if its peripheral device @@ -19,14 +15,11 @@ pub fn find_drives() -> Vec<(String, DriveId)> { // Try CdRom0..CdRom15 for i in 0..16 { let path = format!("\\\\.\\CdRom{}", i); - if let Ok(mut transport) = crate::scsi::open(Path::new(&path)) { - if let Ok(id) = DriveId::from_drive(transport.as_mut()) { - if !id.raw_inquiry.is_empty() - && (id.raw_inquiry[0] & 0x1F) == SCSI_PERIPHERAL_TYPE_OPTICAL - { - drives.push((path, id)); - } - } + if let Ok(mut transport) = crate::scsi::open(Path::new(&path)) + && let Ok(id) = DriveId::from_drive(transport.as_mut()) + && id.is_optical() + { + drives.push((path, id)); } } @@ -34,16 +27,13 @@ pub fn find_drives() -> Vec<(String, DriveId)> { if drives.is_empty() { for letter in b'D'..=b'Z' { let path = format!("{}:", letter as char); - if let Ok(mut transport) = crate::scsi::open(Path::new(&path)) { - if let Ok(id) = DriveId::from_drive(transport.as_mut()) { - if !id.raw_inquiry.is_empty() - && (id.raw_inquiry[0] & 0x1F) == SCSI_PERIPHERAL_TYPE_OPTICAL - { - // Normalize so returned paths are consistently in - // \\.\ form regardless of which loop matched. - drives.push((normalize_path(&path), id)); - } - } + if let Ok(mut transport) = crate::scsi::open(Path::new(&path)) + && let Ok(id) = DriveId::from_drive(transport.as_mut()) + && id.is_optical() + { + // Normalize so returned paths are consistently in + // \\.\ form regardless of which loop matched. + drives.push((normalize_path(&path), id)); } } } @@ -51,6 +41,11 @@ pub fn find_drives() -> Vec<(String, DriveId)> { drives } +/// Candidate drive paths for `find_drive`. +pub(crate) fn candidate_paths() -> Vec { + find_drives().into_iter().map(|(path, _)| path).collect() +} + /// Resolve a device path to its normalized Windows `\\.\` form. Windows /// has no `sr`→`sg` symlink-target indirection, so resolution is purely a /// path normalization and always reports [`DeviceResolution::Direct`]. diff --git a/src/error.rs b/src/error.rs index 97c1a05c..63ea118c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1144,7 +1144,7 @@ impl std::fmt::Display for Error { // pair) — surface them so a truncated-resume report carries the // actual mismatch, not just the bare code. Error::ImageTruncated { have, want } => { - write!(f, "E{} {have}/{want}", self.code()) + write!(f, "E{}: {have}/{want}", self.code()) } Error::ImageEndsBeforeRead { lba, have, want } => { write!(f, "E{}: {lba} {have}/{want}", self.code()) @@ -1728,6 +1728,8 @@ mod tests { s.contains("67890"), "want value missing from ImageTruncated display `{s}`" ); + // The documented `E: ` shape the app layer splits on. + assert_eq!(s, format!("E{}: 12345/67890", E_IMAGE_TRUNCATED)); } #[test] diff --git a/src/identity.rs b/src/identity.rs index a22dd7be..41fe9eef 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -8,7 +8,7 @@ //! MMC-6 §5.3.10 — Feature 010Ch (Firmware Information) use crate::error::Result; -use crate::scsi::{DataDirection, ScsiTransport}; +use crate::scsi::{DataDirection, ScsiTransport, gc_feature_descriptor, gc_reply_len}; /// Drive identity from standard SCSI commands. /// @@ -69,25 +69,18 @@ impl DriveId { // Never decode past what the drive actually sent. inquiry.truncate(inq.bytes_transferred.min(inquiry.len())); - // GET CONFIGURATION Feature 010Ch — MMC-6 §6.6. Best-effort: an optional - // feature a drive may lack (CHECK CONDITION), so failure here is - // feature-absent (empty firmware date + raw bytes), not a probe abort. + // GET CONFIGURATION Feature 010Ch — MMC-6 §6.6. Best-effort: a drive may + // lack it, so failure is feature-absent, not a probe abort. Fields are + // bounded by the reply's own Data/Additional Length, not the transfer count. let mut gc = vec![0u8; 256]; let cdb_gc = [0x46, 0x02, 0x01, 0x0C, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00]; - // `bytes_transferred` is device-reported and untrusted; clamp every - // slice end to the actual buffer length before indexing. let (firmware_date, raw_gc_010c) = match transport.execute(&cdb_gc, DataDirection::FromDevice, &mut gc, 5000) { - Ok(result) => { - let end = result.bytes_transferred.min(gc.len()); - let date = if end > 12 { - String::from_utf8_lossy(&gc[12..24.min(end)]) - .trim() - .to_string() - } else { - String::new() - }; - (date, gc[..end].to_vec()) + 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(_) => (String::new(), Vec::new()), }; @@ -97,23 +90,13 @@ 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 = if let Ok(r) = - transport.execute(&cdb_serial, DataDirection::FromDevice, &mut gc_serial, 5000) - { - if r.bytes_transferred > 12 { - // `bytes_transferred` is device-reported and untrusted; clamp - // the slice end to the buffer length to avoid an out-of-range - // panic on an oversized reported count. - let end = r.bytes_transferred.min(gc_serial.len()); - String::from_utf8_lossy(&gc_serial[12..end]) - .trim() - .to_string() - } else { - String::new() - } - } else { - String::new() - }; + 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(), + }; Ok(DriveId { vendor_id: ascii_field(&inquiry, 8, 16), @@ -142,6 +125,11 @@ impl DriveId { } } + /// True if this is an MMC optical drive (INQUIRY peripheral device type 05h). + pub(crate) fn is_optical(&self) -> bool { + crate::scsi::is_optical_peripheral(&self.raw_inquiry) + } + /// Profile match key: "VENDOR|PRODUCT|REVISION|VENDOR_SPECIFIC" /// /// Used to look up this drive in the profile database. @@ -170,6 +158,13 @@ impl std::fmt::Display for DriveId { } } +// A GET CONFIGURATION text field: trims spaces and the NUL padding `trim` keeps. +fn gc_text(field: &[u8]) -> String { + String::from_utf8_lossy(field) + .trim_matches(|c: char| c.is_whitespace() || c == '\0') + .to_string() +} + /// Extract an ASCII string field from raw SCSI data. fn ascii_field(data: &[u8], start: usize, end: usize) -> String { if data.len() > start { @@ -225,6 +220,22 @@ mod tests { assert_eq!(id.raw_gc_010c.len(), 256); } + // INQUIRY byte 0: low 5 bits are the peripheral device type (5 = MMC), the + // high 3 the qualifier. The shared optical filter behind every find_drive(s). + #[test] + fn is_optical_reads_the_peripheral_device_type() { + let with_byte0 = |b0: u8| DriveId::from_inquiry(&[b0; 36], ""); + assert!(with_byte0(0x05).is_optical()); + assert!(with_byte0(0x25).is_optical(), "qualifier bits masked off"); + assert!(!with_byte0(0x00).is_optical(), "direct-access disk"); + assert!(!with_byte0(0x01).is_optical(), "tape"); + assert!(!with_byte0(0x15).is_optical(), "type 0x15, not 0x05"); + assert!( + !DriveId::from_inquiry(&[], "").is_optical(), + "no INQUIRY data" + ); + } + #[test] fn test_bu40n_identity() { let mut inquiry = vec![0u8; 96]; @@ -425,6 +436,16 @@ mod tests { for b in buf.iter_mut() { *b = b'Z'; } + // Well-formed GC header + descriptor for the requested feature, so + // only the transfer count bounds the decoded field. + if cdb.first() == Some(&0x46) && buf.len() >= 12 { + let dl = (buf.len() - 4) as u32; + buf[0..4].copy_from_slice(&dl.to_be_bytes()); + buf[4..8].fill(0); + buf[8..10].copy_from_slice(&cdb[2..4]); + buf[10] = 0; + buf[11] = u8::try_from(buf.len() - 12).unwrap_or(u8::MAX); + } let bytes_transferred = match cdb.first() { Some(&0x12) => buf.len(), Some(&0x46) if cdb[3] == 0x0C => self.firmware_bytes, @@ -487,6 +508,84 @@ mod tests { assert_eq!(id.serial_number, "Z"); } + // A GET CONFIGURATION (RT=10b) reply for `feature`: 8-byte header whose Data + // Length covers exactly the descriptor, then the 4-byte feature header. + fn gc_reply(feature: u16, payload: &[u8]) -> Vec { + let mut v = vec![0u8; 12]; + v[0..4].copy_from_slice(&((8 + payload.len()) as u32).to_be_bytes()); + v[8..10].copy_from_slice(&feature.to_be_bytes()); + v[11] = payload.len() as u8; + v.extend_from_slice(payload); + v + } + + // Answers INQUIRY honestly and each GET CONFIGURATION with a scripted reply, + // reporting the WHOLE buffer as transferred (resid unreported by the LLD). + struct GcReplyTransport { + firmware: Vec, + serial: Vec, + } + + impl ScsiTransport for GcReplyTransport { + fn execute( + &mut self, + cdb: &[u8], + _dir: DataDirection, + buf: &mut [u8], + _timeout_ms: u32, + ) -> Result { + buf.fill(0); + let reply: &[u8] = match (cdb[0], cdb[3]) { + (0x46, 0x0C) => &self.firmware, + (0x46, 0x08) => &self.serial, + _ => &[], + }; + let n = reply.len().min(buf.len()); + buf[..n].copy_from_slice(&reply[..n]); + Ok(ScsiResult { + status: 0, + bytes_transferred: buf.len(), + sense: [0u8; 32], + }) + } + } + + // MMC-6 §5.3: the field ends at the feature's Additional Length / the header + // Data Length, not at a transfer count the transport may over-report. + #[test] + fn from_drive_honours_gc_data_length_and_additional_length() { + let mut t = GcReplyTransport { + firmware: gc_reply(0x010C, b"201604250000\0\0\0\0"), + serial: gc_reply(0x0108, b"ABCD1234"), + }; + let id = DriveId::from_drive(&mut t).unwrap(); + assert_eq!(id.serial_number, "ABCD1234"); + assert_eq!(id.firmware_date, "201604250000"); + assert_eq!(id.raw_gc_010c.len(), 28, "header + 20-byte descriptor"); + } + + // A drive lacking the feature answers RT=10b with only the 8-byte header. + #[test] + fn from_drive_absent_or_foreign_gc_feature_yields_empty_fields() { + let header_only = vec![0, 0, 0, 4, 0, 0, 0, 0]; + let mut t = GcReplyTransport { + firmware: header_only.clone(), + serial: header_only, + }; + let id = DriveId::from_drive(&mut t).unwrap(); + assert_eq!(id.serial_number, "", "absent 0108h is no serial"); + assert_eq!(id.firmware_date, "", "absent 010Ch is no date"); + + // A descriptor for some OTHER feature is not the one asked for. + let mut t = GcReplyTransport { + firmware: gc_reply(0x0001, b"201604250000"), + serial: gc_reply(0x0001, b"ABCD1234"), + }; + let id = DriveId::from_drive(&mut t).unwrap(); + assert_eq!(id.serial_number, ""); + assert_eq!(id.firmware_date, ""); + } + #[test] fn from_drive_gc_failure_yields_empty_firmware_date() { struct GcFailTransport; diff --git a/src/lib.rs b/src/lib.rs index dc9a2634..9b311022 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -238,7 +238,10 @@ pub use mux::resolve_mux_key_map; pub use mux::select::{PidFilter, StreamSelection}; #[cfg(feature = "rip")] pub use mux::{MuxEvents, MuxInput, MuxOptions, MuxOutcome, mux_stream}; -pub use scsi::{DriveInfo, ScsiSense, ScsiTransport, SenseFamily, drive_has_disc, list_drives}; +pub use scsi::{ + DiscPresence, DriveInfo, ScsiSense, ScsiTransport, SenseFamily, disc_presence, drive_has_disc, + list_drives, +}; #[cfg(feature = "rip")] pub use sector::{ DecryptingSectorSource, FileSectorSource, KeyFetch, PrefetchedSectorSource, SectorSource, diff --git a/src/mux/codec/lpcm.rs b/src/mux/codec/lpcm.rs index 8e28f340..7eb58975 100644 --- a/src/mux/codec/lpcm.rs +++ b/src/mux/codec/lpcm.rs @@ -1,28 +1,204 @@ //! BD/DVD LPCM (Linear PCM) audio parser. //! -//! BD-TS LPCM PES payloads carry a 4-byte header that this parser strips; -//! DVD-PS LPCM payloads already had their private sub-header removed by -//! `PsDemuxer`, so nothing further is stripped here. See `strip_header`. -//! -//! Both origins map to MKV codec ID "A_PCM/INT/BIG" (big-endian); all frames are keyframes. +//! Both origins carry a per-PES audio header this parser consumes: 4 bytes on BD-TS, +//! 3 on DVD-PS (`PsDemuxer` strips only the sub_id/frames/pointer bytes). Output is +//! always interleaved 24-bit big-endian PCM ([`OUTPUT_BIT_DEPTH`](crate::mux::codec::lpcm::OUTPUT_BIT_DEPTH)) in +//! WAVE_FORMAT_EXTENSIBLE channel order, so "A_PCM/INT/BIG" gets a fixed BitDepth that +//! survives every hop. Layouts follow ffmpeg pcm-bluray.c (pad channel, LFE/surround +//! remap) and pcm-dvd.c (20/24-bit sample groups and blocks). use super::{CodecParser, Frame, PesPacket, pts_to_ns}; -/// BD LPCM header size in bytes (present on BD-TS LPCM, absent on DVD-PS LPCM -/// because `PsDemuxer` already stripped the private sub-header). +/// Bit depth of every sample this parser emits (16-bit sources are widened losslessly). +pub const OUTPUT_BIT_DEPTH: u8 = 24; + +/// BD LPCM header: payload_size(2), channel_assignment|rate, bits|start_flag. const BD_LPCM_HEADER_SIZE: usize = 4; +/// DVD LPCM audio header: emphasis|frame#, quant|rate|channels-1, dynamic range. +const DVD_LPCM_HEADER_SIZE: usize = 3; + +/// BD channel_assignment -> (channels, destination index of each source channel). +fn bd_layout(assign: u8) -> Option<(usize, &'static [usize])> { + const ID: [usize; 5] = [0, 1, 2, 3, 4]; + Some(match assign { + 1 => (1, &ID[..1]), + 3 => (2, &ID[..2]), + 4 | 5 => (3, &ID[..3]), + 6 | 7 => (4, &ID[..4]), + 8 => (5, &ID[..5]), + // L R C Ls Rs LFE -> L R C LFE Ls Rs + 9 => (6, &[0, 1, 2, 4, 5, 3]), + // L R C Ls Lrs Rrs Rs -> L R C Lrs Rrs Ls Rs + 10 => (7, &[0, 1, 2, 5, 3, 4, 6]), + // L R C Ls Lrs Rrs Rs LFE -> L R C LFE Lrs Rrs Ls Rs + 11 => (8, &[0, 1, 2, 6, 4, 5, 7, 3]), + _ => return None, + }) +} + +fn bd_rate(code: u8) -> Option { + match code { + 1 => Some(48_000), + 4 => Some(96_000), + 5 => Some(192_000), + _ => None, + } +} + +/// DVD 20/24-bit block (ffmpeg pcm-dvd.c): (groups, samples per group, sample frames). +/// A group is `g` MSB16 words then their low bits; a block is whole sample frames. +fn dvd_block(channels: usize) -> (usize, usize, usize) { + let g = if channels == 1 { 2 } else { 4 }; + let frames = match channels { + 1 => 4, + 2 => 2, + 4 | 8 => 1, + _ => 4, + }; + (frames * channels / g, g, frames) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Format { + /// BD: coded channels padded to even; `bytes` per sample (2 = 16-bit, 3 = 24-bit). + Bd { + channels: usize, + map: &'static [usize], + bytes: usize, + rate: u32, + }, + /// DVD: `bits` 16/20/24. + Dvd { + channels: usize, + bits: u8, + rate: u32, + }, +} + +impl Format { + fn bd(h: &[u8]) -> Option { + let (channels, map) = bd_layout(h[2] >> 4)?; + let rate = bd_rate(h[2] & 0x0F)?; + // 20-bit (code 2) has no verified packing; ffmpeg rejects it, so do we. + let bytes = match h[3] >> 6 { + 1 => 2, + 3 => 3, + _ => return None, + }; + Some(Format::Bd { + channels, + map, + bytes, + rate, + }) + } + + fn dvd(h: &[u8]) -> Option { + let bits = match (h[1] >> 6) & 3 { + 0 => 16, + 1 => 20, + 2 => 24, + _ => return None, + }; + let rate = [48_000, 96_000, 44_100, 32_000][usize::from((h[1] >> 4) & 3)]; + let channels = 1 + usize::from(h[1] & 7); + Some(Format::Dvd { + channels, + bits, + rate, + }) + } + + /// (input bytes, sample frames) of one whole conversion unit. + fn unit(self) -> (usize, usize) { + match self { + Format::Bd { + channels, bytes, .. + } => ((channels + (channels & 1)) * bytes, 1), + Format::Dvd { + channels: c, + bits: 16, + .. + } => (c * 2, 1), + Format::Dvd { channels, bits, .. } => { + let (groups, g, frames) = dvd_block(channels); + let low = if bits == 24 { g } else { g / 2 }; + (groups * (g * 2 + low), frames) + } + } + } + + fn channels_rate(self) -> (usize, u32) { + match self { + Format::Bd { channels, rate, .. } | Format::Dvd { channels, rate, .. } => { + (channels, rate) + } + } + } + + /// Convert one whole unit to 24-bit output PCM. + fn convert(self, u: &[u8], out: &mut Vec) { + match self { + Format::Bd { + channels, + map, + bytes, + .. + } => { + let base = out.len(); + out.resize(base + channels * 3, 0); + for (src, &dst) in map.iter().enumerate() { + let (s, d) = (src * bytes, base + dst * 3); + out[d..d + bytes].copy_from_slice(&u[s..s + bytes]); + } + } + Format::Dvd { bits: 16, .. } => { + for w in u.as_chunks::<2>().0 { + out.extend_from_slice(&[w[0], w[1], 0]); + } + } + Format::Dvd { channels, bits, .. } => { + let (_, g, _) = dvd_block(channels); + let size = g * 2 + if bits == 24 { g } else { g / 2 }; + for grp in u.chunks_exact(size) { + let lo = &grp[g * 2..]; + for k in 0..g { + let low = if bits == 24 { + lo[k] + } else if k % 2 == 0 { + lo[k / 2] & 0xF0 + } else { + lo[k / 2] << 4 + }; + out.extend_from_slice(&[grp[k * 2], grp[k * 2 + 1], low]); + } + } + } + } + } +} + +fn samples_to_ns(samples: u64, rate: u32) -> i64 { + if rate == 0 { + return 0; + } + i64::try_from(u128::from(samples) * 1_000_000_000 / u128::from(rate)).unwrap_or(i64::MAX) +} pub struct LpcmParser { - /// Whether to strip the 4-byte BD LPCM header from each PES payload. - /// - /// `true` for BD-TS LPCM (header still present), `false` for DVD-PS LPCM - /// (header already removed by `PsDemuxer`). - strip_header: bool, - /// Last emitted PTS (ns). A PES with no PTS (legal for audio, e.g. a - /// post-discontinuity continuation) carries this forward rather than - /// resetting the timeline to 0 — matching the ADTS/FLAC/MPEG-audio parsers - /// and preserving A/V sync. - last_pts_ns: i64, + /// `true` for BD-TS (4-byte header), `false` for DVD-PS (3-byte header). + bd: bool, + /// Format of the previous PES; a change drops `carry`. + format: Option, + /// Trailing partial unit (DVD packs may split a sample block across PES). + carry: Vec, + /// Timeline anchor (ns) and sample frames emitted since it. A PES with no PTS + /// is stamped at the anchor plus the emitted duration — never 0 or a duplicate. + anchor_ns: i64, + samples_since_anchor: u64, + /// Last BD header byte 2 (channel_assignment|rate), reported as codec_private so + /// an M2TS re-mux keeps the exact layout (e.g. 2/2 vs 3/1). + bd_layout_byte: Option, } impl Default for LpcmParser { @@ -32,57 +208,221 @@ impl Default for LpcmParser { } impl LpcmParser { - /// BD-TS LPCM parser: strips the 4-byte BD LPCM header from each PES. - pub fn new() -> Self { + fn with(bd: bool) -> Self { Self { - strip_header: true, - last_pts_ns: 0, + bd, + format: None, + carry: Vec::new(), + anchor_ns: 0, + samples_since_anchor: 0, + bd_layout_byte: None, } } - /// DVD-PS LPCM parser: `PsDemuxer` already stripped the private sub-header, - /// so the payload is raw PCM and no further bytes are removed. + /// BD-TS LPCM parser (4-byte BD LPCM header per PES). + pub fn new() -> Self { + Self::with(true) + } + + /// DVD-PS LPCM parser (3-byte DVD audio header per PES). pub fn new_dvd() -> Self { - Self { - strip_header: false, - last_pts_ns: 0, - } + Self::with(false) + } + + fn predicted_pts(&self) -> i64 { + let rate = self.format.map_or(0, |f| f.channels_rate().1); + self.anchor_ns + .saturating_add(samples_to_ns(self.samples_since_anchor, rate)) } } impl CodecParser for LpcmParser { fn parse(&mut self, pes: &PesPacket) -> Vec { - let offset = if self.strip_header { + let hdr = if self.bd { BD_LPCM_HEADER_SIZE } else { - 0 + DVD_LPCM_HEADER_SIZE }; - // If the PES is too short to contain header + data, return nothing. - if pes.data.len() <= offset { + if pes.data.len() <= hdr { + return Vec::new(); + } + let parsed = if self.bd { + Format::bd(&pes.data) + } else { + Format::dvd(&pes.data) + }; + // Reserved header codes: drop the packet, as ffmpeg does (INVALIDDATA). + let Some(format) = parsed else { + return Vec::new(); + }; + if self.bd { + self.bd_layout_byte = Some(pes.data[2]); + } + let predicted = self.predicted_pts(); + if Some(format) != self.format || pes.discontinuity { + self.carry.clear(); + } + self.format = Some(format); + let (unit, unit_frames) = format.unit(); + let (channels, rate) = format.channels_rate(); + // PTS belongs to the first unit STARTING here, so a carried unit starts one earlier. + // DVD first_access_unit_pointer is ignored (as ffmpeg/VLC): <= 1 audio frame late. + let lead = if self.carry.is_empty() { + 0 + } else { + unit_frames + }; + self.anchor_ns = match pes.pts.or(pes.dts).map(pts_to_ns) { + Some(p) => p.saturating_sub(samples_to_ns(lead as u64, rate)), + None => predicted, + }; + self.samples_since_anchor = 0; + let pts_ns = self.anchor_ns; + + self.carry.extend_from_slice(&pes.data[hdr..]); + let whole = self.carry.len() - self.carry.len() % unit; + let mut data = Vec::with_capacity(whole / unit * unit_frames * channels * 3); + for u in self.carry[..whole].chunks_exact(unit) { + format.convert(u, &mut data); + } + self.carry.drain(..whole); + // BD PES hold whole sample frames (ffmpeg drops a remainder); only DVD + // blocks legitimately straddle PES. + if self.bd { + self.carry.clear(); + } + self.samples_since_anchor = (data.len() / (channels * 3)) as u64; + if data.is_empty() { return Vec::new(); } - let pts_ns = pes - .pts - .or(pes.dts) - .map(pts_to_ns) - .unwrap_or(self.last_pts_ns); - self.last_pts_ns = pts_ns; vec![Frame { discontinuity: pes.discontinuity, coding: None, source: super::pesbuf::PesFacts::of(pes).source, pts_ns, keyframe: true, - data: pes.data[offset..].to_vec(), + data, duration_ns: None, }] } fn codec_private(&self) -> Option> { - None + self.bd_layout_byte + .map(|b| [LAYOUT_TAG.as_slice(), &[b]].concat()) } } +/// Tag on the parser's codec_private, so a foreign MKV `A_PCM` CodecPrivate never +/// passes for a BD layout byte. No released build wrote an untagged byte; any +/// untagged value falls back to the count-default layout. +const LAYOUT_TAG: &[u8; 4] = b"BDLP"; + +/// Tagged codec_private for BD layout byte `b`. +pub(crate) fn tagged_layout(b: u8) -> Vec { + [LAYOUT_TAG.as_slice(), &[b]].concat() +} + +/// The BD layout byte (channel_assignment|rate) from a tagged LPCM codec_private. +pub(crate) fn layout_byte(cp: &[u8]) -> Option { + match cp.strip_prefix(LAYOUT_TAG.as_slice()) { + Some(&[b]) => Some(b), + _ => None, + } +} + +/// Correct each BD LPCM track's channels/rate (and a default label) from its parser +/// layout byte: playlists label every multi-channel LPCM "5.1". +pub(crate) fn correct_title_layout(title: &mut crate::disc::DiscTitle) { + use crate::disc::{AudioChannels, SampleRate, Stream}; + for (i, s) in title.streams.iter_mut().enumerate() { + let Stream::Audio(a) = s else { continue }; + if a.codec != crate::disc::Codec::Lpcm { + continue; + } + let Some(b) = title + .codec_privates + .get(i) + .and_then(|c| c.as_deref()) + .and_then(layout_byte) + else { + continue; + }; + let (Some((count, _)), Some(hz)) = (bd_layout(b >> 4), bd_rate(b & 0x0F)) else { + continue; + }; + // AudioChannels has no 3.0 / 7.0 variant: 3ch (3/0, 2/1) reads "2.1", 7ch "6.1". + let basic = crate::labels::generate_audio_label(&a.codec, &a.channels, a.secondary); + a.channels = AudioChannels::from_count(count as u8); + a.sample_rate = SampleRate::from_hz(hz); + if a.label == basic { + a.label = crate::labels::generate_audio_label(&a.codec, &a.channels, a.secondary); + } + } +} + +/// BD LPCM header bytes 2-3 for re-muxing parser output (24-bit) to M2TS, or `None` +/// when BD LPCM can't carry it. Reuses the source layout byte (the parser's +/// codec_private) when it agrees; else the ffmpeg pcm-blurayenc.c default for the count. +pub(crate) fn bd_header(channels: u8, rate_hz: u32, source: Option) -> Option<[u8; 2]> { + if let Some(b) = source + && bd_layout(b >> 4).map(|(c, _)| c) == Some(usize::from(channels)) + && bd_rate(b & 0x0F) == Some(rate_hz) + { + return Some([b, 3 << 6]); + } + let assign = match channels { + 1 => 1, + 2 => 3, + 3 => 4, + 4 => 6, + 5 => 8, + 6 => 9, + 7 => 10, + 8 => 11, + _ => return None, + }; + let rate = match rate_hz { + 48_000 => 1, + 96_000 => 4, + 192_000 => 5, + _ => return None, + }; + Some([(assign << 4) | rate, 3 << 6]) +} + +/// Re-pack 24-bit WAVE-order PCM as BD LPCM PES payloads (header + BD order + pad +/// channel) of 5 ms each (rate/200 samples), as BD authoring does. Returns +/// `(ns offset of the payload, payload)`; a trailing partial frame is dropped. +pub(crate) fn bd_payloads(pcm: &[u8], header: [u8; 2]) -> Vec<(i64, Vec)> { + let (Some((channels, map)), Some(rate)) = + (bd_layout(header[0] >> 4), bd_rate(header[0] & 0x0F)) + else { + return Vec::new(); + }; + let coded = (channels + (channels & 1)) * 3; + let per_pes = rate as usize / 200; + let mut out = Vec::new(); + for (n, chunk) in pcm.chunks(per_pes * channels * 3).enumerate() { + let frames = chunk.len() / (channels * 3); + if frames == 0 { + continue; + } + let size = (frames * coded) as u16; + let mut p = Vec::with_capacity(BD_LPCM_HEADER_SIZE + frames * coded); + p.extend_from_slice(&size.to_be_bytes()); + p.extend_from_slice(&header); + for f in chunk.chunks_exact(channels * 3) { + let base = p.len(); + p.resize(base + coded, 0); + for (src, &dst) in map.iter().enumerate() { + p[base + src * 3..base + src * 3 + 3].copy_from_slice(&f[dst * 3..dst * 3 + 3]); + } + } + out.push((samples_to_ns((n * per_pes) as u64, rate), p)); + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -99,221 +439,400 @@ mod tests { } } - #[test] - fn header_skip_extracts_pcm_data() { - let mut parser = LpcmParser::new(); - // 4-byte LPCM header + 6 bytes of PCM data - let header = vec![0x00, 0x01, 0x00, 0b1001_0001]; // frame#=1, quant=24bit, rate=48k, ch=1 - let pcm_data = vec![0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE]; - let mut pes_data = header; - pes_data.extend_from_slice(&pcm_data); + /// BD PES: payload_size(2), channel_assignment<<4 | 48 kHz, bits_code<<6. + fn bd(ch_assign: u8, bits_code: u8, pcm: &[u8]) -> Vec { + let mut v = vec![0x00, 0x00, (ch_assign << 4) | 0x01, bits_code << 6]; + v.extend_from_slice(pcm); + v + } - let pes = make_pes(pes_data, Some(90000)); - let frames = parser.parse(&pes); + /// DVD PES: emphasis/frame#, quant|freq|channels-1, dynamic range. + fn dvd(quant_freq_ch: u8, pcm: &[u8]) -> Vec { + let mut v = vec![0x00, quant_freq_ch, 0x80]; + v.extend_from_slice(pcm); + v + } - assert_eq!(frames.len(), 1); - assert_eq!(frames[0].data, pcm_data); - assert_eq!(frames[0].pts_ns, 1_000_000_000); // 90000 ticks = 1 second + /// 16-bit BE samples widened to the parser's 24-bit output. + fn w24(s16: &[u8]) -> Vec { + s16.chunks(2).flat_map(|c| [c[0], c[1], 0]).collect() + } + + /// One 16-bit sample per channel, each channel's bytes = its index. + fn frame16(order: &[u8]) -> Vec { + order.iter().flat_map(|&c| [c, c]).collect() + } + + fn all(frames: &[Frame]) -> Vec { + frames.iter().flat_map(|f| f.data.clone()).collect() } #[test] - fn bd_lpcm_strips_4_byte_header() { - // BD-TS LPCM: the 4-byte BD header is part of the ES payload and must - // be stripped, leaving exactly the PCM bytes. - let mut parser = LpcmParser::new(); - let header = vec![0x00, 0x01, 0x00, 0b1001_0001]; - let pcm = vec![0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88]; - let mut data = header; - data.extend_from_slice(&pcm); - - let frames = parser.parse(&make_pes(data, Some(0))); - assert_eq!(frames.len(), 1); - assert_eq!(frames[0].data, pcm, "BD must strip exactly 4 header bytes"); + fn bd_stereo_16bit_strips_header_and_widens_to_24() { + let mut p = LpcmParser::new(); + let pcm = [0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0x01, 0x02]; + let f = p.parse(&make_pes(bd(3, 1, &pcm), Some(90_000))); + assert_eq!(f.len(), 1); + assert_eq!(f[0].data, w24(&pcm)); + assert_eq!(f[0].pts_ns, 1_000_000_000); + assert!(f[0].keyframe); } #[test] - fn dvd_lpcm_preserves_all_pcm_bytes() { - // DVD-PS LPCM: PsDemuxer already removed the 7-byte private sub-header, so - // the payload here is raw PCM. Must NOT strip further — applying the BD - // 4-byte strip would drop one sample pair per PES and drift the audio. - let mut parser = LpcmParser::new_dvd(); - let pcm = vec![0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0x01, 0x02]; - let frames = parser.parse(&make_pes(pcm.clone(), Some(90000))); - - assert_eq!(frames.len(), 1); - assert_eq!( - frames[0].data, pcm, - "DVD must preserve every PCM byte — no second strip" - ); - assert_eq!(frames[0].pts_ns, 1_000_000_000); + fn bd_stereo_24bit_passes_through() { + let mut p = LpcmParser::default(); + let pcm = [1, 2, 3, 4, 5, 6]; + assert_eq!(p.parse(&make_pes(bd(3, 3, &pcm), Some(0)))[0].data, pcm); } #[test] - fn dvd_lpcm_emits_short_payload_bd_would_drop() { - // A 4-byte raw-PCM DVD payload (1 sample pair at 16-bit stereo). The BD - // parser drops <= 4 bytes as "header only"; the DVD parser must emit it. - let mut bd = LpcmParser::new(); - let mut dvd = LpcmParser::new_dvd(); - let pcm = vec![0xAA, 0xBB, 0xCC, 0xDD]; + fn bd_mono_drops_the_pad_channel() { + // Mono is coded as 2 channels; the second is padding. + let mut p = LpcmParser::new(); + let f = p.parse(&make_pes( + bd(1, 1, &[0x11, 0x12, 0, 0, 0x21, 0x22, 0, 0]), + Some(0), + )); + assert_eq!(f[0].data, w24(&[0x11, 0x12, 0x21, 0x22])); + } + #[test] + fn bd_odd_channel_24bit_drops_the_pad_channel() { + // 3/0 (L R C) at 24-bit: 4 coded channels, last is padding. + let mut p = LpcmParser::new(); + let src = [1, 1, 1, 2, 2, 2, 3, 3, 3, 9, 9, 9]; + let f = p.parse(&make_pes(bd(4, 3, &src), Some(0))); + assert_eq!(f[0].data, vec![1, 1, 1, 2, 2, 2, 3, 3, 3]); + } + + #[test] + fn bd_51_16bit_moves_lfe_to_wave_order() { + // BD L R C Ls Rs LFE -> L R C LFE Ls Rs (ffmpeg 5POINT1 mapping). + let mut p = LpcmParser::new(); + let f = p.parse(&make_pes(bd(9, 1, &frame16(&[0, 1, 2, 3, 4, 5])), Some(0))); + assert_eq!(f[0].data, w24(&frame16(&[0, 1, 2, 5, 3, 4]))); + } + + #[test] + fn bd_71_16bit_reorders_to_wave_order() { + // BD L R C Ls Lrs Rrs Rs LFE -> L R C LFE Lrs Rrs Ls Rs (ffmpeg 7POINT1). + let mut p = LpcmParser::new(); + let f = p.parse(&make_pes( + bd(11, 1, &frame16(&[0, 1, 2, 3, 4, 5, 6, 7])), + Some(0), + )); + assert_eq!(f[0].data, w24(&frame16(&[0, 1, 2, 7, 4, 5, 3, 6]))); + } + + #[test] + fn bd_71_24bit_reorders_to_wave_order() { + let mut p = LpcmParser::new(); + let src: Vec = (0..8u8).flat_map(|c| [c; 3]).collect(); + let f = p.parse(&make_pes(bd(11, 3, &src), Some(0))); + let want: Vec = [0u8, 1, 2, 7, 4, 5, 3, 6] + .iter() + .flat_map(|&c| [c; 3]) + .collect(); + assert_eq!(f[0].data, want); + } + + #[test] + fn bd_70_reorders_and_drops_pad() { + // BD L R C Ls Lrs Rrs Rs -> L R C Lrs Rrs Ls Rs (ffmpeg 7POINT0). + let mut p = LpcmParser::new(); + let f = p.parse(&make_pes( + bd(10, 1, &frame16(&[0, 1, 2, 3, 4, 5, 6, 7])), + Some(0), + )); + assert_eq!(f[0].data, w24(&frame16(&[0, 1, 2, 4, 5, 3, 6]))); + } + + #[test] + fn bd_short_or_header_only_pes_dropped() { + let mut p = LpcmParser::new(); + assert!(p.parse(&make_pes(Vec::new(), Some(0))).is_empty()); assert!( - bd.parse(&make_pes(pcm.clone(), Some(0))).is_empty(), - "BD treats 4 bytes as header-only" + p.parse(&make_pes(vec![0x00, 0x01, 0x00], Some(0))) + .is_empty() ); - let frames = dvd.parse(&make_pes(pcm.clone(), Some(0))); - assert_eq!(frames.len(), 1); - assert_eq!(frames[0].data, pcm); + assert!(p.parse(&make_pes(bd(3, 1, &[]), Some(0))).is_empty()); } #[test] - fn always_keyframe() { - let mut parser = LpcmParser::new(); - for i in 0..5u8 { - let data = vec![0x00, 0x00, 0x00, 0x00, i, i + 1]; - let pes = make_pes(data, Some(90000 * i as i64)); - let frames = parser.parse(&pes); - assert_eq!(frames.len(), 1); - assert!(frames[0].keyframe, "LPCM frames should always be keyframes"); + fn reserved_header_drops_the_packet() { + // ffmpeg rejects reserved channel/rate/depth codes (INVALIDDATA); so do we. + let mut p = LpcmParser::new(); + let pcm = [1, 2, 3, 4]; + assert!( + p.parse(&make_pes(bd(0, 1, &pcm), Some(0))).is_empty(), + "channels 0" + ); + assert!( + p.parse(&make_pes(bd(3, 0, &pcm), Some(0))).is_empty(), + "depth 0" + ); + let six = [0u8; 6]; + assert!( + p.parse(&make_pes(bd(3, 2, &six), Some(0))).is_empty(), + "BD 20-bit" + ); + let mut bad_rate = bd(3, 1, &pcm); + bad_rate[2] = 0x32; + assert!(p.parse(&make_pes(bad_rate, Some(0))).is_empty(), "rate 2"); + let mut d = LpcmParser::new_dvd(); + assert!( + d.parse(&make_pes(dvd(0xC1, &pcm), Some(0))).is_empty(), + "quant 3" + ); + } + + #[test] + fn no_pts_before_any_timestamp_starts_at_zero() { + // No PTS and no earlier timestamp → 0 (both variants). + let mut p = LpcmParser::new(); + assert_eq!(p.parse(&make_pes(bd(3, 1, &[0; 8]), None))[0].pts_ns, 0); + let mut d = LpcmParser::new_dvd(); + assert_eq!(d.parse(&make_pes(dvd(0x01, &[0; 4]), None))[0].pts_ns, 0); + } + + #[test] + fn pts_less_pes_carries_timeline_and_propagates_discontinuity() { + // No PTS (legal for audio): continue the timeline — not 0, not a duplicate — + // and keep the PES discontinuity flag. 4 bytes stereo 16-bit = 1 sample. + let mut p = LpcmParser::new(); + let data = bd(3, 1, &[0x11, 0x22, 0x33, 0x44]); + p.parse(&make_pes(data.clone(), Some(90_000))); + let mut pes = make_pes(data, None); + pes.discontinuity = true; + let f = p.parse(&pes); + assert_eq!(f.len(), 1); + assert_eq!(f[0].pts_ns, 1_000_020_833, "advanced by one 48 kHz sample"); + assert!(f[0].discontinuity); + } + + #[test] + fn pts_less_pes_advances_by_previous_duration() { + // 480 stereo samples at 48 kHz = 10 ms. + let mut p = LpcmParser::new(); + let pcm = vec![0u8; 480 * 4]; + p.parse(&make_pes(bd(3, 1, &pcm), Some(90_000))); + let f = p.parse(&make_pes(bd(3, 1, &pcm), None)); + assert_eq!(f[0].pts_ns, 1_010_000_000); + } + + #[test] + fn dvd_16bit_strips_audio_header() { + let mut p = LpcmParser::new_dvd(); + let pcm = [0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0x01, 0x02]; + let f = p.parse(&make_pes(dvd(0x01, &pcm), Some(90_000))); + assert_eq!(f[0].data, w24(&pcm)); + assert_eq!(f[0].pts_ns, 1_000_000_000); + } + + #[test] + fn dvd_header_only_pes_dropped() { + let mut p = LpcmParser::new_dvd(); + assert!(p.parse(&make_pes(Vec::new(), Some(0))).is_empty()); + assert!(p.parse(&make_pes(vec![0, 1, 0x80], Some(0))).is_empty()); + } + + #[test] + fn dvd_24bit_stereo_unpacks_grouped_samples() { + // Group = MSB16 of L0 R0 L1 R1, then their low bytes. + let mut p = LpcmParser::new_dvd(); + let src = [ + 0xA0, 0xA1, 0xB0, 0xB1, 0xC0, 0xC1, 0xD0, 0xD1, 0xA2, 0xB2, 0xC2, 0xD2, + ]; + let f = p.parse(&make_pes(dvd(0x81, &src), Some(0))); + assert_eq!( + f[0].data, + vec![ + 0xA0, 0xA1, 0xA2, 0xB0, 0xB1, 0xB2, 0xC0, 0xC1, 0xC2, 0xD0, 0xD1, 0xD2 + ] + ); + } + + #[test] + fn dvd_20bit_stereo_unpacks_nibbles() { + let mut p = LpcmParser::new_dvd(); + let src = [0xA0, 0xA1, 0xB0, 0xB1, 0xC0, 0xC1, 0xD0, 0xD1, 0x12, 0x34]; + let f = p.parse(&make_pes(dvd(0x41, &src), Some(0))); + assert_eq!( + f[0].data, + vec![ + 0xA0, 0xA1, 0x10, 0xB0, 0xB1, 0x20, 0xC0, 0xC1, 0x30, 0xD0, 0xD1, 0x40 + ] + ); + } + + #[test] + fn dvd_24bit_mono_block_is_two_groups_of_two() { + let mut p = LpcmParser::new_dvd(); + let src = [1, 1, 2, 2, 0x10, 0x20, 3, 3, 4, 4, 0x30, 0x40]; + let f = p.parse(&make_pes(dvd(0x80, &src), Some(0))); + let want = vec![1, 1, 0x10, 2, 2, 0x20, 3, 3, 0x30, 4, 4, 0x40]; + assert_eq!(f[0].data, want); + } + + #[test] + fn dvd_20bit_mono_block_is_two_groups_of_two() { + let mut p = LpcmParser::new_dvd(); + let src = [1, 1, 2, 2, 0x12, 3, 3, 4, 4, 0x34]; + let f = p.parse(&make_pes(dvd(0x40, &src), Some(0))); + let want = vec![1, 1, 0x10, 2, 2, 0x20, 3, 3, 0x30, 4, 4, 0x40]; + assert_eq!(f[0].data, want); + } + + /// Build a DVD 20/24-bit block for `ch` channels: sample n (interleaved) has + /// MSB16 = [n, n] and low byte 0xn0 (24-bit) / nibble n (20-bit). + fn dvd_block(ch: usize, bits: u8, frames: usize) -> (Vec, Vec) { + let n = ch * frames; + let (mut src, mut want) = (Vec::new(), Vec::new()); + for g in (0..n).step_by(4) { + for k in g..g + 4 { + src.extend_from_slice(&[k as u8, k as u8]); + } + if bits == 24 { + src.extend((g..g + 4).map(|k| (k as u8) << 4)); + } else { + src.push(((g as u8 & 0xF) << 4) | ((g as u8 + 1) & 0xF)); + src.push((((g as u8 + 2) & 0xF) << 4) | ((g as u8 + 3) & 0xF)); + } + } + for k in 0..n { + let low = if bits == 24 { + (k as u8) << 4 + } else { + (k as u8 & 0xF) << 4 + }; + want.extend_from_slice(&[k as u8, k as u8, low]); + } + (src, want) + } + + #[test] + fn dvd_multichannel_blocks_emit_whole_sample_frames_only() { + // ffmpeg pcm-dvd block: odd/6 channels = `ch` groups (4 frames), 8ch = 2 groups + // (1 frame). A lone group is not a whole frame and must be held back. + for (ch, frames) in [(3usize, 4usize), (5, 4), (6, 4), (8, 1)] { + for (bits, q) in [(24u8, 0x80u8), (20, 0x40)] { + let (src, want) = dvd_block(ch, bits, frames); + let group = if bits == 24 { 12 } else { 10 }; + let hdr = q | (ch as u8 - 1); + let mut p = LpcmParser::new_dvd(); + let a = p.parse(&make_pes(dvd(hdr, &src[..group]), Some(0))); + assert!(all(&a).is_empty(), "{ch}ch {bits}-bit: one group held"); + let b = p.parse(&make_pes(dvd(hdr, &src[group..]), None)); + let got = all(&b); + assert_eq!(got, want, "{ch}ch {bits}-bit block"); + assert_eq!(got.len() % (ch * 3), 0, "whole sample frames"); + } } } #[test] - fn empty_pes_returns_no_frames() { - let mut parser = LpcmParser::new(); - let pes = make_pes(Vec::new(), Some(0)); - assert!(parser.parse(&pes).is_empty()); + fn dvd_3ch_pts_less_timeline_counts_whole_frames() { + // 3ch 24-bit block = 4 sample frames; two blocks per PES = 8 frames, so the + // PTS-less successor sits exactly 8 samples later (no flooring drift). + let (src, _) = dvd_block(3, 24, 4); + let two: Vec = src.iter().chain(src.iter()).copied().collect(); + let mut p = LpcmParser::new_dvd(); + p.parse(&make_pes(dvd(0x82, &two), Some(0))); + let f = p.parse(&make_pes(dvd(0x82, &two), None)); + assert_eq!(f[0].pts_ns, 166_666, "8 samples at 48 kHz"); + assert_eq!(f[0].data.len(), 8 * 3 * 3); } #[test] - fn header_only_pes_returns_no_frames() { - let mut parser = LpcmParser::new(); - // Exactly 4 bytes = header only, no PCM data - let pes = make_pes(vec![0x00, 0x01, 0x00, 0x00], Some(0)); - assert!(parser.parse(&pes).is_empty()); - } - - #[test] - fn codec_private_none() { - let parser = LpcmParser::new(); - assert!(parser.codec_private().is_none()); - } - - #[test] - fn pts_conversion() { - let mut parser = LpcmParser::new(); - // PTS = 0 should give pts_ns = 0 - let pes = make_pes(vec![0; 8], Some(0)); - let frames = parser.parse(&pes); - assert_eq!(frames[0].pts_ns, 0); - - // No PTS should default to 0 - let pes_no_pts = make_pes(vec![0; 8], None); - let frames = parser.parse(&pes_no_pts); - assert_eq!(frames[0].pts_ns, 0); - } - - // --- BD strip offset boundary --- - - #[test] - fn bd_five_bytes_yields_one_pcm_byte() { - // BD strips exactly BD_LPCM_HEADER_SIZE (4). The guard is - // `data.len() <= offset` (drop), so 5 bytes → 1 PCM byte emitted, not 0. - let mut parser = LpcmParser::new(); - let f = parser.parse(&make_pes(vec![0x00, 0x01, 0x00, 0x91, 0xAB], Some(0))); - assert_eq!(f.len(), 1); - assert_eq!(f[0].data, vec![0xAB], "5 BD bytes → 1 PCM byte"); - } - - #[test] - fn bd_three_bytes_dropped() { - // Fewer than the 4-byte header → dropped, no panic / no underflow slice. - let mut parser = LpcmParser::new(); - assert!( - parser - .parse(&make_pes(vec![0x00, 0x01, 0x00], Some(0))) - .is_empty() - ); - } - - // --- DVD strips nothing --- - - #[test] - fn dvd_one_byte_payload_emitted() { - // DVD offset is 0, so even a single byte is real PCM and must be emitted - // (`len <= 0` is false for len 1). - let mut parser = LpcmParser::new_dvd(); - let f = parser.parse(&make_pes(vec![0xAB], Some(0))); - assert_eq!(f.len(), 1); - assert_eq!(f[0].data, vec![0xAB]); - } - - #[test] - fn dvd_empty_payload_dropped() { - // DVD with an empty payload: `len <= 0` (0 <= 0) → dropped. - let mut parser = LpcmParser::new_dvd(); - assert!(parser.parse(&make_pes(Vec::new(), Some(0))).is_empty()); - } - - #[test] - fn bd_default_constructor_strips_header() { - // Default::default() must build the BD (strip) variant, matching new(). - let mut parser = LpcmParser::default(); - let header = vec![0x00, 0x01, 0x00, 0x91]; - let pcm = vec![0x11, 0x22, 0x33, 0x44]; - let mut data = header; - data.extend_from_slice(&pcm); - let f = parser.parse(&make_pes(data, Some(0))); - assert_eq!(f[0].data, pcm, "default = BD variant, strips 4 bytes"); - } - - #[test] - fn lpcm_no_pts_defaults_zero_dvd() { - // DVD variant with no PTS → pts_ns 0 (unwrap_or(0)). - let mut parser = LpcmParser::new_dvd(); - let f = parser.parse(&make_pes(vec![0xAA, 0xBB], None)); - assert_eq!(f[0].pts_ns, 0); - } - - #[test] - fn pts_less_pes_carries_last_timestamp_and_propagates_discontinuity() { - // A PES with no PTS (legal for audio, e.g. after a discontinuity) must carry the - // last known timestamp forward — resetting to 0 would corrupt A/V sync — and the - // emitted unit must reflect the PES discontinuity flag, not a hardcoded false. Mirrors the adts/flac guards. - let mut parser = LpcmParser::new(); - let header = vec![0x00, 0x01, 0x00, 0b1001_0001]; - let pcm = vec![0x11, 0x22, 0x33, 0x44]; - let mut data = header; - data.extend_from_slice(&pcm); - // Prime last_pts_ns with a real timestamp. - parser.parse(&make_pes(data.clone(), Some(90_000))); - - // Next PES has no PTS and is flagged discontinuous. - let mut pes = make_pes(data, None); - pes.discontinuity = true; - let frames = parser.parse(&pes); - assert_eq!(frames.len(), 1); + fn dvd_24bit_group_split_across_pes_is_carried() { + let mut p = LpcmParser::new_dvd(); + let src = [ + 0xA0, 0xA1, 0xB0, 0xB1, 0xC0, 0xC1, 0xD0, 0xD1, 0xA2, 0xB2, 0xC2, 0xD2, + ]; + let a = p.parse(&make_pes(dvd(0x81, &src[..5]), Some(0))); + assert!(all(&a).is_empty(), "partial block held back"); + let b = p.parse(&make_pes(dvd(0x81, &src[5..]), Some(90))); assert_eq!( - frames[0].pts_ns, 1_000_000_000, - "carried forward, not reset to 0" - ); - assert!( - frames[0].discontinuity, - "PES discontinuity propagates into the access unit" + all(&b), + vec![ + 0xA0, 0xA1, 0xA2, 0xB0, 0xB1, 0xB2, 0xC0, 0xC1, 0xC2, 0xD0, 0xD1, 0xD2 + ] ); } - // The text-based guard in codec/mod.rs can't see writes via `facts.source`; - // only a runtime check proves an emitted frame carries the byte it came - // from, needed for placing a track by byte instead of timestamp inference. + #[test] + fn carried_bytes_are_stamped_before_the_pes_pts() { + // DVD 16-bit stereo: 2 of a sample's 4 bytes arrive in the previous PES. The + // PTS belongs to the first sample STARTING in this PES, so the emitted run + // (led by the carried sample) is stamped one 48 kHz sample earlier. + let mut p = LpcmParser::new_dvd(); + p.parse(&make_pes(dvd(0x01, &[0u8; 4 * 48 + 2]), Some(0))); + let f = p.parse(&make_pes(dvd(0x01, &[0u8; 2 + 4 * 10]), Some(90_000))); + assert_eq!( + f[0].pts_ns, + 1_000_000_000 - 20_833, + "one carried sample earlier" + ); + } + + #[test] + fn bd_payloads_split_to_fit_payload_size_and_round_trip() { + let h = bd_header(8, 48_000, None).unwrap(); + assert!( + bd_header(2, 44_100, None).is_none(), + "BD LPCM has no 44.1 kHz" + ); + assert!(bd_header(0, 48_000, None).is_none()); + let pcm: Vec = (0..3000 * 8 * 3).map(|i| (i % 253) as u8).collect(); + let parts = bd_payloads(&pcm, h); + assert_eq!(parts.len(), 13, "12 x 240 + 120 samples"); + assert_eq!(parts[1].0, 5_000_000); + let mut p = LpcmParser::new(); + let got: Vec = parts + .into_iter() + .flat_map(|(_, d)| all(&p.parse(&make_pes(d, Some(0))))) + .collect(); + assert_eq!(got, pcm); + } + + #[test] + fn bd_payloads_are_5ms_pes_like_ffmpeg_blurayenc() { + // 20000 stereo samples @ 48 kHz -> 83 full 240-sample PES + one of 80. + let h = bd_header(2, 48_000, None).unwrap(); + let parts = bd_payloads(&vec![0u8; 20_000 * 2 * 3], h); + assert_eq!(parts.len(), 84); + assert!(parts[..83].iter().all(|(_, p)| p.len() == 4 + 240 * 6)); + assert_eq!(parts[83].1.len(), 4 + 80 * 6); + assert_eq!(parts[1].0, 5_000_000, "second PES 5 ms later"); + } + + #[test] + fn bd_parser_reports_the_channel_assignment() { + let mut p = LpcmParser::new(); + assert_eq!(p.codec_private(), None); + p.parse(&make_pes(bd(7, 1, &[0; 8]), Some(0))); + let cp = p.codec_private().unwrap(); + assert_eq!( + cp, + b"BDLP\x71".to_vec(), + "tagged so foreign CodecPrivate never matches" + ); + assert_eq!(layout_byte(&cp), Some(0x71)); + assert_eq!( + layout_byte(&[0x71]), + None, + "untagged (foreign MKV) byte ignored" + ); + assert_eq!(LpcmParser::new_dvd().codec_private(), None); + } + #[test] fn an_emitted_frame_carries_the_packets_source_offset() { - let mut parser = LpcmParser::new(); - let mut data = vec![0x00, 0x01, 0x00, 0b1001_0001]; - data.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE]); - let mut pes = make_pes(data, Some(90_000)); + let mut p = LpcmParser::new(); + let mut pes = make_pes(bd(3, 1, &[0xDE, 0xAD, 0xBE, 0xEF]), Some(90_000)); pes.source = Some(crate::pes::SourcePos::at_byte(7_777)); - let frames = parser.parse(&pes); - assert!(!frames.is_empty(), "the frame is emitted"); - assert_eq!(frames[0].source.map(|s| s.byte), Some(7_777)); + let f = p.parse(&pes); + assert!(!f.is_empty(), "the frame is emitted"); + assert_eq!(f[0].source.map(|s| s.byte), Some(7_777)); } } diff --git a/src/mux/driver.rs b/src/mux/driver.rs index 12978c9a..e2ed48e9 100644 --- a/src/mux/driver.rs +++ b/src/mux/driver.rs @@ -159,7 +159,9 @@ pub struct MuxOptions { /// `'static` `EventFn`. Progress is split into read-side and write-side callbacks (CLI renders /// WRITE, autorip READ). pub trait MuxEvents: Send + Sync + 'static { - /// Fired once, immediately after the output sink is created. + /// Fired once, immediately after the output sink is created. The title lists + /// only streams the sink will write (tracks it refused are removed, so indices + /// compact); `MuxOutcome::undelivered_streams` keeps source-title indices. fn on_output_opened(&self, _title: &DiscTitle) {} /// Fired periodically from the reader side with the running read-byte count /// and the source extents' total byte estimate. @@ -201,9 +203,8 @@ pub struct MuxOutcome { /// Number of streams in the muxed title. pub streams: usize, /// `title.streams` indices the output sink accepted frames for but could not - /// put in the finished container (`Stream::undelivered_streams`) — today only - /// the `mp4://` sink, which must drop an audio track no frame of which - /// yielded a parseable sample entry. + /// put in the finished container (`Stream::undelivered_streams`): `mp4://` audio + /// with no parseable sample entry, and `m2ts://` LPCM that BD LPCM can't carry. /// /// Non-empty means the file does NOT match the pre-mux plan even with `completed = true`. A /// caller reporting a successful export must report these too — a lossy outcome is never @@ -600,7 +601,7 @@ fn drive_mux( buffered_bytes, cap = HEADER_BUFFER_CAP_BYTES, "header buffer cap exceeded: the title keeps yielding frames but a \ - required codec_private (video or AAC) never resolved; refusing \ + required codec_private (video, AAC or BD LPCM) never resolved; refusing \ rather than buffering the whole stream into RAM" ); return Err(Error::MuxHeaderBufferExceeded { @@ -615,7 +616,7 @@ fn drive_mux( // ── Header gate ── Halt first, whatever headers_ready() says: on the // highway path a halt can end the stream as `Ok(None)`, and that EOF also - // releases the AAC wait, so a ready gate does not mean the pump finished. + // releases the in-band config wait, so a ready gate does not mean the pump finished. if halt.is_cancelled() { return Ok(MuxOutcome { completed: false, @@ -641,6 +642,7 @@ fn drive_mux( out_title.codec_privates = (0..info.streams.len()) .map(|i| stream.codec_private(i)) .collect(); + crate::mux::codec::lpcm::correct_title_layout(&mut out_title); // AAC tracks whose ASC was unknown at header time: handed to the sink when // it resolves (a seekable MKV backpatches it). let mut late_aac: Vec = info @@ -665,8 +667,25 @@ fn drive_mux( for track in 0..num_streams { output_stream.set_track_timing(track, stream.track_timing(track))?; } + // A sink may refuse a stream at open (m2ts: LPCM BD LPCM can't carry); the + // opened title lists only what will be written. + let refused = output_stream.undelivered_streams(); + if refused.is_empty() { + events.on_output_opened(&out_title); + } else { + let mut opened = out_title.clone(); + let keep = |i: &usize| !refused.contains(i); + opened.streams = (0..opened.streams.len()) + .filter(keep) + .map(|i| out_title.streams[i].clone()) + .collect(); + opened.codec_privates = (0..out_title.codec_privates.len()) + .filter(keep) + .map(|i| out_title.codec_privates[i].clone()) + .collect(); + events.on_output_opened(&opened); + } let output_stream = CountingStream::new(output_stream); - events.on_output_opened(&out_title); // The write consumer runs on its own thread so the latency-bound sink write // overlaps the next `stream.read()`. `bytes` mirrors the consumer's running @@ -2244,6 +2263,231 @@ mod tests { } } + /// A source stream carrying one BD LPCM track, declared per `channels`/`rate`, + /// whose parser reported `layout` (the tagged BD header byte) as codec_private. + struct LpcmSource { + info: DiscTitle, + layout: Option>, + frames: usize, + } + impl LpcmSource { + fn new( + channels: crate::disc::AudioChannels, + rate: crate::disc::SampleRate, + layout: Option>, + ) -> Self { + let lpcm = crate::disc::Codec::Lpcm; + let mut info = DiscTitle::empty(); + info.streams = vec![crate::disc::Stream::Audio(crate::disc::AudioStream { + pid: 0x1100, + codec: lpcm, + channels, + language: "eng".into(), + sample_rate: rate, + secondary: false, + purpose: crate::disc::LabelPurpose::Normal, + label: crate::labels::generate_audio_label(&lpcm, &channels, false), + })]; + LpcmSource { + info, + layout, + frames: 1, + } + } + } + impl Stream for LpcmSource { + fn read(&mut self) -> std::io::Result> { + if self.frames == 0 { + return Ok(None); + } + self.frames -= 1; + Ok(Some(PesFrame { + discard_padding_ns: 0, + track: 0, + pts: 0, + keyframe: true, + data: vec![0; 240 * 8 * 3], + duration_ns: None, + source: None, + coding: None, + })) + } + fn write(&mut self, _frame: &PesFrame) -> std::io::Result<()> { + Ok(()) + } + fn finish(&mut self) -> std::io::Result<()> { + Ok(()) + } + fn info(&self) -> &DiscTitle { + &self.info + } + fn codec_private(&self, _track: usize) -> Option> { + self.layout.clone() + } + } + + /// Keeps the title handed to `on_output_opened`. + #[derive(Default)] + struct TitleSpy(std::sync::Mutex>); + impl MuxEvents for TitleSpy { + fn on_output_opened(&self, title: &DiscTitle) { + *self.0.lock().unwrap() = Some(title.clone()); + } + } + + fn run(src: LpcmSource, url: &str, spy: &TitleSpy) -> MuxOutcome { + let d = Duration::from_secs(60); + drive_mux(Box::new(src), url, &Halt::new(), spy, None, d, None).unwrap() + } + + // Every entry point (Url/Session/Iso/Live) funnels into drive_mux: the BD LPCM + // layout byte must override the playlist's "5.1" for 7.1 audio there. + #[test] + fn drive_mux_corrects_lpcm_channels_from_the_parser_layout() { + use crate::disc::{AudioChannels, SampleRate}; + let dir = tempfile::tempdir().unwrap(); + let url = format!("mkv://{}", dir.path().join("o.mkv").display()); + let layout = Some(b"BDLP\xB4".to_vec()); + let src = LpcmSource::new(AudioChannels::Surround51, SampleRate::S48, layout); + let spy = TitleSpy::default(); + run(src, &url, &spy); + let t = spy.0.lock().unwrap().clone().unwrap(); + let crate::disc::Stream::Audio(a) = &t.streams[0] else { + panic!("audio") + }; + assert_eq!(a.channels, AudioChannels::Surround71); + assert_eq!(a.sample_rate, SampleRate::S96); + let want = crate::labels::generate_audio_label(&a.codec, &AudioChannels::Surround71, false); + assert_eq!(a.label, want); + } + + /// BD source whose LPCM layout byte exists only once the first PES has been + /// parsed, gated by the real `HeaderGate` (as DiscStream/PipelinedPesStream are). + struct GatedLpcm { + info: DiscTitle, + parser: crate::mux::codec::lpcm::LpcmParser, + gate: crate::mux::header_gate::HeaderGate, + pes: std::collections::VecDeque>, + } + impl GatedLpcm { + fn new(with_video: bool) -> Self { + use crate::mux::codec::CodecParser as _; + let src = LpcmSource::new( + crate::disc::AudioChannels::Surround51, + crate::disc::SampleRate::S48, + None, + ); + let mut info = src.info; + if with_video { + let v = LateAacStream::new().info.streams[0].clone(); + info.streams.insert(0, v); + } + // 7.1 (assignment 11) @ 96 kHz, 24-bit: 480 samples = 5 ms. + let mut pes = vec![0x00, 0x00, 0xB4, 0xC0]; + pes.extend(vec![0u8; 480 * 8 * 3]); + let parser = crate::mux::codec::lpcm::LpcmParser::new(); + assert!(parser.codec_private().is_none()); + GatedLpcm { + info, + parser, + gate: Default::default(), + pes: [pes.clone(), pes].into(), + } + } + fn lpcm_track(&self) -> usize { + self.info.streams.len() - 1 + } + } + impl Stream for GatedLpcm { + fn read(&mut self) -> std::io::Result> { + use crate::mux::codec::CodecParser as _; + let Some(data) = self.pes.pop_front() else { + self.gate.expire(); + return Ok(None); + }; + let pkt = crate::mux::ts::PesPacket { + source: None, + pid: 0x1100, + pts: Some(0), + dts: None, + data, + discontinuity: false, + }; + let f = self.parser.parse(&pkt).remove(0); + let frame = PesFrame { + discard_padding_ns: 0, + track: self.lpcm_track(), + pts: f.pts_ns, + keyframe: true, + data: f.data, + duration_ns: None, + source: None, + coding: None, + }; + self.gate.observe(&frame); + Ok(Some(frame)) + } + fn write(&mut self, _frame: &PesFrame) -> std::io::Result<()> { + Ok(()) + } + fn finish(&mut self) -> std::io::Result<()> { + Ok(()) + } + fn info(&self) -> &DiscTitle { + &self.info + } + fn codec_private(&self, track: usize) -> Option> { + use crate::mux::codec::CodecParser as _; + if track == self.lpcm_track() { + self.parser.codec_private() + } else { + Some(vec![1, 0x64, 0, 0x28, 0xFF, 0xE0, 0, 0]) // minimal avcC + } + } + fn headers_ready(&self) -> bool { + self.gate.ready(&self.info, |i| self.codec_private(i)) + } + } + + // The layout byte arrives only after the first read: the header gate must wait + // for it, or a 7.1/96k track is declared as the playlist's 5.1/48k. + #[test] + fn late_lpcm_layout_byte_still_corrects_the_header() { + use crate::disc::{AudioChannels, SampleRate}; + for with_video in [false, true] { + let src = GatedLpcm::new(with_video); + assert!(!src.headers_ready(), "BD LPCM waits for its layout byte"); + let dir = tempfile::tempdir().unwrap(); + // m2ts: an MKV with a declared but frameless video track is refused. + let url = format!("m2ts://{}", dir.path().join("o.m2ts").display()); + let spy = TitleSpy::default(); + let d = Duration::from_secs(60); + drive_mux(Box::new(src), &url, &Halt::new(), &spy, None, d, None).unwrap(); + let t = spy.0.lock().unwrap().clone().unwrap(); + let crate::disc::Stream::Audio(a) = t.streams.last().unwrap() else { + panic!("audio") + }; + assert_eq!(a.channels, AudioChannels::Surround71, "video={with_video}"); + assert_eq!(a.sample_rate, SampleRate::S96, "video={with_video}"); + } + } + + #[test] + fn m2ts_dropped_lpcm_is_undelivered_and_not_in_the_opened_title() { + use crate::disc::{AudioChannels, SampleRate}; + let dir = tempfile::tempdir().unwrap(); + let url = format!("m2ts://{}", dir.path().join("o.m2ts").display()); + // Track 0 (48 kHz) is written; track 1 (44.1 kHz) cannot be BD LPCM. + let mut src = LpcmSource::new(AudioChannels::Stereo, SampleRate::S48, None); + let other = LpcmSource::new(AudioChannels::Stereo, SampleRate::S44_1, None); + src.info.streams.extend(other.info.streams); + let spy = TitleSpy::default(); + let out = run(src, &url, &spy); + assert_eq!(out.undelivered_streams, vec![1]); + let opened = spy.0.lock().unwrap().clone().unwrap(); + assert_eq!(opened.streams.len(), 1, "only the carried track is listed"); + } + // The sink lives in the consumer thread and is destroyed with it, so an // undelivered stream is knowable ONLY at `close()`, which must carry it // out alongside the byte count or `MuxOutcome` can never report it. diff --git a/src/mux/header_gate.rs b/src/mux/header_gate.rs index 0373f66d..67145db1 100644 --- a/src/mux/header_gate.rs +++ b/src/mux/header_gate.rs @@ -3,15 +3,15 @@ use crate::disc::{Codec, DiscTitle, Stream}; use crate::pes::PesFrame; -// Source time an AAC track may take to deliver its first frame (the only source -// of its AudioSpecificConfig) before headers are finalised without it. -const AAC_CONFIG_WAIT_NS: i64 = 5_000_000_000; +// Source time an AAC (ASC) or BD LPCM (layout byte) track may take to deliver its +// first frame, the only source of that config, before headers finalise without it. +const CONFIG_WAIT_NS: i64 = 5_000_000_000; // Larger PTS steps are discontinuities/wraps, not elapsed time. const MAX_PTS_STEP_NS: i64 = 1_000_000_000; // Backstops, well inside the driver's cap: per-track frames that did not // advance PTS (a stuck clock), and total buffered bytes. -const AAC_CONFIG_WAIT_FRAMES: u32 = 2048; -const AAC_CONFIG_WAIT_BYTES: usize = super::driver::HEADER_BUFFER_CAP_BYTES / 2; +const CONFIG_WAIT_FRAMES: u32 = 2048; +const CONFIG_WAIT_BYTES: usize = super::driver::HEADER_BUFFER_CAP_BYTES / 2; // One track's view of source time, so interleaved tracks far apart in PTS // neither rebase nor double-count each other. @@ -64,9 +64,9 @@ impl HeaderGate { clock.max_pts = pts; } } - if clock.stalled >= AAC_CONFIG_WAIT_FRAMES - || clock.elapsed_ns >= AAC_CONFIG_WAIT_NS - || self.bytes >= AAC_CONFIG_WAIT_BYTES + if clock.stalled >= CONFIG_WAIT_FRAMES + || clock.elapsed_ns >= CONFIG_WAIT_NS + || self.bytes >= CONFIG_WAIT_BYTES { self.expired = true; } @@ -77,8 +77,8 @@ impl HeaderGate { self.expired = true; } - /// Primary video always needs its config; AAC needs it until the wait expires - /// (Matroska A_AAC frames are ADTS-stripped, so the ASC is mandatory). + /// Primary video always needs its config; AAC (ASC, mandatory for ADTS-stripped + /// A_AAC) and BD LPCM (layout byte: playlists say "5.1") until the wait expires. pub(crate) fn ready( &self, title: &DiscTitle, @@ -89,7 +89,11 @@ impl HeaderGate { Stream::Video(v) => !v.secondary, // Secondary (commentary) AAC is not exempt: without its ASC it is // undecodable too, and the wait is bounded anyway. - Stream::Audio(a) => matches!(a.codec, Codec::Aac) && !self.expired, + Stream::Audio(a) => { + let bd_lpcm = a.codec == Codec::Lpcm + && title.content_format == crate::disc::ContentFormat::BdTs; + (a.codec == Codec::Aac || bd_lpcm) && !self.expired + } Stream::Subtitle(_) => false, }; !needs || codec_private(idx).is_some() @@ -126,10 +130,34 @@ mod tests { !g.ready(&aac_title(), |_| None) } + fn lpcm_title(format: crate::disc::ContentFormat) -> DiscTitle { + let mut t = aac_title(); + if let Stream::Audio(a) = &mut t.streams[0] { + a.codec = Codec::Lpcm; + } + t.content_format = format; + t + } + + #[test] + fn bd_lpcm_waits_for_its_layout_byte_until_expiry() { + use crate::disc::ContentFormat; + let bd = lpcm_title(ContentFormat::BdTs); + let mut g = HeaderGate::default(); + assert!(!g.ready(&bd, |_| None), "BD LPCM needs its layout byte"); + assert!(g.ready(&bd, |_| Some(vec![1]))); + assert!( + g.ready(&lpcm_title(ContentFormat::MpegPs), |_| None), + "DVD LPCM has no layout byte to wait for" + ); + g.expire(); + assert!(g.ready(&bd, |_| None), "bounded like AAC"); + } + #[test] fn frame_count_backstop_expires_when_pts_never_advances() { let mut g = HeaderGate::default(); - for _ in 0..AAC_CONFIG_WAIT_FRAMES - 1 { + for _ in 0..CONFIG_WAIT_FRAMES - 1 { g.account(0, 0, 0); } assert!(waiting(&g)); @@ -207,7 +235,7 @@ mod tests { #[test] fn byte_bound_expires_before_the_driver_cap() { let mut g = HeaderGate::default(); - g.account(0, 0, AAC_CONFIG_WAIT_BYTES - 1); + g.account(0, 0, CONFIG_WAIT_BYTES - 1); assert!(waiting(&g)); g.account(0, 0, 1); assert!(!waiting(&g)); diff --git a/src/mux/m2ts.rs b/src/mux/m2ts.rs index f14b7d12..3e50591b 100644 --- a/src/mux/m2ts.rs +++ b/src/mux/m2ts.rs @@ -16,18 +16,55 @@ use std::io::{self, Write}; pub struct M2tsStream { disc_title: DiscTitle, muxer: super::tsmux::TsMuxer>, + /// Per input track: output track index (None = dropped at create) and, for LPCM, + /// the BD LPCM header to re-pack parser PCM with. + route: Vec)>>, } impl M2tsStream { /// Create for writing PES frames → BD-TS output. /// Writes FMKV metadata header, then muxes PES frames into BD transport stream. + /// LPCM that BD LPCM can't carry (e.g. 44.1 kHz DVD) is dropped from the output. pub fn create(mut writer: impl Write + Send + 'static, title: &DiscTitle) -> io::Result { + let mut out = title.clone(); + out.streams.clear(); + out.codec_privates.clear(); + let mut route = Vec::with_capacity(title.streams.len()); + for (i, s) in title.streams.iter().enumerate() { + let mut cp = title.codec_privates.get(i).cloned().flatten(); + let lpcm = match s { + DiscStream::Audio(a) if a.codec == crate::disc::Codec::Lpcm => { + let src = cp.as_deref().and_then(super::codec::lpcm::layout_byte); + let h = super::codec::lpcm::bd_header( + a.channels.count(), + a.sample_rate.hz() as u32, + src, + ); + if h.is_none() { + tracing::warn!( + target: "mux", + track = i, + "LPCM layout/rate not representable as BD LPCM; track omitted from M2TS" + ); + route.push(None); + continue; + } + // Advertise the layout actually packed, not a rejected source byte. + cp = h.map(|h| super::codec::lpcm::tagged_layout(h[0])); + h + } + _ => None, + }; + route.push(Some((out.streams.len(), lpcm))); + out.streams.push(s.clone()); + out.codec_privates.push(cp); + } // Write FMKV header unconditionally: skipping it for a zero-stream title // would make the output indistinguishable from a non-FMKV file on // read-back (read_header → Ok(None) → PMT fallback). Empty array is valid. - let m = meta::M2tsMeta::from_title(title); + let m = meta::M2tsMeta::from_title(&out); meta::write_header(&mut writer, &m)?; - let pids: Vec = title + let pids: Vec = out .streams .iter() .map(|s| match s { @@ -41,17 +78,12 @@ impl M2tsStream { // Declaring the codec decides both ES framing (HEVC/H.264 are length- // prefixed → need Annex-B conversion; MPEG-2/VC-1 are already start-code // ES) and which param-set parser applies (avcC vs hvcC) — kept as one fact. - for (i, s) in title.streams.iter().enumerate() { + for (i, s) in out.streams.iter().enumerate() { if let DiscStream::Video(v) = s { muxer.set_video_codec(i, v.codec)?; } } - for (i, cp) in title.codec_privates.iter().enumerate() { - // codec_privates is parallel to streams/pids; ignore trailing entries - // beyond the track count rather than error on a benign metadata overrun. - if i >= pids.len() { - break; - } + for (i, cp) in out.codec_privates.iter().enumerate() { if let Some(data) = cp { muxer.set_codec_private(i, data.clone())?; } @@ -59,6 +91,7 @@ impl M2tsStream { Ok(Self { disc_title: title.clone(), muxer, + route, }) } } @@ -72,8 +105,30 @@ impl crate::pes::Stream for M2tsStream { } fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> { - self.muxer - .write_frame(frame.track, frame.pts, frame.keyframe, &frame.data) + match self.route.get(frame.track).copied() { + // Dropped at create (already warned): nothing to write. + Some(None) => Ok(()), + // Parser output is plain 24-bit PCM; BD-TS needs the BD LPCM framing back. + Some(Some((track, Some(header)))) => { + for (offset_ns, payload) in super::codec::lpcm::bd_payloads(&frame.data, header) { + self.muxer.write_frame( + track, + frame.pts.saturating_add(offset_ns), + frame.keyframe, + &payload, + )?; + } + Ok(()) + } + Some(Some((track, None))) => { + self.muxer + .write_frame(track, frame.pts, frame.keyframe, &frame.data) + } + // Out of range: let the muxer report its usual error. + None => self + .muxer + .write_frame(frame.track, frame.pts, frame.keyframe, &frame.data), + } } fn finish(&mut self) -> io::Result<()> { @@ -84,6 +139,13 @@ impl crate::pes::Stream for M2tsStream { &self.disc_title } + fn undelivered_streams(&self) -> Vec { + // Known from create: LPCM BD LPCM can't carry is never written. + (0..self.route.len()) + .filter(|&i| self.route[i].is_none()) + .collect() + } + fn codec_private(&self, _track: usize) -> Option> { // Write side doesn't have parsers; codec_private flows in // via the title metadata at `create` time and gets baked @@ -311,4 +373,235 @@ mod tests { let flags = h[5]; assert_eq!(flags & 0x40, 0x40, "RAI bit set"); } + #[test] + fn lpcm_round_trips_through_m2ts_write_and_read() { + // The parser emits 24-bit WAVE-order PCM; the M2TS writer must re-synthesise + // the BD LPCM header, pad channel and BD channel order so our own m2ts:// + // reader (LpcmParser::new) recovers identical PCM. + use crate::disc::{AudioChannels, AudioStream, LabelPurpose, SampleRate}; + use crate::mux::codec::CodecParser; + const AUDIO_PID: u16 = 0x1100; + for (channels, samples) in [ + (AudioChannels::Mono, 240), + (AudioChannels::Stereo21, 240), + (AudioChannels::Surround51, 240), + (AudioChannels::Surround71, 240), + (AudioChannels::Mono, 20_000), + (AudioChannels::Stereo, 20_000), + (AudioChannels::Stereo21, 20_000), + (AudioChannels::Quad, 20_000), + ] { + let mut title = make_title(); + title.streams.push(DiscStream::Audio(AudioStream { + pid: AUDIO_PID, + codec: Codec::Lpcm, + channels, + language: "eng".into(), + sample_rate: SampleRate::S48, + secondary: false, + purpose: LabelPurpose::Normal, + label: String::new(), + })); + let shared = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let mut stream = M2tsStream::create(SharedSink(shared.clone()), &title).unwrap(); + let ch = channels.count() as usize; + let pcm: Vec = (0..samples * ch * 3).map(|i| (i % 251) as u8).collect(); + for (i, chunk) in [&pcm[..], &pcm[..]].iter().enumerate() { + stream + .write(&PesFrame { + discard_padding_ns: 0, + coding: None, + source: None, + track: 1, + pts: i as i64 * samples as i64 * 1_000_000_000 / 48_000, + keyframe: true, + data: chunk.to_vec(), + duration_ns: None, + }) + .unwrap(); + } + stream.finish().unwrap(); + drop(stream); + + let buf = shared.lock().unwrap().clone(); + let mut cursor = std::io::Cursor::new(&buf); + super::meta::read_header(&mut cursor).unwrap().unwrap(); + let ts = &buf[cursor.position() as usize..]; + let mut demux = crate::mux::ts::TsDemuxer::new(&[AUDIO_PID]); + let mut pes = demux.feed(ts); + pes.extend(demux.flush()); + let mut parser = crate::mux::codec::lpcm::LpcmParser::new(); + let got: Vec = pes + .iter() + .flat_map(|p| parser.parse(p)) + .flat_map(|f| f.data) + .collect(); + let mut want = pcm.clone(); + want.extend_from_slice(&pcm); + assert_eq!( + got.len(), + want.len(), + "{channels:?} x{samples}: no bytes lost" + ); + assert_eq!( + got, want, + "{channels:?} x{samples}: PCM survives m2ts write -> read" + ); + } + } + fn lpcm_title( + channels: crate::disc::AudioChannels, + rate: crate::disc::SampleRate, + ) -> DiscTitle { + let mut title = make_title(); + title + .streams + .push(DiscStream::Audio(crate::disc::AudioStream { + pid: 0x1100, + codec: Codec::Lpcm, + channels, + language: "eng".into(), + sample_rate: rate, + secondary: false, + purpose: crate::disc::LabelPurpose::Normal, + label: String::new(), + })); + title + } + + fn ts_after_header(buf: &[u8]) -> (crate::mux::meta::M2tsMeta, Vec) { + let mut cursor = std::io::Cursor::new(buf); + let m = super::meta::read_header(&mut cursor).unwrap().unwrap(); + (m, buf[cursor.position() as usize..].to_vec()) + } + + #[test] + fn uncarriable_lpcm_is_dropped_from_the_output_at_create() { + // BD LPCM has no 44.1 kHz: the track must not be advertised (FMKV/PMT) + // with an empty PID; other tracks keep working. + use crate::disc::{AudioChannels, SampleRate}; + let title = lpcm_title(AudioChannels::Stereo, SampleRate::S44_1); + let shared = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let mut stream = M2tsStream::create(SharedSink(shared.clone()), &title).unwrap(); + for track in [0, 1] { + stream + .write(&PesFrame { + discard_padding_ns: 0, + coding: None, + source: None, + track, + pts: 0, + keyframe: true, + data: if track == 0 { + fake_idr_pes_data() + } else { + vec![0; 12] + }, + duration_ns: None, + }) + .unwrap(); + } + stream.finish().unwrap(); + drop(stream); + let buf = shared.lock().unwrap().clone(); + let (meta, ts) = ts_after_header(&buf); + assert_eq!(meta.streams.len(), 1, "only the video track is advertised"); + let has_pid = |pid: u16| { + ts.as_chunks::<192>().0.iter().any(|p| { + let h = &p[4..]; + (((h[1] & 0x1F) as u16) << 8 | h[2] as u16) == pid + }) + }; + assert!(has_pid(VIDEO_PID), "video still written"); + assert!(!has_pid(0x1100), "no packets on the dropped LPCM PID"); + } + + #[test] + fn lpcm_reuses_the_source_channel_assignment() { + // A source 2/2 (assignment 7) must not be re-labelled 3/1 (4ch default 6). + use crate::disc::{AudioChannels, SampleRate}; + let mut title = lpcm_title(AudioChannels::Quad, SampleRate::S48); + title.codec_privates = vec![None, Some(b"BDLP\x71".to_vec())]; + let shared = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let mut stream = M2tsStream::create(SharedSink(shared.clone()), &title).unwrap(); + stream + .write(&PesFrame { + discard_padding_ns: 0, + coding: None, + source: None, + track: 1, + pts: 0, + keyframe: true, + data: vec![0; 240 * 4 * 3], + duration_ns: None, + }) + .unwrap(); + stream.finish().unwrap(); + drop(stream); + let buf = shared.lock().unwrap().clone(); + let (_, ts) = ts_after_header(&buf); + let mut demux = crate::mux::ts::TsDemuxer::new(&[0x1100]); + let mut pes = demux.feed(&ts); + pes.extend(demux.flush()); + assert_eq!( + pes[0].data[2], 0x71, + "BD header keeps channel_assignment 7 (2/2)" + ); + } + #[test] + fn lpcm_ignores_an_untagged_foreign_layout_byte() { + use crate::disc::{AudioChannels, SampleRate}; + let mut title = lpcm_title(AudioChannels::Quad, SampleRate::S48); + title.codec_privates = vec![None, Some(vec![0x71])]; + let shared = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let mut stream = M2tsStream::create(SharedSink(shared.clone()), &title).unwrap(); + stream + .write(&PesFrame { + discard_padding_ns: 0, + coding: None, + source: None, + track: 1, + pts: 0, + keyframe: true, + data: vec![0; 240 * 4 * 3], + duration_ns: None, + }) + .unwrap(); + stream.finish().unwrap(); + drop(stream); + let buf = shared.lock().unwrap().clone(); + let (_, ts) = ts_after_header(&buf); + let mut demux = crate::mux::ts::TsDemuxer::new(&[0x1100]); + let mut pes = demux.feed(&ts); + pes.extend(demux.flush()); + assert_eq!( + pes[0].data[2], 0x61, + "count default (4.0), not the foreign byte" + ); + } + + #[test] + fn dropped_lpcm_is_reported_undelivered() { + use crate::disc::{AudioChannels, SampleRate}; + let title = lpcm_title(AudioChannels::Stereo, SampleRate::S44_1); + let sink = SharedSink(std::sync::Arc::new(std::sync::Mutex::new(Vec::new()))); + let stream = M2tsStream::create(sink, &title).unwrap(); + assert_eq!(stream.undelivered_streams(), vec![1]); + } + #[test] + fn fmkv_header_carries_the_layout_byte_actually_used() { + // 2ch stream with a stale 5.1 layout byte: packing falls back to stereo, so + // the FMKV header must carry the stereo byte, not the rejected 5.1 one. + use crate::disc::{AudioChannels, SampleRate}; + let mut title = lpcm_title(AudioChannels::Stereo, SampleRate::S48); + title.codec_privates = vec![None, Some(b"BDLP\x91".to_vec())]; + let shared = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let stream = M2tsStream::create(SharedSink(shared.clone()), &title).unwrap(); + drop(stream); + let buf = shared.lock().unwrap().clone(); + let mut cursor = std::io::Cursor::new(&buf); + let meta = super::meta::read_header(&mut cursor).unwrap().unwrap(); + let back = meta.to_title(); + assert_eq!(back.codec_privates[1], Some(b"BDLP\x31".to_vec())); + } } diff --git a/src/mux/mkv.rs b/src/mux/mkv.rs index bbd3e9ba..66bf69e9 100644 --- a/src/mux/mkv.rs +++ b/src/mux/mkv.rs @@ -450,10 +450,12 @@ impl MkvTrack { field_duration_ns: 0, sample_rate: sr, channels: ch, - // KNOWN GAP, not deliberate: Matroska requires BitDepth for `A_PCM/INT/*`, - // but `LpcmParser` strips the BD/DVD framing that carries it and - // `AudioStream` has no bit-depth field; guessing 16 would be worse than 0. - bit_depth: 0, + // Matroska requires BitDepth for `A_PCM/INT/*`; `LpcmParser` always emits 24-bit. + bit_depth: if a.codec == Codec::Lpcm { + super::codec::lpcm::OUTPUT_BIT_DEPTH + } else { + 0 + }, dv_config: None, hdr10: None, mvc_params: None, @@ -933,7 +935,10 @@ impl MkvMuxer { ebml::write_uint(&mut writer, ebml::FLAG_FORCED, 1)?; } - if let Some(ref cp) = track.codec_private { + // A_PCM defines no CodecPrivate; the LPCM parser's is BD re-mux metadata. + if let Some(ref cp) = track.codec_private + && track.codec_id != ebml::CODEC_PCM_BE + { match mvc_record.as_ref() { // MVC (Blu-ray 3D) base track: CodecPrivate = base-view avcC + `mvcC` // extension, the track-level signal decoders read to recognise the @@ -2471,6 +2476,37 @@ mod tests { } } + #[test] + fn lpcm_track_writes_bitdepth_24() { + // `LpcmParser` always emits 24-bit BE PCM; without BitDepth players assume + // 16-bit and decode noise. Check the element is actually written. + let track = MkvTrack::audio(&audio_stream(Codec::Lpcm)); + assert_eq!(track.bit_depth, 24); + let mut buf = Cursor::new(Vec::new()); + MkvMuxer::new(&mut buf, &[track], None, 0.0, &[]).unwrap(); + let bytes = buf.into_inner(); + // BitDepth (0x6264), size 1, value 24. + assert!( + bytes.windows(4).any(|w| w == [0x62, 0x64, 0x81, 24]), + "BitDepth=24 element present" + ); + } + + #[test] + fn lpcm_track_omits_codec_private() { + // The LPCM parser's codec_private is BD re-mux metadata (channel_assignment); + // A_PCM defines no CodecPrivate, so it must not reach the track header. + let mut track = MkvTrack::audio(&audio_stream(Codec::Lpcm)); + track.codec_private = Some(vec![0x91]); + let mut buf = Cursor::new(Vec::new()); + MkvMuxer::new(&mut buf, &[track], None, 0.0, &[]).unwrap(); + let bytes = buf.into_inner(); + assert!( + !bytes.windows(2).any(|w| w == [0x63, 0xA2]), + "no CodecPrivate" + ); + } + #[test] fn dts_variants_map_to_registered_a_dts_codec_id() { // `A_DTS` is the sole registered ID for the whole DTS family; the `/MA` and diff --git a/src/mux/ps.rs b/src/mux/ps.rs index d09ec4dc..2330fa18 100644 --- a/src/mux/ps.rs +++ b/src/mux/ps.rs @@ -540,7 +540,9 @@ fn parse_pes_packet(data: &[u8]) -> Option { // first_access_unit_pointer(2)), verified on a real HD-DVD EVO. Strip exactly 4 // bytes/packet for a clean ES; a shorter skip splices sub-header into a frame. 0xC0..=0xC7 => 4, - 0xA0..=0xA7 => 7, // LPCM: sub_id + frames + ptr(2) + emphasis + quant_freq + channels + // LPCM: sub_id + frames + ptr(2); the 3-byte audio header (quant/rate/channels) + // is left for `LpcmParser`, which needs it to unpack 20/24-bit samples. + 0xA0..=0xA7 => 4, _ => 1, }; let start = skip.min(payload.len()); @@ -759,7 +761,8 @@ mod tests { 0x00, 0x00, 0x01, 0xBD, 0x00, 0x0C, // length = 12 0x80, 0x00, 0x00, // no PTS, header_data_len=0 0xA0, // sub-stream ID: LPCM stream 0 - 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, // LPCM sub-header (6 bytes after sub_id) + 0x01, 0x00, 0x00, // frames + first_access_unit ptr (stripped) + 0x00, 0x00, 0x00, // audio header (kept for the LPCM parser) 0x01, 0x02, // LPCM payload ]; data.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]); @@ -767,7 +770,8 @@ mod tests { let packets = demuxer.feed(&data); assert_eq!(packets.len(), 1); assert_eq!(packets[0].sub_stream_id, Some(0xA0)); - assert_eq!(packets[0].data, vec![0x01, 0x02]); + // The 3-byte audio header (quant/rate/channels) stays for the LPCM parser. + assert_eq!(packets[0].data, vec![0x00, 0x00, 0x00, 0x01, 0x02]); } // --- Incremental feeding --- diff --git a/src/pes.rs b/src/pes.rs index ea58c40e..d8435f24 100644 --- a/src/pes.rs +++ b/src/pes.rs @@ -282,10 +282,9 @@ pub trait Stream: Send { /// Sink side: `info().streams` indices this sink PLANNED to carry (and /// accepted frames for) but could not put in the finished container, valid - /// after [`finish`](Self::finish). Empty for every sink that writes - /// everything it accepted — which is all of them except `mp4://`, whose - /// `finish()` must drop an audio track no frame of which yielded a parseable - /// sample entry (an `stsd` cannot describe it). + /// after [`finish`](Self::finish). Empty for every sink that writes everything + /// it accepted, except `mp4://` (audio with no parseable sample entry, known at + /// finish) and `m2ts://` (LPCM BD LPCM can't carry, known from creation). fn undelivered_streams(&self) -> Vec { // Without this, such a drop would silently contradict the pre-mux // plan the crate publishes (`mp4_fit_report`); the driver folds diff --git a/src/scsi/fd_handoff.rs b/src/scsi/fd_handoff.rs index 7a898dc9..305f2f5a 100644 --- a/src/scsi/fd_handoff.rs +++ b/src/scsi/fd_handoff.rs @@ -13,12 +13,64 @@ //! ``` //! Under `all(loom, test)`, callers must execute inside `loom::model`. -// See "Building the model" above for why this is gated on `test` too. +// Gated on `test` too: a `--cfg loom` build of the library alone keeps std atomics. #[cfg(all(loom, test))] pub(crate) use loom::sync::atomic::{AtomicBool, AtomicI32, Ordering}; #[cfg(not(all(loom, test)))] pub(crate) use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; +/// Reserve one of `max` recovery-thread slots on `counter`, atomically (no +/// load-then-add TOCTOU). `false` past the cap, with nothing left reserved. +pub(crate) fn reserve_recovery_slot(counter: &std::sync::atomic::AtomicUsize, max: usize) -> bool { + use std::sync::atomic::Ordering::AcqRel; + if counter.fetch_add(1, AcqRel) < max { + true + } else { + counter.fetch_sub(1, AcqRel); + false + } +} + +/// Give back a slot taken by [`reserve_recovery_slot`]. +pub(crate) fn release_recovery_slot(counter: &std::sync::atomic::AtomicUsize) { + counter.fetch_sub(1, std::sync::atomic::Ordering::AcqRel); +} + +/// The fd `Drop` should send ALLOW MEDIUM REMOVAL on, if any: only when this +/// transport holds a PREVENT, only on an fd it already owns (the live one, else +/// a published recovery fd), and never after an ALLOW already hit a dead bus. +pub(crate) fn drop_unlock_fd( + prevent_held: bool, + allow_transport_failed: bool, + fd: i32, + recovered: Option, +) -> Option { + if !prevent_held || allow_transport_failed { + return None; + } + if fd >= 0 { Some(fd) } else { recovered } +} + +/// How `Drop` sends its ALLOW MEDIUM REMOVAL. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum DropUnlock { + /// No PREVENT of ours to clear, or the thread cap is full (SG_IO already + /// stuck): just close. + Skip, + /// On a capped detached thread: SG_IO on a wedged node can block for long. + /// If the spawn itself fails, the caller sends ALLOW inline instead. + Detached(i32), +} + +/// Pick the [`DropUnlock`] for `unlock_fd` (from [`drop_unlock_fd`]), given +/// whether a recovery-thread slot was reserved. +pub(crate) fn drop_unlock_plan(unlock_fd: Option, slot: bool) -> DropUnlock { + match unlock_fd { + Some(fd) if slot => DropUnlock::Detached(fd), + _ => DropUnlock::Skip, + } +} + /// The empty slot. Not a valid fd; `open()` never returns a negative value. pub(crate) const EMPTY: i32 = -1; @@ -101,6 +153,101 @@ mod tests { (AtomicI32::new(EMPTY), AtomicBool::new(false)) } + // ── Drop-time tray unlock ────────────────────────────────────────────── + + /// Drop sends ALLOW only for a transport that holds a PREVENT, only on an fd + /// it already owns (never an inline reopen of a possibly wedged node), and + /// not again after an ALLOW already died on the transport. + #[test] + fn drop_unlock_fd_uses_only_an_owned_fd_and_only_after_prevent() { + assert_eq!( + drop_unlock_fd(false, false, 5, None), + None, + "never PREVENTed" + ); + assert_eq!(drop_unlock_fd(false, false, -1, Some(7)), None); + assert_eq!(drop_unlock_fd(true, false, 5, None), Some(5)); + assert_eq!(drop_unlock_fd(true, false, 5, Some(7)), Some(5)); + assert_eq!( + drop_unlock_fd(true, false, -1, Some(7)), + Some(7), + "recovered fd" + ); + assert_eq!( + drop_unlock_fd(true, false, -1, None), + None, + "no inline reopen" + ); + assert_eq!( + drop_unlock_fd(true, true, 5, None), + None, + "ALLOW hit a dead bus" + ); + } + + /// 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. + #[test] + fn drop_unlock_plan_skips_when_the_thread_cap_is_full() { + assert_eq!(drop_unlock_plan(None, true), DropUnlock::Skip); + assert_eq!(drop_unlock_plan(None, false), DropUnlock::Skip); + assert_eq!(drop_unlock_plan(Some(5), true), DropUnlock::Detached(5)); + assert_eq!(drop_unlock_plan(Some(5), false), DropUnlock::Skip); + } + + // ── Recovery-thread cap ──────────────────────────────────────────────── + + /// Past the cap nothing stays reserved (a leaked reservation would ratchet the + /// counter to the cap and force every later reopen inline), and a release + /// frees the slot again. + #[test] + fn recovery_slot_cap_gives_back_an_over_cap_reservation() { + use std::sync::atomic::{AtomicUsize, Ordering::Relaxed}; + let counter = AtomicUsize::new(0); + assert!(reserve_recovery_slot(&counter, 2)); + assert!(reserve_recovery_slot(&counter, 2)); + for _ in 0..5 { + assert!(!reserve_recovery_slot(&counter, 2), "over the cap"); + } + assert_eq!(counter.load(Relaxed), 2, "over-cap attempts must not leak"); + release_recovery_slot(&counter); + assert!( + reserve_recovery_slot(&counter, 2), + "a released slot is reusable" + ); + } + + /// Concurrent reservers never hold more than `max` slots at once. + #[test] + fn recovery_slot_cap_holds_under_contention() { + use std::sync::atomic::{AtomicUsize, Ordering::Relaxed}; + const MAX: usize = 3; + let counter = AtomicUsize::new(0); + let held = AtomicUsize::new(0); + let peak = AtomicUsize::new(0); + std::thread::scope(|sc| { + for _ in 0..8 { + sc.spawn(|| { + for _ in 0..2_000 { + if reserve_recovery_slot(&counter, MAX) { + let now = held.fetch_add(1, Relaxed) + 1; + peak.fetch_max(now, Relaxed); + held.fetch_sub(1, Relaxed); + release_recovery_slot(&counter); + } + } + }); + } + }); + assert!( + peak.load(Relaxed) <= MAX, + "cap exceeded: {}", + peak.load(Relaxed) + ); + assert_eq!(counter.load(Relaxed), 0, "every reservation returned"); + } + // ── Positive: the ordinary hand-off ──────────────────────────────────── /// The path taken on every successful recovery: the thread publishes, the diff --git a/src/scsi/linux.rs b/src/scsi/linux.rs index 1bf09fed..cf10bc10 100644 --- a/src/scsi/linux.rs +++ b/src/scsi/linux.rs @@ -1,13 +1,14 @@ //! Linux SCSI transport via synchronous blocking SG_IO ioctl. //! //! `execute()` is one syscall: `ioctl(fd, SG_IO, &hdr)` blocks until the -//! kernel completes the command (success, error, or its own timeout). -//! No userspace abort, no fd close+reopen, no SG_SCSI_RESET escalation — -//! the kernel SCSI mid-layer's own error-handling ladder runs internally -//! when `hdr.timeout` expires. +//! kernel completes the command (success, error, or its own timeout). No +//! userspace abort or SG_SCSI_RESET: the kernel mid-layer runs its own error +//! ladder. On a transport-level failure the fd is closed and reopened (on +//! detached threads, see `fd_handoff`), so the fd is NOT stable across one. use super::fd_handoff::{ - AtomicBool, AtomicI32, claim_for_teardown, publish_recovered_fd, take_recovered_fd, + AtomicBool, AtomicI32, DropUnlock, claim_for_teardown, drop_unlock_fd, drop_unlock_plan, + publish_recovered_fd, release_recovery_slot, reserve_recovery_slot, take_recovered_fd, }; use super::{DataDirection, ScsiResult, ScsiTransport}; use crate::error::{Error, Result}; @@ -28,6 +29,9 @@ const K_MAX_CDB_SIZE: usize = 16; /// SPC-4 PREVENT ALLOW MEDIUM REMOVAL (0x1E) with PREVENT=0. Sent by `Drop` so /// the tray is not left locked; a six-byte group-0 CDB. const ALLOW_MEDIUM_REMOVAL: [u8; 6] = [0x1E, 0, 0, 0, 0, 0]; +/// PREVENT field values (CDB byte 4 bits 1:0) tracked for the Drop unlock. +const ALLOW: u8 = 0b00; +const PREVENT: u8 = 0b01; /// Cap on detached fd-recovery threads outstanding at once (process-wide). /// A sustained bridge wedge would otherwise spawn 2 threads per failed ioctl @@ -70,13 +74,20 @@ const _: () = assert!(std::mem::size_of::() == 88); const _: () = assert!(std::mem::size_of::() == 64); pub struct SgIoTransport { - pub fd: i32, + // Private: execute()/Drop close whatever these hold, so safe code outside + // must never be able to plant a descriptor it does not own (I/O safety). + fd: i32, device_path: std::path::PathBuf, /// Single-slot mailbox a background recovery thread publishes a freshly /// opened fd into. Drained by `execute()`, or claimed by `Drop` if the /// transport dies first. The protocol — and the memory ordering that makes /// it leak-free — lives in `super::fd_handoff`. - pub fd_recovery: Arc, + fd_recovery: Arc, + /// A PREVENT MEDIUM REMOVAL was issued and not yet cleared by an ALLOW; + /// only then does `Drop` unlock the tray (enumeration probes never do). + prevent_held: bool, + /// The last ALLOW died on the transport: Drop must not retry it. + allow_transport_failed: bool, /// Set to `true` by `Drop` before it claims the slot. A recovery thread /// that publishes after that point sees it and closes its own fd, since /// nothing will ever drain the slot again. @@ -86,31 +97,50 @@ pub struct SgIoTransport { impl SgIoTransport { /// Open a SCSI device for use. pub fn open(device: &Path) -> Result { + Self::open_with_errno(device).map_err(|(e, _)| e) + } + + // `open`, also handing back the failed open(2)'s errno. + fn open_with_errno(device: &Path) -> std::result::Result)> { let device = Self::resolve_to_sg(device); - let c_path = Self::to_c_path(&device); - let fd = unsafe { - libc::open( - c_path.as_ptr() as *const libc::c_char, - libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC, - ) - }; + let fd = Self::open_fd(&device); if fd < 0 { - return Self::open_error(&device); + let err = std::io::Error::last_os_error(); + return Err((Self::map_open_error(&err, &device), err.raw_os_error())); } Ok(SgIoTransport { fd, device_path: device, fd_recovery: Arc::new(AtomicI32::new(super::fd_handoff::EMPTY)), + prevent_held: false, + allow_transport_failed: false, dead: Arc::new(AtomicBool::new(false)), }) } + // open(2) the sg node the way every path here does; negative on failure. + fn open_fd(device: &Path) -> i32 { + let c_path = Self::to_c_path(device); + unsafe { + libc::open( + c_path.as_ptr() as *const libc::c_char, + libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC, + ) + } + } + // Map errno from a failed open(): permission-denied -> DevicePermission, // else DeviceNotFound. Path carried in the error; no English text (app // layer localizes). fn open_error(device: &Path) -> Result { - let err = std::io::Error::last_os_error(); - Err(if err.kind() == std::io::ErrorKind::PermissionDenied { + Err(Self::map_open_error( + &std::io::Error::last_os_error(), + device, + )) + } + + fn map_open_error(err: &std::io::Error, device: &Path) -> Error { + if err.kind() == std::io::ErrorKind::PermissionDenied { Error::DevicePermission { path: device.display().to_string(), } @@ -118,15 +148,12 @@ impl SgIoTransport { Error::DeviceNotFound { path: device.display().to_string(), } - }) + } } - /// Send a raw SCSI command on a bare fd, for the one caller that has no - /// live `&mut self` to route through `execute()`: `Drop`, unlocking the - /// tray on its way out. - /// - /// The CDB goes through the shared [`super::checked_cdb_len`] guard rather than the old - /// `cdb.len().min(16)` clamp. + /// Send a no-data SCSI command on a bare fd, for callers with no transport + /// to route through `execute()`: `Drop`'s tray unlock and `disc_presence`. + /// The CDB goes through the shared [`super::checked_cdb_len`] guard. fn raw_command(fd: i32, cdb: &[u8], timeout_ms: u32) -> Result<()> { let cmd_len = super::checked_cdb_len(cdb, K_MAX_CDB_SIZE)?; let mut sense = [0u8; 32]; @@ -208,16 +235,51 @@ impl SgIoTransport { impl Drop for SgIoTransport { fn drop(&mut self) { - if self.fd >= 0 { - // Unlock tray before closing — don't leave it locked. - let _ = Self::raw_command(self.fd, &ALLOW_MEDIUM_REMOVAL, 3_000); - unsafe { libc::close(self.fd) }; - } // A failed execute() spawns a thread that opens a fresh fd into // fd_recovery, normally drained at the top of the next execute(). If - // dropped first (abort-on-wedge), claim and close it here. - if let Some(recovered) = claim_for_teardown(&self.fd_recovery, &self.dead) { - unsafe { libc::close(recovered) }; + // dropped first (abort-on-wedge), claim it here. + let recovered = claim_for_teardown(&self.fd_recovery, &self.dead); + let owned: Vec = [Some(self.fd), recovered] + .into_iter() + .flatten() + .filter(|&f| f >= 0) + .collect(); + let close_all = move |fds: &[i32]| { + for &f in fds { + unsafe { libc::close(f) }; + } + }; + let unlock = drop_unlock_fd( + self.prevent_held, + self.allow_transport_failed, + self.fd, + recovered, + ); + let unlock_inline = |fd: i32| { + let _ = Self::raw_command(fd, &ALLOW_MEDIUM_REMOVAL, 3_000); + close_all(&owned); + }; + // Cap full = SG_IO calls already stuck: close without ALLOW rather than + // block here on the kernel EH ladder. Only a failed spawn (a resource + // limit, not a wedge) sends ALLOW inline. + let slot = + unlock.is_some() && reserve_recovery_slot(&RECOVERY_THREADS, MAX_RECOVERY_THREADS); + match drop_unlock_plan(unlock, slot) { + DropUnlock::Skip => close_all(&owned), + DropUnlock::Detached(fd) => { + let fds = owned.clone(); + let spawned = std::thread::Builder::new().spawn(move || { + let _ = Self::raw_command(fd, &ALLOW_MEDIUM_REMOVAL, 3_000); + close_all(&fds); + release_recovery_slot(&RECOVERY_THREADS); + }); + // Spawn failed: the slot is ours to give back, and the lock still + // needs clearing (closing the fd does not release PREVENT). + if spawned.is_err() { + release_recovery_slot(&RECOVERY_THREADS); + unlock_inline(fd); + } + } } } } @@ -237,6 +299,13 @@ impl ScsiTransport for SgIoTransport { let cmd_len = super::checked_cdb_len(cdb, K_MAX_CDB_SIZE)?; let exec_t0 = std::time::Instant::now(); let opcode = cdb[0]; + // Held from the moment a PREVENT is attempted: its outcome may be unknown. + let removal = super::prevent_allow_request(cdb); + match removal { + Some(PREVENT) => self.prevent_held = true, + Some(ALLOW) => self.allow_transport_failed = false, + _ => {} + } tracing::trace!( target: "freemkv::scsi", phase = "enter", @@ -321,6 +390,9 @@ impl ScsiTransport for SgIoTransport { "transport-level failure (timeout / bridge wedge)" ); + if removal == Some(ALLOW) { + self.allow_transport_failed = true; + } // Spawn recovery: close old fd, open new one in background. // This prevents the main thread from blocking on close() while // the kernel finishes the previous ioctl. @@ -331,24 +403,19 @@ impl ScsiTransport for SgIoTransport { let dead = self.dead.clone(); // Cap outstanding recovery threads (past MAX a sustained wedge spawns - // unbounded threads): reserve the slot ATOMICALLY via fetch_add, act on - // the pre-increment value, and give the slot back if it overshoots. - use std::sync::atomic::Ordering; - if RECOVERY_THREADS.fetch_add(1, Ordering::AcqRel) < MAX_RECOVERY_THREADS { + // unbounded threads); the atomic reservation lives in `fd_handoff`. + if reserve_recovery_slot(&RECOVERY_THREADS, MAX_RECOVERY_THREADS) { std::thread::spawn(move || { if old_fd >= 0 { unsafe { libc::close(old_fd) }; } - RECOVERY_THREADS.fetch_sub(1, Ordering::AcqRel); + release_recovery_slot(&RECOVERY_THREADS); }); - } else { - RECOVERY_THREADS.fetch_sub(1, Ordering::AcqRel); - if old_fd >= 0 { - unsafe { libc::close(old_fd) }; - } + } else if old_fd >= 0 { + unsafe { libc::close(old_fd) }; } - if RECOVERY_THREADS.fetch_add(1, Ordering::AcqRel) < MAX_RECOVERY_THREADS { + if reserve_recovery_slot(&RECOVERY_THREADS, MAX_RECOVERY_THREADS) { std::thread::spawn(move || { // Don't unwrap: a device path with an interior NUL would // panic this detached thread (silently swallowed). Bail @@ -356,7 +423,7 @@ impl ScsiTransport for SgIoTransport { let c_path = match std::ffi::CString::new(path.as_os_str().as_encoded_bytes()) { Ok(c) => c, Err(_) => { - RECOVERY_THREADS.fetch_sub(1, Ordering::AcqRel); + release_recovery_slot(&RECOVERY_THREADS); return; } }; @@ -374,12 +441,11 @@ impl ScsiTransport for SgIoTransport { unsafe { libc::close(orphan) }; } } - RECOVERY_THREADS.fetch_sub(1, Ordering::AcqRel); + release_recovery_slot(&RECOVERY_THREADS); }); } else { - // Over the cap: give the reservation back, then reopen inline - // (blocking this call briefly) instead of spawning a 9th thread. - RECOVERY_THREADS.fetch_sub(1, Ordering::AcqRel); + // Over the cap: reopen inline (blocking this call briefly) + // instead of spawning a 9th thread. if let Ok(c_path) = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()) { let new_fd = unsafe { libc::open( @@ -426,6 +492,9 @@ impl ScsiTransport for SgIoTransport { // Compute in usize so 2-4 GiB transfers don't wrap through an i32 // cast and report a large read as ~0 bytes. Negative resid is // clamped to 0 before subtracting. + if removal == Some(ALLOW) { + self.prevent_held = false; + } let resid = hdr.resid.max(0) as usize; let bytes_transferred = data.len().saturating_sub(resid); tracing::trace!( @@ -444,9 +513,9 @@ impl ScsiTransport for SgIoTransport { } } -// Lightweight discovery + presence (Linux): `list_drives` walks sysfs -// type-5 nodes with an INQUIRY each. `drive_has_disc` sends TEST UNIT -// READY; on the wedge signature (status `0xff`, no sense) it bubbles up. +// Discovery + presence (Linux): `list_drives` walks sysfs type-5 nodes with an +// INQUIRY each; without sysfs it keeps every sg node `keep_unfiltered_node` keeps. +// `disc_presence` sends TEST UNIT READY; a wedge (0xFF, no sense) bubbles up. /// SCSI peripheral type 5 = "CD-ROM device" (covers DVD, BD-ROM, BD-RE, etc.). /// Stored in `/sys/class/scsi_generic/sgN/device/type` as ASCII decimal. @@ -459,7 +528,7 @@ const SG_FALLBACK_MAX: u8 = 16; pub(super) fn list_drives() -> Vec { let mut out = Vec::new(); - let names = enumerate_sg_names(); + let (names, type_filtered) = enumerate_sg_names(); for name in names { let path = format!("/dev/{name}"); if !std::path::Path::new(&path).exists() { @@ -474,22 +543,29 @@ pub(super) fn list_drives() -> Vec { // INQUIRY-only probe — open transport, run INQUIRY, drop. No // identify, no init, no firmware reset preamble's secondary // commands beyond what `SgIoTransport::open` already does. - let info = match SgIoTransport::open(std::path::Path::new(&path)) { - Ok(mut transport) => match super::inquiry(&mut transport) { - Ok(r) => super::DriveInfo { - path: path.clone(), - vendor: pick_identity(r.vendor_id, &sysfs_vendor), - model: pick_identity(r.model, &sysfs_model), - firmware: pick_identity(r.firmware, &sysfs_firmware), - }, - Err(_) => super::DriveInfo { - path: path.clone(), - vendor: sysfs_vendor, - model: sysfs_model, - firmware: sysfs_firmware, - }, + let probed = SgIoTransport::open_with_errno(std::path::Path::new(&path)) + .map(|mut t| super::inquiry(&mut t)); + if !type_filtered { + let probe = match &probed { + Ok(Ok(r)) if super::is_optical_peripheral(&r.raw) => super::NodeProbe::Optical, + Ok(Ok(_)) => super::NodeProbe::NotOptical, + Ok(Err(_)) => super::NodeProbe::Unresponsive, + Err((_, errno)) => super::open_failure_probe(*errno), + }; + if !super::keep_unfiltered_node(probe) { + continue; + } + } + let info = match probed { + Ok(Ok(r)) => super::DriveInfo { + path: path.clone(), + vendor: pick_identity(r.vendor_id, &sysfs_vendor), + model: pick_identity(r.model, &sysfs_model), + firmware: pick_identity(r.firmware, &sysfs_firmware), }, - Err(_) => super::DriveInfo { + // Present but unresponsive (wedged, busy, or sysfs-typed but denied): + // listed from what sysfs knows, so autorip reports it, not an unplug. + _ => super::DriveInfo { path: path.clone(), vendor: sysfs_vendor, model: sysfs_model, @@ -524,11 +600,12 @@ fn sysfs_identity(name: &str) -> (String, String, String) { (read("vendor"), read("model"), read("rev")) } -// Enumerate `sg*` names via `/sys/class/scsi_generic/`, filtered to type 5 -// (optical). Falls back to a `sg0..15` probe when sysfs is unreadable. -// Names sorted lexically so caller iteration is deterministic. -fn enumerate_sg_names() -> Vec { +// `sg*` names via `/sys/class/scsi_generic/`, filtered to type 5 (optical), and +// whether that filter applied: false for the unfiltered `sg0..15` fallback when +// sysfs is unreadable. Sorted so caller iteration is deterministic. +pub(crate) fn enumerate_sg_names() -> (Vec, bool) { let mut names = Vec::new(); + let mut type_filtered = true; if let Ok(entries) = std::fs::read_dir("/sys/class/scsi_generic") { for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().to_string(); @@ -546,8 +623,9 @@ fn enumerate_sg_names() -> Vec { } } } else { - // Sysfs missing — fall back to a brute-force probe. The INQUIRY - // step in `list_drives` filters non-optical responses naturally. + // Sysfs missing: brute-force probe, unfiltered — callers must check + // the INQUIRY peripheral type themselves. + type_filtered = false; for i in 0..SG_FALLBACK_MAX { let name = format!("sg{i}"); if std::path::Path::new(&format!("/dev/{name}")).exists() { @@ -556,70 +634,67 @@ fn enumerate_sg_names() -> Vec { } } names.sort(); - names + (names, type_filtered) } /// Send TEST UNIT READY directly — no transport, no reset, no side effects. -pub(super) fn drive_has_disc(path: &Path) -> Result { +pub(super) fn disc_presence(path: &Path) -> Result { let device = SgIoTransport::resolve_to_sg(path); - let c_path = SgIoTransport::to_c_path(&device); - let fd = unsafe { - libc::open( - c_path.as_ptr() as *const libc::c_char, - libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC, - ) - }; + let fd = SgIoTransport::open_fd(&device); if fd < 0 { return SgIoTransport::open_error(&device); } - let cdb = [crate::scsi::SCSI_TEST_UNIT_READY, 0, 0, 0, 0, 0]; - let mut sense = [0u8; 32]; - let mut hdr: sg_io_hdr = unsafe { std::mem::zeroed() }; - hdr.interface_id = b'S' as i32; - hdr.dxfer_direction = SG_DXFER_NONE; - hdr.cmd_len = cdb.len() as u8; - hdr.mx_sb_len = sense.len() as u8; - hdr.dxfer_len = 0; - hdr.dxferp = std::ptr::null_mut(); - hdr.cmdp = cdb.as_ptr(); - hdr.sbp = sense.as_mut_ptr(); - hdr.timeout = crate::scsi::TUR_TIMEOUT_MS; - hdr.flags = SG_FLAG_Q_AT_HEAD; - - let ret = unsafe { libc::ioctl(fd, SG_IO as _, &mut hdr as *mut sg_io_hdr) }; - // Capture the ioctl errno BEFORE close(): POSIX permits close() to - // set errno (e.g. EIO on a flaky USB path), which would otherwise - // clobber the ioctl failure reason reported below. - let ioctl_err = std::io::Error::last_os_error(); + let r = super::tur_disc_presence(|| { + SgIoTransport::raw_command(fd, &cdb, crate::scsi::TUR_TIMEOUT_MS) + }); unsafe { libc::close(fd) }; + r +} - if ret < 0 { - return Err(Error::IoError { source: ioctl_err }); - } +// After a transport failure the fd is reopened in the background and the next +// execute() adopts it. Needs a real device: FREEMKV_TEST_SG_DEVICE (default sg2). +#[cfg(test)] +mod recovery_device_tests { + use super::*; + use std::time::Duration; - let driver_status_real = hdr.driver_status & !super::DRIVER_SENSE; - if hdr.host_status != 0 || driver_status_real != 0 { - return Err(Error::ScsiError { - opcode: cdb[0], - status: super::SCSI_STATUS_TRANSPORT_FAILURE, - sense: None, - }); - } + #[test] + #[ignore] + fn timeout_does_not_kill_transport() { + let device = + std::env::var("FREEMKV_TEST_SG_DEVICE").unwrap_or_else(|_| "/dev/sg2".to_string()); + let mut transport = SgIoTransport::open(Path::new(&device)).expect("open device"); + let fd_before = transport.fd; + // READ(10) with a 1 ms timeout forces a kernel timeout. + let cdb = [0x28, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00]; + let mut data = vec![0u8; 2048]; + let err = transport + .execute(&cdb, DataDirection::FromDevice, &mut data, 1) + .expect_err("1 ms timeout must fail"); + assert!(err.is_scsi_transport_failure(), "got {err:?}"); + assert_eq!(transport.fd, -1, "fd handed to recovery"); - if hdr.status == 0 { - return Ok(true); - } + let mut published = false; + for _ in 0..100 { + if transport + .fd_recovery + .load(std::sync::atomic::Ordering::Acquire) + >= 0 + { + published = true; + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + assert!(published, "recovery thread should have produced a new fd"); - let parsed = super::parse_sense(&sense, hdr.sb_len_wr); - if parsed.is_not_ready() { - Ok(false) - } else { - Err(Error::ScsiError { - opcode: cdb[0], - status: hdr.status, - sense: Some(parsed), - }) + let t0 = std::time::Instant::now(); + let r = transport.execute(&cdb, DataDirection::FromDevice, &mut data, 5_000); + assert!(r.is_ok(), "recovered fd should work: {r:?}"); + assert!(t0.elapsed() < Duration::from_secs(2), "{:?}", t0.elapsed()); + assert_ne!(transport.fd, -1, "fd valid after recovery"); + assert_ne!(transport.fd, fd_before, "fd fresh after recovery"); } } diff --git a/src/scsi/macos.rs b/src/scsi/macos.rs index 07523121..0101f4eb 100644 --- a/src/scsi/macos.rs +++ b/src/scsi/macos.rs @@ -52,7 +52,7 @@ unsafe extern "C" { // Strip the /dev/ (or raw-device /dev/r) prefix off a device path, // yielding the BSD name the shim's IOKit lookups take. Shared by -// MacScsiTransport::open and drive_has_disc so they can't disagree. +// MacScsiTransport::open and disc_presence so they can't disagree. fn bsd_name_of(device: &Path) -> Result<&str> { let dev_str = device.to_str().ok_or_else(|| Error::DeviceNotFound { path: device.display().to_string(), @@ -256,13 +256,13 @@ fn cstr_to_str(bytes: &[u8]) -> &str { // Media-presence probe via the IOKit registry only — no exclusive access, no unmount, no SCSI // command (open() force-unmounts the disc; a probe documented as side-effect-free must never do // that). -pub(super) fn drive_has_disc(path: &Path) -> Result { +pub(super) fn disc_presence(path: &Path) -> Result { let bsd_name = bsd_name_of(path)?; let mut bsd_c = bsd_name.as_bytes().to_vec(); bsd_c.push(0); match unsafe { shim_media_present(bsd_c.as_ptr()) } { - 1 => Ok(true), - 0 => Ok(false), + 1 => Ok(super::DiscPresence::Present), + 0 => Ok(super::DiscPresence::Absent), // -1: IOKit itself is unavailable (IOMainPort / matching-dictionary // failure). That is not "no disc" — surface it rather than report a // false negative the caller would act on. @@ -276,7 +276,7 @@ pub(super) fn drive_has_disc(path: &Path) -> Result { mod tests { use super::{ K_MAX_CDB_SIZE, OPEN, ShimDriveInfo, bsd_name_of, cstr_to_str, device_path_for_selector, - drive_has_disc, drive_info_from_shim, map_shim_open_error, + disc_presence, drive_info_from_shim, map_shim_open_error, }; use crate::error::Error; use std::path::Path; @@ -377,8 +377,8 @@ mod tests { assert_eq!(cstr_to_str(&bytes), ""); } - // Regression test: drive_has_disc used to open a FULL exclusive transport (force-unmount + - // 500ms sleep) on every poll tick. Checks it now answers Ok(false) fast with no lock held. + // Regression test: the presence probe used to open a FULL exclusive transport (force-unmount + + // 500ms sleep) on every poll tick. Checks it now answers Absent fast with no lock held. #[test] fn presence_probe_does_not_open_a_transport() { let path = Path::new("/dev/freemkv-no-such-device"); @@ -387,11 +387,11 @@ mod tests { // scopes the check to this call, can't flake, still catches a held lock. let open_before = OPEN.load(Ordering::Acquire); let t0 = std::time::Instant::now(); - let r = drive_has_disc(path); + let r = disc_presence(path); let elapsed = t0.elapsed(); assert!( - matches!(r, Ok(false)), + matches!(r, Ok(crate::scsi::DiscPresence::Absent)), "a device with no IOMedia must report absent media, got {r:?}" ); assert!( diff --git a/src/scsi/macos_shim.c b/src/scsi/macos_shim.c index ac62f29b..0b407c7b 100644 --- a/src/scsi/macos_shim.c +++ b/src/scsi/macos_shim.c @@ -33,6 +33,8 @@ typedef struct { DADiskRef da_disk; dispatch_queue_t da_queue; int da_claimed; + // Claim whose callback had not arrived by the 5 s timeout; resolved in da_release. + struct DAClaimResult *da_pending; } ShimHandle; typedef struct { @@ -332,7 +334,9 @@ static DADissenterRef da_claim_release(DADiskRef disk, void *ctx) { // other side may still touch it. This matters on the 5 s timeout path, where // da_hold returns but da_claim_done can still fire later — a stack DAClaimResult // would be a use-after-free, and never releasing the semaphore would leak it. -typedef struct { dispatch_semaphore_t sem; int ok; volatile int rc; } DAClaimResult; +typedef struct DAClaimResult { + dispatch_semaphore_t sem; int ok; volatile int done; volatile int rc; +} DAClaimResult; static void da_claim_result_release(DAClaimResult *r) { if (__sync_sub_and_fetch(&r->rc, 1) == 0) { @@ -345,6 +349,7 @@ static void da_claim_done(DADiskRef disk, DADissenterRef dissenter, void *ctx) { (void)disk; DAClaimResult *r = (DAClaimResult *)ctx; r->ok = (dissenter == NULL); + __atomic_store_n(&r->done, 1, __ATOMIC_RELEASE); dispatch_semaphore_signal(r->sem); da_claim_result_release(r); } @@ -375,51 +380,69 @@ static int da_hold(const char *bsd_name) { if (!r) { pthread_mutex_unlock(&g_handle_lock); return 0; } r->sem = dispatch_semaphore_create(0); r->ok = 0; + r->done = 0; r->rc = 2; // one ref held here, one for the async da_claim_done callback DADiskClaim(g_handle.da_disk, kDADiskClaimOptionDefault, da_claim_release, NULL, da_claim_done, r); pthread_mutex_unlock(&g_handle_lock); - // Bounded wait for the async claim callback (5 s), so a wedged DA can't - // hang open() forever. On timeout the callback may still fire later, so we - // must NOT read r->ok or free r here — releasing our ref hands ownership to - // whichever side finishes last (see da_claim_result_release). - int claimed; - if (dispatch_semaphore_wait(r->sem, - dispatch_time(DISPATCH_TIME_NOW, 5LL * NSEC_PER_SEC)) != 0) { - claimed = 0; - } else { + // Bounded wait (5 s) so a wedged DA can't hang open(). On timeout the + // 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; + if (!timed_out) { claimed = r->ok; + da_claim_result_release(r); } - da_claim_result_release(r); pthread_mutex_lock(&g_handle_lock); g_handle.da_claimed = claimed; + if (timed_out) { + if (g_handle.da_pending) da_claim_result_release(g_handle.da_pending); + g_handle.da_pending = r; + } pthread_mutex_unlock(&g_handle_lock); return claimed; } +static void da_noop(void *ctx) { (void)ctx; } + static void da_release(void) { + // Detach the DA state under the lock, tear it down outside it: draining the + // queue runs da_mount_approval, which takes g_handle_lock itself. pthread_mutex_lock(&g_handle_lock); - if (g_handle.da_session) { - DAUnregisterCallback(g_handle.da_session, (void *)da_mount_approval, NULL); - } - if (g_handle.da_disk) { - if (g_handle.da_claimed) DADiskUnclaim(g_handle.da_disk); - CFRelease(g_handle.da_disk); - g_handle.da_disk = NULL; - } - if (g_handle.da_session) { - DASessionSetDispatchQueue(g_handle.da_session, NULL); - CFRelease(g_handle.da_session); - g_handle.da_session = NULL; - } - if (g_handle.da_queue) { - dispatch_release(g_handle.da_queue); - g_handle.da_queue = NULL; - } + DASessionRef session = g_handle.da_session; + DADiskRef disk = g_handle.da_disk; + dispatch_queue_t queue = g_handle.da_queue; + int claimed = g_handle.da_claimed; + DAClaimResult *pending = g_handle.da_pending; + g_handle.da_session = NULL; + g_handle.da_disk = NULL; + g_handle.da_queue = NULL; g_handle.da_claimed = 0; + g_handle.da_pending = NULL; pthread_mutex_unlock(&g_handle_lock); + + if (session) DAUnregisterCallback(session, (void *)da_mount_approval, NULL); + if (disk && claimed) DADiskUnclaim(disk); + if (session) DASessionSetDispatchQueue(session, NULL); + // No callback is scheduled past this point; flush any already in flight. + if (queue) dispatch_sync_f(queue, NULL, da_noop); + if (pending) { + if (__atomic_load_n(&pending->done, __ATOMIC_ACQUIRE)) { + // Late success: the claim is held even though da_hold gave up. + if (disk && pending->ok) DADiskUnclaim(disk); + } else { + // Never delivered now: drop the callback's reference too. + da_claim_result_release(pending); + } + da_claim_result_release(pending); + } + if (disk) CFRelease(disk); + if (session) CFRelease(session); + if (queue) dispatch_release(queue); } // ── Public API ──────────────────────────────────────────────────────────── diff --git a/src/scsi/mod.rs b/src/scsi/mod.rs index df180c8f..fae1cbe1 100644 --- a/src/scsi/mod.rs +++ b/src/scsi/mod.rs @@ -498,29 +498,29 @@ pub fn list_drives() -> Vec { } } -/// True if the drive at `path` currently has a disc inserted. +/// Whether the drive at `path` holds a disc ([`DiscPresence`]). /// -/// Issues a single TEST UNIT READY (cheapest SCSI op, no data transfer). -/// Sense-key 2 ("not ready, medium not present") → `Ok(false)`; any other -/// ready/not-ready response → `Ok(true)`. Suitable for poll-loop tick -/// (~50 ms / drive on a healthy bus). +/// Issues TEST UNIT READY (cheapest SCSI op, no data transfer), re-issued after +/// a UNIT ATTENTION (up to 4 times), and classifies the sense (derived from +/// MMC-6 Table F.3). On macOS the answer comes from IOKit and is only `Present`/`Absent`. /// /// **No internal recovery.** A wedged target surfaces as `Err(Error::ScsiError)` with `status -/// == SCSI_STATUS_TRANSPORT_FAILURE` and `sense: None` — no bus/USB reset, no retry. -pub fn drive_has_disc(path: &Path) -> Result { +/// == SCSI_STATUS_TRANSPORT_FAILURE` and `sense: None`, no bus/USB reset, no retry. Other +/// non-NOT-READY sense is also `Err`. +pub fn disc_presence(path: &Path) -> Result { #[cfg(target_os = "linux")] { - linux::drive_has_disc(path) + linux::disc_presence(path) } #[cfg(target_os = "macos")] { - macos::drive_has_disc(path) + macos::disc_presence(path) } #[cfg(target_os = "windows")] { - windows::drive_has_disc(path) + windows::disc_presence(path) } #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] @@ -532,6 +532,13 @@ pub fn drive_has_disc(path: &Path) -> Result { } } +/// True if the drive at `path` holds a disc: [`disc_presence`] with `Settling` +/// counted as a disc ([`DiscPresence::has_disc`]). Suitable for a poll-loop tick +/// (~50 ms / drive on a healthy bus). +pub fn drive_has_disc(path: &Path) -> Result { + disc_presence(path).map(DiscPresence::has_disc) +} + // ── CDB builders (platform-agnostic) ──────────────────────────────────────── /// SCSI INQUIRY response. @@ -557,8 +564,11 @@ pub fn inquiry(scsi: &mut dyn ScsiTransport) -> Result { }) } -/// Send GET CONFIGURATION for feature 0x010C (Firmware Information). +/// Send GET CONFIGURATION for feature 0x010C (Firmware Information). Returns +/// the reply (8-byte header + descriptor) cut to what the drive actually sent. pub fn get_config_010c(scsi: &mut dyn ScsiTransport) -> Result> { + // 8-byte header + 20-byte 010Ch descriptor (MMC-6 §5.3.10). + const ALLOC: u8 = 28; let cdb = [ SCSI_GET_CONFIGURATION, 0x02, @@ -568,12 +578,38 @@ pub fn get_config_010c(scsi: &mut dyn ScsiTransport) -> Result> { 0x00, 0x00, 0x00, - 0x10, + ALLOC, 0x00, ]; - let mut buf = [0u8; 16]; - scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000)?; - Ok(buf.to_vec()) + let mut buf = [0u8; ALLOC as usize]; + let r = scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000)?; + Ok(buf[..gc_reply_len(&buf, r.bytes_transferred)].to_vec()) +} + +/// Valid length of a GET CONFIGURATION reply: the transfer count, clamped to the +/// buffer and to the header's Data Length + 4 (MMC-6 §5.3.1). +pub(crate) fn gc_reply_len(buf: &[u8], transferred: usize) -> usize { + let end = transferred.min(buf.len()); + match buf.get(..4) { + Some(h) if end >= 4 => { + let data_len = u32::from_be_bytes([h[0], h[1], h[2], h[3]]) as usize; + end.min(data_len.saturating_add(4)) + } + _ => end, + } +} + +/// The descriptor for `feature` in a GET CONFIGURATION (RT=10b) reply, feature +/// header included, bounded by [`gc_reply_len`] and its Additional Length. `None` +/// when the drive answered header-only (feature absent) or with another feature. +#[cfg_attr(not(feature = "rip"), allow(dead_code))] +pub(crate) fn gc_feature_descriptor(buf: &[u8], transferred: usize, feature: u16) -> Option<&[u8]> { + let reply = &buf[..gc_reply_len(buf, transferred)]; + let desc = reply.get(8..)?; + if desc.len() < 4 || u16::from_be_bytes([desc[0], desc[1]]) != feature { + return None; + } + Some(&desc[..desc.len().min(4 + usize::from(desc[3]))]) } /// Build a READ BUFFER CDB. @@ -629,6 +665,350 @@ pub fn build_read10_fua(lba: u32, count: u16) -> [u8; 10] { ] } +/// True if INQUIRY byte 0's peripheral device type (low 5 bits; the high 3 +/// are the qualifier) is 05h, an MMC optical drive (SPC-4 §6.4.2). +#[cfg_attr(not(any(feature = "rip", target_os = "windows")), allow(dead_code))] +pub(crate) fn is_optical_peripheral(inquiry: &[u8]) -> bool { + const PERIPHERAL_TYPE_MASK: u8 = 0x1F; + const PERIPHERAL_TYPE_OPTICAL: u8 = 0x05; + inquiry + .first() + .is_some_and(|b| b & PERIPHERAL_TYPE_MASK == PERIPHERAL_TYPE_OPTICAL) +} + +/// PREVENT ALLOW MEDIUM REMOVAL (1Eh): `Some` of CDB byte 4 bits 1:0 (MMC-6: +/// bit 1 Persistent, bit 0 Prevent; SPC-4 §6.13), `None` for other commands. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +pub(crate) fn prevent_allow_request(cdb: &[u8]) -> Option { + const PREVENT_ALLOW_MEDIUM_REMOVAL: u8 = 0x1E; + match cdb { + [PREVENT_ALLOW_MEDIUM_REMOVAL, _, _, _, b4, ..] => Some(b4 & 0b11), + _ => None, + } +} + +/// Whether a drive holds a disc, from TEST UNIT READY sense. The split is our +/// reading of MMC-6 Table F.3 (derived, not spec text). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum DiscPresence { + /// A medium is loaded: GOOD, or a NOT READY state that only exists with one + /// (04/02, 04/04, 04/07, 04/08, 0Ch, 30h other than cleaning cartridges). + Present, + /// NOT READY 3Ah, MEDIUM NOT PRESENT (tray open or closed and empty), or a + /// cleaning cartridge / cleaning failure (30/03, 30/07): nothing to rip. + Absent, + /// Not ready for a reason that does not settle presence: 04/01 (a mounted + /// disc spinning up or changing Format-layer, MMC-6 §6.22.3), 04/00, 04/03, + /// 04/09, 3Eh, or another NOT READY. Poll again. + Settling, +} + +impl DiscPresence { + /// The [`drive_has_disc`] answer: `Settling` counts as a disc, so a poll loop + /// never drops a session while a mounted disc re-spins. + pub fn has_disc(self) -> bool { + self != DiscPresence::Absent + } +} + +/// [`DiscPresence`] from TEST UNIT READY, `tur` issuing one TUR (Ok = GOOD). +/// UNIT ATTENTIONs are re-polled (up to 4); non-NOT-READY sense is `Err`. +#[cfg_attr(not(any(target_os = "linux", target_os = "windows")), allow(dead_code))] +pub(crate) fn tur_disc_presence(mut tur: impl FnMut() -> Result<()>) -> Result { + const MAX_ATTENTION_RETRIES: u32 = 4; + let mut retries = 0; + loop { + let Err(e) = tur() else { + return Ok(DiscPresence::Present); + }; + let Some(s) = e.scsi_sense().copied() else { + return Err(e); + }; + match s.sense_key { + SENSE_KEY_NOT_READY => return Ok(not_ready_presence(s.asc, s.ascq)), + SENSE_KEY_UNIT_ATTENTION if retries < MAX_ATTENTION_RETRIES => retries += 1, + _ => return Err(e), + } + } +} + +// NOT READY ASC/ASCQ -> presence, derived from MMC-6 Tables F.3 / F.10. +fn not_ready_presence(asc: u8, ascq: u8) -> DiscPresence { + match (asc, ascq) { + (0x3A, _) => DiscPresence::Absent, + // Initializing cmd required, format / operation / long write in progress. + (0x04, 0x02 | 0x04 | 0x07 | 0x08) => DiscPresence::Present, + // Write error recovery needed, defects in error window. + (0x0C, 0x07 | 0x0F) => DiscPresence::Present, + // Cleaning cartridge installed / cleaning failure: no disc to rip. + (0x30, 0x03 | 0x07) => DiscPresence::Absent, + // Incompatible / unreadable medium installed. + (0x30, _) => DiscPresence::Present, + _ => DiscPresence::Settling, + } +} + +/// Whether a no-sysfs (unfiltered) sg node belongs in `list_drives`. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +#[derive(Debug, Clone, Copy)] +pub(crate) enum NodeProbe { + /// INQUIRY says peripheral type 05h. + Optical, + /// INQUIRY says another peripheral type. + NotOptical, + /// Opened but INQUIRY failed, or open failed with e.g. EBUSY. + Unresponsive, + /// open() found no device (ENOENT, ENXIO, ENODEV). + Absent, + /// open() refused (EACCES, EPERM): nothing says it is optical. + Denied, +} + +/// [`NodeProbe`] for an sg node whose open(2) failed with `errno`. +#[cfg(unix)] +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +pub(crate) fn open_failure_probe(errno: Option) -> NodeProbe { + match errno { + Some(libc::ENOENT | libc::ENXIO | libc::ENODEV) => NodeProbe::Absent, + Some(libc::EACCES | libc::EPERM) => NodeProbe::Denied, + _ => NodeProbe::Unresponsive, + } +} + +/// Kept unless definitively not an optical drive or not there: a wedged or busy +/// drive must list as present (autorip: "unresponsive"), not as unplugged. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +pub(crate) fn keep_unfiltered_node(p: NodeProbe) -> bool { + matches!(p, NodeProbe::Optical | NodeProbe::Unresponsive) +} + +/// Upper bound on an adapter AlignmentMask (page alignment). +pub(crate) const MAX_ALIGNMENT_MASK: usize = 0xFFF; + +/// Adapter-reported SPTI AlignmentMask made safe to size a bounce buffer with: +/// widened to the next 2^n-1 if malformed, capped at [`MAX_ALIGNMENT_MASK`]. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +pub(crate) fn sanitize_alignment_mask(raw: u32) -> usize { + let smeared = match raw.checked_ilog2() { + Some(top) => u32::MAX >> (31 - top), + None => 0, + }; + (smeared as usize).min(MAX_ALIGNMENT_MASK) +} + +/// SPTI TimeOutValue: whole seconds, rounded up, at least 1, never wrapping. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +pub(crate) fn spti_timeout_secs(timeout_ms: u32) -> u32 { + timeout_ms.div_ceil(1000).max(1) +} + +#[cfg(test)] +mod transport_helper_tests { + use super::*; + + fn sense(sense_key: u8, asc: u8, ascq: u8) -> ScsiSense { + ScsiSense { + sense_key, + asc, + ascq, + } + } + + fn tur_err(sense_key: u8, asc: u8, ascq: u8) -> Result<()> { + Err(Error::ScsiError { + opcode: SCSI_TEST_UNIT_READY, + status: SCSI_STATUS_CHECK_CONDITION, + sense: Some(sense(sense_key, asc, ascq)), + }) + } + + // Runs `tur_disc_presence` over scripted TUR outcomes; returns the verdict + // and how many TURs it issued. + fn presence(replies: Vec>) -> (Result, usize) { + let mut replies = replies.into_iter(); + let mut calls = 0; + let r = tur_disc_presence(|| { + calls += 1; + replies.next().unwrap_or(Ok(())) + }); + (r, calls) + } + + fn one(sense_key: u8, asc: u8, ascq: u8) -> Result { + presence(vec![tur_err(sense_key, asc, ascq)]).0 + } + + // MMC-6 Table F.3: only 3Ah is MEDIUM NOT PRESENT. 04/01 is a mounted disc + // spinning up or changing Format-layer (§6.22.3), so it is Settling, never + // Absent; states that only exist with a medium loaded are Present. + #[test] + fn tur_disc_presence_follows_the_mmc6_readiness_table() { + use DiscPresence::*; + assert!(matches!(presence(vec![Ok(())]), (Ok(Present), 1))); + for ascq in [0x00, 0x01, 0x02] { + assert!(matches!(one(2, 0x3A, ascq), Ok(Absent)), "3A/{ascq:02x}"); + } + for (asc, ascq) in [(0x04, 0x02), (0x04, 0x04), (0x04, 0x07), (0x04, 0x08)] { + assert!( + matches!(one(2, asc, ascq), Ok(Present)), + "{asc:02x}/{ascq:02x}" + ); + } + for (asc, ascq) in [(0x0C, 0x07), (0x0C, 0x0F), (0x30, 0x00), (0x30, 0x02)] { + assert!( + matches!(one(2, asc, ascq), Ok(Present)), + "{asc:02x}/{ascq:02x}" + ); + } + for (asc, ascq) in [(0x04, 0x00), (0x04, 0x01), (0x04, 0x03), (0x04, 0x09)] { + assert!( + matches!(one(2, asc, ascq), Ok(Settling)), + "{asc:02x}/{ascq:02x}" + ); + } + assert!(matches!(one(2, 0x3E, 0x00), Ok(Settling)), "3E/00"); + // A cleaning cartridge (30/03) or cleaning failure (30/07) is nothing to + // rip and does not resolve like a spin-up: no disc. + for ascq in [0x03, 0x07] { + assert!(matches!(one(2, 0x30, ascq), Ok(Absent)), "30/{ascq:02x}"); + } + assert!(one(3, 0x11, 0).is_err(), "not a NOT READY key"); + } + + // A UNIT ATTENTION says nothing about the medium, and several can be queued + // (06/29 reset, then 06/28 medium change): re-issue TUR up to 4 times. + #[test] + fn tur_disc_presence_retries_queued_unit_attentions() { + use DiscPresence::*; + let ua = |asc| tur_err(6, asc, 0); + assert!(matches!( + presence(vec![ua(0x28), tur_err(2, 0x3A, 0)]), + (Ok(Absent), 2) + )); + assert!(matches!( + presence(vec![ua(0x29), ua(0x28), Ok(())]), + (Ok(Present), 3) + )); + assert!(matches!( + presence(vec![ua(0x28), tur_err(2, 4, 1)]), + (Ok(Settling), 2) + )); + let five = (0..5).map(|_| ua(0x28)).collect(); + assert!( + matches!(presence(five), (Err(_), 5)), + "attentions never clear" + ); + } + + // drive_has_disc keeps its bool: Settling reads as "still there", so a poll + // loop never tears down a session while a mounted disc re-spins. + #[test] + fn drive_has_disc_maps_settling_to_present() { + assert!(DiscPresence::Present.has_disc()); + assert!(DiscPresence::Settling.has_disc()); + assert!(!DiscPresence::Absent.has_disc()); + } + + // Without sysfs, an sg node stays listed unless it is definitively not an + // optical drive or not there: a wedged or busy drive must read as present + // but unresponsive (autorip's "firmware unresponsive"), not as unplugged. + #[test] + fn unfiltered_sg_node_is_dropped_only_when_definitively_not_a_drive() { + assert!(keep_unfiltered_node(NodeProbe::Optical)); + assert!(keep_unfiltered_node(NodeProbe::Unresponsive)); + assert!(!keep_unfiltered_node(NodeProbe::NotOptical)); + assert!(!keep_unfiltered_node(NodeProbe::Absent)); + // Without sysfs a non-root user sees EACCES on every root-owned sg node + // (disks, tapes): unprovable as optical, so not listed. + assert!(!keep_unfiltered_node(NodeProbe::Denied)); + } + + // How a failed open(2) of an unfiltered sg node is classified. + #[cfg(unix)] + #[test] + fn unfiltered_open_errno_classification() { + use NodeProbe::*; + let probe = |e| open_failure_probe(Some(e)); + assert!(matches!(probe(libc::ENOENT), Absent)); + assert!(matches!(probe(libc::ENXIO), Absent)); + assert!(matches!(probe(libc::ENODEV), Absent)); + assert!(matches!(probe(libc::EACCES), Denied)); + assert!(matches!(probe(libc::EPERM), Denied)); + assert!(matches!(probe(libc::EBUSY), Unresponsive)); + assert!(matches!(open_failure_probe(None), Unresponsive)); + } + + // Drop unlocks the tray only for a transport that issued a PREVENT; the + // CDB decode is what tracks that. + #[test] + fn prevent_allow_request_decodes_only_1eh() { + // SPC-4 §6.13 PREVENT field, bits 1:0: 00 allow, 01 prevent, + // 10 persistent allow, 11 persistent prevent. Upper bits are reserved. + assert_eq!(prevent_allow_request(&[0x1E, 0, 0, 0, 0x00, 0]), Some(0b00)); + assert_eq!(prevent_allow_request(&[0x1E, 0, 0, 0, 0x01, 0]), Some(0b01)); + assert_eq!(prevent_allow_request(&[0x1E, 0, 0, 0, 0x02, 0]), Some(0b10)); + assert_eq!(prevent_allow_request(&[0x1E, 0, 0, 0, 0x03, 0]), Some(0b11)); + assert_eq!(prevent_allow_request(&[0x1E, 0, 0, 0, 0xFD, 0]), Some(0b01)); + assert_eq!(prevent_allow_request(&[0x12, 0, 0, 0, 0x01, 0]), None); + assert_eq!(prevent_allow_request(&[0x1E, 0, 0]), None, "short CDB"); + } + + // AlignmentMask is adapter-reported: it must be 2^n-1 and bounded, or a bogus + // 0xFFFF_FFFF sizes every bounce buffer at data.len() + 4 GiB. + #[test] + fn alignment_mask_is_validated_and_capped() { + assert_eq!(sanitize_alignment_mask(0), 0); + assert_eq!(sanitize_alignment_mask(0x3), 0x3); + assert_eq!(sanitize_alignment_mask(0x1FF), 0x1FF); + assert_eq!(sanitize_alignment_mask(0x5), 0x7, "not 2^n-1: widen"); + assert_eq!(sanitize_alignment_mask(0xFFFF_FFFF), MAX_ALIGNMENT_MASK); + assert_eq!(sanitize_alignment_mask(0x0010_0000), MAX_ALIGNMENT_MASK); + } + + // SPTI's TimeOutValue is whole seconds, rounded up, never 0 — and a huge + // timeout must saturate, not wrap to 1 s (or panic in debug). + #[test] + fn spti_timeout_rounds_up_without_overflow() { + assert_eq!(spti_timeout_secs(0), 1); + assert_eq!(spti_timeout_secs(1_500), 2); + assert_eq!(spti_timeout_secs(5_000), 5); + assert_eq!(spti_timeout_secs(u32::MAX), u32::MAX.div_ceil(1000)); + } + + // Feature 010Ch is an 8-byte header plus a 20-byte descriptor (Additional + // Length 10h): the allocation length must cover all 28 bytes. + #[test] + fn get_config_010c_returns_the_whole_descriptor() { + struct Fw(Vec); + impl ScsiTransport for Fw { + fn execute( + &mut self, + cdb: &[u8], + _dir: DataDirection, + data: &mut [u8], + _timeout_ms: u32, + ) -> Result { + let alloc = usize::from(u16::from_be_bytes([cdb[7], cdb[8]])); + let mut reply = vec![0, 0, 0, 24, 0, 0, 0, 0, 0x01, 0x0C, 0x00, 0x10]; + reply.extend_from_slice(b"202101311259\0\0\0\0"); + let n = reply.len().min(alloc).min(data.len()); + data[..n].copy_from_slice(&reply[..n]); + self.0 = cdb.to_vec(); + Ok(ScsiResult { + status: 0, + bytes_transferred: n, + sense: [0u8; 32], + }) + } + } + let mut t = Fw(Vec::new()); + let v = get_config_010c(&mut t).unwrap(); + assert_eq!(v.len(), 28, "header + full 010Ch descriptor"); + assert_eq!(&v[12..24], b"202101311259", "date through the minute"); + } +} + // Round `p` up to satisfy an SPTI AlignmentMask (`(p + mask) & !mask`); mask=0 means no // alignment requirement. #[allow(dead_code)] diff --git a/src/scsi/windows.rs b/src/scsi/windows.rs index ac08291a..db5c6c2a 100644 --- a/src/scsi/windows.rs +++ b/src/scsi/windows.rs @@ -15,9 +15,6 @@ const IOCTL_SCSI_PASS_THROUGH_DIRECT: u32 = 0x4D014; /// IOCTL_STORAGE_QUERY_PROPERTY — CTL_CODE(IOCTL_STORAGE_BASE(0x2D), /// 0x500, METHOD_BUFFERED(0), FILE_ANY_ACCESS(0)) = 0x002D1400. const IOCTL_STORAGE_QUERY_PROPERTY: u32 = 0x002D1400; -// IOCTL_STORAGE_RESET_DEVICE (ntddstor.h) = 0x002D5004. Two earlier values silently no-op'd the -// reset (ERROR_INVALID_FUNCTION) instead of failing loud. -const IOCTL_STORAGE_RESET_DEVICE: u32 = 0x002D_5004; /// STORAGE_PROPERTY_ID::StorageAdapterProperty. const STORAGE_ADAPTER_PROPERTY: u32 = 1; /// STORAGE_QUERY_TYPE::PropertyStandardQuery. @@ -146,11 +143,13 @@ pub struct SptiTransport { /// alongside `max_transfer`. `0` (common on USB bridges) means no alignment requirement; a /// nonzero mask (SCSI/SAS HBAs) means a misaligned `DataBuffer` makes `DeviceIoControl` /// fail. When set and the caller's buffer is misaligned, `execute()` bounces through an - /// aligned scratch buffer (see there). - alignment_mask: u32, + /// aligned scratch buffer (see there). Sanitized by `sanitize_alignment_mask`. + alignment_mask: usize, + /// Reused aligned scratch for misaligned buffers; grows to the largest transfer seen. + bounce: Vec, } -// SptiTransport's only field is an isize HANDLE: Send+Sync auto-derive; +// SptiTransport's fields are an isize HANDLE plus owned data: Send+Sync auto-derive; // `&mut self` on execute() enforces exclusive use, not absence of Sync. // Normalize a device path to \\.\X: format. @@ -210,75 +209,10 @@ impl SptiTransport { Ok(SptiTransport { handle, max_transfer, - alignment_mask, + alignment_mask: super::sanitize_alignment_mask(alignment_mask), + bounce: Vec::new(), }) } - - /// Reset the drive to a known good state. - /// Opens the device, sends IOCTL_STORAGE_RESET_DEVICE to reset - /// the USB/SCSI bus, then closes. Same concept as SG_SCSI_RESET on Linux. - pub fn reset(device: &Path) -> Result<()> { - let dev_str = device.to_str().ok_or_else(|| Error::DeviceNotFound { - path: device.display().to_string(), - })?; - let win_path = normalize_device_path(dev_str); - let wide: Vec = win_path.encode_utf16().chain(std::iter::once(0)).collect(); - - // Open - let handle = unsafe { - CreateFileW( - wide.as_ptr(), - GENERIC_READ | GENERIC_WRITE, - FILE_SHARE_READ | FILE_SHARE_WRITE, - std::ptr::null(), - OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL, - std::ptr::null(), - ) - }; - if handle == INVALID_HANDLE_VALUE { - return Ok(()); // can't open — skip reset, not fatal - } - - // Send device reset. Result must be checked: a wrong/unsupported - // IOCTL fails with ERROR_INVALID_FUNCTION and no-ops silently — - // surface failures so a non-functional reset isn't masked below. - let mut returned: u32 = 0; - let ok = unsafe { - DeviceIoControl( - handle, - IOCTL_STORAGE_RESET_DEVICE, - std::ptr::null_mut(), - 0, - std::ptr::null_mut(), - 0, - &mut returned, - std::ptr::null_mut(), - ) - }; - let reset_ok = ok != 0; - if reset_ok { - tracing::debug!("IOCTL_STORAGE_RESET_DEVICE succeeded"); - } else { - let err = unsafe { GetLastError() }; - // Not fatal — reset is best-effort — but a failing reset means - // the device was NOT reset, so skip the settle-sleep below. - tracing::warn!( - last_error = err, - ioctl = format_args!("{IOCTL_STORAGE_RESET_DEVICE:#010x}"), - "IOCTL_STORAGE_RESET_DEVICE failed; drive not reset" - ); - } - - // Close the handle, then — only if the reset actually happened — wait - // for the drive to settle. A failed IOCTL reset performed no reset, so - // sleeping would burn 2 s for nothing. - unsafe { CloseHandle(handle) }; - if reset_ok { - std::thread::sleep(std::time::Duration::from_secs(2)); - } - Ok(()) - } } /// Enumerate optical drives on Windows: probe `\\.\CdRom0..15`, falling back to @@ -290,9 +224,6 @@ impl SptiTransport { /// modules — `scsi` builds without `rip` (freemkv-firmware), where the old /// `crate::drive::windows::find_drives()` delegation failed to compile. pub(super) fn list_drives() -> Vec { - // SCSI peripheral device type 5 = MMC / optical, low 5 bits of INQUIRY byte 0. - const SCSI_PERIPHERAL_TYPE_OPTICAL: u8 = 0x05; - fn probe(path: &str, out: &mut Vec) { let Ok(mut transport) = crate::scsi::open(Path::new(path)) else { return; @@ -300,7 +231,7 @@ pub(super) fn list_drives() -> Vec { let Ok(r) = super::inquiry(transport.as_mut()) else { return; }; - if !r.raw.is_empty() && (r.raw[0] & 0x1F) == SCSI_PERIPHERAL_TYPE_OPTICAL { + if super::is_optical_peripheral(&r.raw) { out.push(super::DriveInfo { path: normalize_device_path(path), vendor: r.vendor_id, @@ -324,21 +255,20 @@ pub(super) fn list_drives() -> Vec { } /// TEST UNIT READY probe on Windows. No in-library recovery — see the -/// Linux `drive_has_disc` doc block for the rationale. -pub(super) fn drive_has_disc(path: &Path) -> Result { +/// Linux `disc_presence` doc block for the rationale. +pub(super) fn disc_presence(path: &Path) -> Result { let mut transport = SptiTransport::open(path)?; let cdb = [crate::scsi::SCSI_TEST_UNIT_READY, 0, 0, 0, 0, 0]; - let mut buf = [0u8; 0]; - match transport.execute( - &cdb, - crate::scsi::DataDirection::None, - &mut buf, - crate::scsi::TUR_TIMEOUT_MS, - ) { - Ok(_) => Ok(true), - Err(ref e) if e.scsi_sense().is_some_and(|s| s.is_not_ready()) => Ok(false), - Err(e) => Err(e), - } + super::tur_disc_presence(|| { + transport + .execute( + &cdb, + crate::scsi::DataDirection::None, + &mut [], + crate::scsi::TUR_TIMEOUT_MS, + ) + .map(|_| ()) + }) } impl Drop for SptiTransport { @@ -445,21 +375,24 @@ impl ScsiTransport for SptiTransport { // Round up to the next whole second (SPTI's TimeOutValue has no // sub-second resolution) rather than truncating — truncation // broke 1500ms fast-reads on Drive::read. - sptwb.spt.TimeOutValue = ((timeout_ms + 999) / 1000).max(1); + sptwb.spt.TimeOutValue = super::spti_timeout_secs(timeout_ms); // AlignmentMask bounce buffer: `DataBuffer` must satisfy the // adapter's `AlignmentMask` (0 on USB — zero-copy; nonzero on // SCSI/SAS HBAs, where misalignment fails the IOCTL outright). - let mask = self.alignment_mask as usize; + let mask = self.alignment_mask; let needs_bounce = !data.is_empty() && mask != 0 && (data.as_mut_ptr() as usize) & mask != 0; - let mut bounce: Vec = Vec::new(); let data_ptr: *mut u8 = if data.is_empty() { std::ptr::null_mut() } else if needs_bounce { - // Over-allocate by `mask` so an aligned start exists within. - bounce = vec![0u8; data.len() + mask]; - let base = bounce.as_mut_ptr() as usize; + // Over-allocate by `mask` so an aligned start exists within. Reused + // across commands; not resized again until the copy-back below. + let want = data.len() + mask; + if self.bounce.len() < want { + self.bounce.resize(want, 0); + } + let base = self.bounce.as_mut_ptr() as usize; let aligned = crate::scsi::align_up(base, mask); let aligned_ptr = aligned as *mut u8; // For writes (ToDevice) prime the aligned region with the @@ -535,13 +468,11 @@ impl ScsiTransport for SptiTransport { // If we bounced a FROM-device read, copy the aligned scratch back // into the caller's buffer (only the bytes actually transferred). if needs_bounce && direction == DataDirection::FromDevice { - let aligned_ptr = data_ptr; // points inside `bounce` + let aligned_ptr = data_ptr; // points inside `self.bounce` unsafe { std::ptr::copy_nonoverlapping(aligned_ptr, data.as_mut_ptr(), transferred); } } - // `bounce` is dropped here, after the last use of `data_ptr`. - drop(bounce); let mut sense = [0u8; 32]; sense.copy_from_slice(&sptwb.sense); @@ -558,37 +489,6 @@ impl ScsiTransport for SptiTransport { mod tests { use super::*; - // Regression guard for `SptiTransport::reset()`: recomputes IOCTL_STORAGE_RESET_DEVICE from - // the CTL_CODE formula, independently of the hardcoded constant. - #[test] - fn ioctl_storage_reset_device_value_is_correct() { - // CTL_CODE(DeviceType, Function, Method, Access) = - // (DeviceType << 16) | (Access << 14) | (Function << 2) | Method - const fn ctl_code(device_type: u32, function: u32, method: u32, access: u32) -> u32 { - (device_type << 16) | (access << 14) | (function << 2) | method - } - const IOCTL_STORAGE_BASE: u32 = 0x2D; - const METHOD_BUFFERED: u32 = 0; - const FILE_READ_ACCESS: u32 = 1; - let expected = ctl_code( - IOCTL_STORAGE_BASE, - 0x0401, - METHOD_BUFFERED, - FILE_READ_ACCESS, - ); - - assert_eq!( - IOCTL_STORAGE_RESET_DEVICE, expected, - "IOCTL_STORAGE_RESET_DEVICE must equal CTL_CODE(0x2D, 0x0401, \ - METHOD_BUFFERED, FILE_READ_ACCESS); a wrong value fails \ - ERROR_INVALID_FUNCTION and silently no-ops the reset" - ); - assert_eq!(IOCTL_STORAGE_RESET_DEVICE, 0x002D_5004); - // The two historically wrong values must never reappear. - assert_ne!(IOCTL_STORAGE_RESET_DEVICE, 0x002D_1004); - assert_ne!(IOCTL_STORAGE_RESET_DEVICE, 0x002D_D000); - } - // Regression guard: `StorageAdapterDescriptor` must match `STORAGE_ADAPTER_DESCRIPTOR` // (winioctl.h) field-for-field. #[test] @@ -668,8 +568,6 @@ mod tests { ); // winioctl.h: CTL_CODE(IOCTL_STORAGE_BASE, 0x0500, METHOD_BUFFERED, ANY) assert_eq!(IOCTL_STORAGE_QUERY_PROPERTY, ctl_code(0x2D, 0x500, 0, 0)); - // winioctl.h: CTL_CODE(IOCTL_STORAGE_BASE, 0x0401, METHOD_BUFFERED, READ) - assert_eq!(IOCTL_STORAGE_RESET_DEVICE, ctl_code(0x2D, 0x401, 0, 1)); // winioctl.h STORAGE_PROPERTY_ID / STORAGE_QUERY_TYPE enums. assert_eq!(STORAGE_ADAPTER_PROPERTY, 1); // StorageAdapterProperty (Device=0, Adapter=1) diff --git a/tests/scsi_recovery.rs b/tests/scsi_recovery.rs index 9dd789e5..15e08c28 100644 --- a/tests/scsi_recovery.rs +++ b/tests/scsi_recovery.rs @@ -1,90 +1,9 @@ -//! Tests for SgIoTransport timeout recovery (Fix 2). -//! -//! When `execute()` detects a transport-level failure (kernel timeout / -//! USB bridge wedge — `host_status != 0`), it spawns two background -//! threads to (1) close the old fd and (2) open a fresh one, then -//! stores the new fd in `fd_recovery`. The next `execute()` call picks -//! up the recovered fd without blocking on close(). -//! -//! These tests require a real /dev/sg* device and are therefore #[ignore]. +//! Drive-level timeout bound on a real device. The SgIoTransport fd-recovery +//! test lives beside the transport (its fields are private) in +//! `src/scsi/linux.rs`. Requires a real /dev/sg* device, hence #[ignore]. use std::path::Path; -#[test] -#[ignore] -fn test_sgio_transport_timeout_does_not_kill_transport() { - let device = "/dev/sg2"; - let device = std::env::var("FREEMKV_TEST_SG_DEVICE").unwrap_or(device.to_string()); - let _path = Path::new(&device); - - #[cfg(target_os = "linux")] - { - use libfreemkv::scsi::ScsiTransport; - use libfreemkv::scsi::linux::SgIoTransport; - use libfreemkv::scsi::{DataDirection, SCSI_STATUS_TRANSPORT_FAILURE}; - use std::sync::Arc; - use std::sync::atomic::{AtomicI32, Ordering}; - use std::time::Duration; - - let mut transport = SgIoTransport::open(_path).expect("open device"); - let fd_before = transport.fd; - - // READ_10 with 1 ms timeout to force kernel timeout. - let cdb = [0x28, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00]; - let mut data = vec![0u8; 2048]; - - let start = std::time::Instant::now(); - let result = transport.execute(&cdb, DataDirection::FromDevice, &mut data, 1); - let _elapsed = start.elapsed(); - - assert!(result.is_err(), "expected Err on timeout, got {:?}", result); - let err = result.unwrap_err(); - assert!( - matches!(&err, libfreemkv::Error::ScsiError { status, .. } if *status == SCSI_STATUS_TRANSPORT_FAILURE), - "expected ScsiError(TRANSPORT_FAILURE), got {:?}", - err - ); - assert_eq!(transport.fd, -1, "fd should be -1 after timeout"); - - // Wait for recovery thread to store new fd. - let recovered = Arc::new(AtomicI32::new(-1)); - let recovery_ref = transport.fd_recovery.clone(); - for _ in 0..100 { - let v = recovery_ref.load(Ordering::Acquire); - if v >= 0 { - recovered.store(v, Ordering::Release); - break; - } - std::thread::sleep(Duration::from_millis(50)); - } - let new_fd = recovered.load(Ordering::Acquire); - assert!(new_fd >= 0, "recovery thread should have produced a new fd"); - - // Next execute() must pick up recovered fd quickly. - let mut data2 = vec![0u8; 2048]; - let start2 = std::time::Instant::now(); - let result2 = transport.execute(&cdb, DataDirection::FromDevice, &mut data2, 5000); - let elapsed2 = start2.elapsed(); - - assert!( - result2.is_ok(), - "execute() with recovered fd should succeed, got {:?}", - result2 - ); - assert!( - elapsed2 < Duration::from_secs(2), - "should return quickly, took {:?}", - elapsed2 - ); - assert_ne!(transport.fd, -1, "fd should be valid after recovery"); - assert_ne!(transport.fd, fd_before, "fd should be fresh after recovery"); - } - #[cfg(not(target_os = "linux"))] - { - eprintln!("SKIP: test requires Linux / SgIoTransport"); - } -} - #[test] #[ignore] fn test_drive_read_per_cdb_timeout_bounds_call() {