WO-2 (delete SectorReader trait): - The 0.18 trait split into SectorSource (read-only) and SectorSink (write-only) is final; the legacy SectorReader alias was a bridge. - Renames every internal &mut dyn SectorReader (~25 sites) to &mut dyn SectorSource. The trait method capacity() becomes capacity_sectors() with a default of 0 (preserves SectorReader's default-0 behavior). - Deletes the SectorReader trait, its blanket-to-Source bridge, and the FileSectorReader type alias. Adds explicit forwarding impls for Box<dyn SectorSource> and &mut dyn SectorSource so generic decorators like DecryptingSectorSource<S: SectorSource> compose. WO-3a (extract Disc::patch): - Moves Disc::patch (1230 lines) and bytes_bad_in_title from disc/mod.rs into disc/patch.rs as a split inherent impl. Zero behavior change — pure mechanical relocation. disc/mod.rs drops from 3,945 to 2,714 LOC. WO-6 (partial): - Deletes src/labels/png_filenames.rs — was a 72-LOC stub with detect() returning false, never wired into the PARSERS registry. CLAUDE.md doc drift fixes (audited 2026-05-13): - JUMP_BASE_SECTORS: 256→1024 (64 MB base for UHD, not 8 MB) - PASSN_DAMAGE_THRESHOLD_PCT: 12→6 - PASSN_SKIP_SECTORS_BASE: 64→32 - MAX_RANGE_SECS=180: replaced by proportional range_sectors × 25, capped at RANGE_BUDGET_CAP_SECS=1800.
853 lines
28 KiB
Rust
853 lines
28 KiB
Rust
//! Drive session — open, identify, and read from optical drives.
|
||
//!
|
||
//! 4. `probe_disc()` — probe disc surface. Drive learns optimal speeds.
|
||
|
||
pub(crate) fn extract_scsi_context(e: &Error) -> (u8, Option<crate::scsi::ScsiSense>) {
|
||
match e {
|
||
Error::ScsiError { status, sense, .. } => (*status, *sense),
|
||
Error::DiscRead { status, sense, .. } => (status.unwrap_or(0), *sense),
|
||
_ => (0, None),
|
||
}
|
||
}
|
||
|
||
pub mod capture;
|
||
|
||
// Per-platform discovery helpers (the `pub(crate)` `find_drives` /
|
||
// equivalents). Crate-public so `scsi/{linux,macos,windows}.rs` can
|
||
// reuse the existing enumeration logic when shaping `DriveInfo`.
|
||
#[cfg(target_os = "linux")]
|
||
pub(crate) mod linux;
|
||
#[cfg(target_os = "macos")]
|
||
pub(crate) mod macos;
|
||
#[cfg(windows)]
|
||
pub(crate) mod windows;
|
||
|
||
use crate::error::{Error, Result};
|
||
use crate::event::{Event, EventKind};
|
||
use crate::identity::DriveId;
|
||
use crate::platform::PlatformDriver;
|
||
use crate::platform::mt1959::Mt1959;
|
||
use crate::profile::{self, DriveProfile};
|
||
use crate::scsi::ScsiTransport;
|
||
use crate::sector::SectorSource;
|
||
use std::path::Path;
|
||
use std::sync::Arc;
|
||
use std::sync::atomic::{AtomicBool, Ordering};
|
||
|
||
/// Physical state of the drive tray and disc.
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub enum DriveStatus {
|
||
/// Tray is open
|
||
TrayOpen,
|
||
/// Tray closed, no disc
|
||
NoDisc,
|
||
/// Tray closed, disc present and ready
|
||
DiscPresent,
|
||
/// Drive is loading or spinning up
|
||
NotReady,
|
||
/// Could not determine status
|
||
Unknown,
|
||
}
|
||
|
||
// SCSI opcodes used in drive control
|
||
const SCSI_TEST_UNIT_READY: u8 = 0x00;
|
||
const SCSI_START_STOP_UNIT: u8 = 0x1B;
|
||
const SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL: u8 = 0x1E;
|
||
const SCSI_GET_EVENT_STATUS: u8 = 0x4A;
|
||
const SCSI_MODE_SENSE: u8 = 0x5A;
|
||
const SCSI_REPORT_KEY: u8 = 0xA4;
|
||
|
||
/// Optical disc drive session -- open, identify, unlock, and read.
|
||
pub struct Drive {
|
||
scsi: Box<dyn ScsiTransport>,
|
||
driver: Option<Box<dyn PlatformDriver>>,
|
||
pub profile: Option<DriveProfile>,
|
||
pub platform: Option<profile::Platform>,
|
||
pub drive_id: DriveId,
|
||
device_path: String,
|
||
/// Halt flag — when set, Drive::read() bails at the next check point.
|
||
halt: Arc<AtomicBool>,
|
||
/// Event handler — fires for read errors and library-level state changes.
|
||
event_fn: Option<Box<dyn Fn(Event) + Send>>,
|
||
/// Linux only: raw fd for the corresponding block device (`/dev/sr*`)
|
||
/// used as a recovery fallback when SCSI READ via `/dev/sg*` returns
|
||
/// an error. The kernel `sr_mod` driver auto-retries failed reads
|
||
/// (~5× per command) — historically the reason `dd if=/dev/sr0`
|
||
/// recovers ~50% of bad sectors that single-shot `SG_IO` READ
|
||
/// misses on the same drive. `None` when the block device couldn't
|
||
/// be resolved or opened (no fallback in that case; SCSI read
|
||
/// errors propagate as before).
|
||
#[cfg(target_os = "linux")]
|
||
block_dev_fd: Option<std::os::unix::io::RawFd>,
|
||
}
|
||
|
||
impl Drive {
|
||
pub fn open(device: &Path) -> Result<Self> {
|
||
let mut transport = crate::scsi::open(device)?;
|
||
let profiles = profile::load_bundled()?;
|
||
let drive_id = DriveId::from_drive(transport.as_mut())?;
|
||
|
||
let m = profile::find_by_drive_id(&profiles, &drive_id);
|
||
let (driver, platform, profile) = match m {
|
||
Some(m) => (
|
||
create_driver(m.platform, &m.profile).ok(),
|
||
Some(m.platform),
|
||
Some(m.profile),
|
||
),
|
||
None => (None, None, None),
|
||
};
|
||
|
||
#[cfg(target_os = "linux")]
|
||
let block_dev_fd = open_block_device_for_sg(device);
|
||
|
||
Ok(Drive {
|
||
scsi: transport,
|
||
driver,
|
||
platform,
|
||
profile,
|
||
drive_id,
|
||
device_path: device.to_string_lossy().to_string(),
|
||
halt: Arc::new(AtomicBool::new(false)),
|
||
event_fn: None,
|
||
#[cfg(target_os = "linux")]
|
||
block_dev_fd,
|
||
})
|
||
}
|
||
|
||
/// Get a clone of the halt flag. Set to true to interrupt Drive::read().
|
||
pub fn halt_flag(&self) -> Arc<AtomicBool> {
|
||
self.halt.clone()
|
||
}
|
||
|
||
/// Halt the drive — Drive::read() will bail at the next check point.
|
||
pub fn halt(&self) {
|
||
self.halt.store(true, Ordering::Relaxed);
|
||
}
|
||
|
||
/// Clear the halt flag for the next operation.
|
||
pub fn clear_halt(&self) {
|
||
self.halt.store(false, Ordering::Relaxed);
|
||
}
|
||
|
||
/// Set an event handler for read recovery events.
|
||
pub fn on_event(&mut self, f: impl Fn(Event) + Send + 'static) {
|
||
self.event_fn = Some(Box::new(f));
|
||
}
|
||
|
||
#[allow(dead_code)] // public on_event registration kept; Drive currently
|
||
// has no internal emission sites after the 0.13.6 recovery strip.
|
||
// DiscStream is the BytesRead source. Plan to drop on_event in 0.14.
|
||
fn emit(&self, kind: EventKind) {
|
||
if let Some(ref f) = self.event_fn {
|
||
f(Event { kind });
|
||
}
|
||
}
|
||
|
||
fn is_halted(&self) -> bool {
|
||
self.halt.load(Ordering::Relaxed)
|
||
}
|
||
|
||
/// Halt-aware SCSI execute. Returns `Err(Halted)` if the flag is set
|
||
/// before the command dispatches or by the time it completes. The only
|
||
/// path to talk to the drive in the recovery hot loop; keeps Drive::read
|
||
/// free of explicit halt checks.
|
||
fn checked_exec(
|
||
&mut self,
|
||
cdb: &[u8],
|
||
dir: crate::scsi::DataDirection,
|
||
buf: &mut [u8],
|
||
timeout_ms: u32,
|
||
) -> Result<crate::scsi::ScsiResult> {
|
||
if self.is_halted() {
|
||
return Err(Error::Halted);
|
||
}
|
||
let r = self.scsi.as_mut().execute(cdb, dir, buf, timeout_ms)?;
|
||
if self.is_halted() {
|
||
return Err(Error::Halted);
|
||
}
|
||
Ok(r)
|
||
}
|
||
|
||
/// Close the drive cleanly. Unlocks tray, flushes SCSI state, closes fd.
|
||
/// Also runs automatically on Drop as a safety net.
|
||
pub fn close(self) {
|
||
// cleanup() runs here via Drop
|
||
}
|
||
|
||
/// Shared cleanup — called by Drop (and thus by close).
|
||
fn cleanup(&mut self) {
|
||
self.unlock_tray();
|
||
}
|
||
|
||
/// Whether this drive has a known profile (unlock parameters available).
|
||
pub fn has_profile(&self) -> bool {
|
||
self.profile.is_some()
|
||
}
|
||
|
||
/// Access the SCSI transport for direct commands (used by CSS/AACS auth).
|
||
pub fn scsi_mut(&mut self) -> &mut dyn ScsiTransport {
|
||
self.scsi.as_mut()
|
||
}
|
||
|
||
pub fn wait_ready(&mut self) -> Result<()> {
|
||
let tur = [SCSI_TEST_UNIT_READY, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||
|
||
for _ in 0..60 {
|
||
let mut buf = [0u8; 0];
|
||
if self
|
||
.scsi
|
||
.as_mut()
|
||
.execute(&tur, crate::scsi::DataDirection::None, &mut buf, 5_000)
|
||
.is_ok()
|
||
{
|
||
return Ok(());
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||
}
|
||
Err(Error::DeviceNotReady {
|
||
path: self.device_path.clone(),
|
||
})
|
||
}
|
||
|
||
/// Query the physical state of the drive — disc present, tray open, etc.
|
||
/// Uses GET EVENT STATUS NOTIFICATION which works regardless of firmware state.
|
||
pub fn drive_status(&mut self) -> DriveStatus {
|
||
// GET EVENT STATUS NOTIFICATION: polled, media event class (0x10)
|
||
let cdb = [
|
||
SCSI_GET_EVENT_STATUS,
|
||
0x01,
|
||
0x00,
|
||
0x00,
|
||
0x10,
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
0x08,
|
||
0x00,
|
||
];
|
||
let mut buf = [0u8; 8];
|
||
match self.scsi.as_mut().execute(
|
||
&cdb,
|
||
crate::scsi::DataDirection::FromDevice,
|
||
&mut buf,
|
||
5_000,
|
||
) {
|
||
Ok(r) if r.bytes_transferred >= 6 => {
|
||
let media_status = buf[5];
|
||
// Bits 1-0: door/tray state
|
||
// Bit 1: media present, Bit 0: tray open
|
||
match media_status & 0x03 {
|
||
0x00 => DriveStatus::NoDisc, // tray closed, no disc
|
||
0x01 => DriveStatus::TrayOpen, // tray open
|
||
0x02 => DriveStatus::DiscPresent, // tray closed, disc present
|
||
0x03 => DriveStatus::DiscPresent, // tray closed, disc present
|
||
_ => DriveStatus::Unknown,
|
||
}
|
||
}
|
||
_ => {
|
||
// Fallback: try TUR
|
||
let tur = [SCSI_TEST_UNIT_READY, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||
let mut empty = [0u8; 0];
|
||
match self.scsi.as_mut().execute(
|
||
&tur,
|
||
crate::scsi::DataDirection::None,
|
||
&mut empty,
|
||
5_000,
|
||
) {
|
||
Ok(_) => DriveStatus::DiscPresent,
|
||
Err(ref e)
|
||
if e.scsi_sense()
|
||
.is_some_and(|s| s.is_not_ready() || s.is_unit_attention()) =>
|
||
{
|
||
DriveStatus::NotReady
|
||
}
|
||
_ => DriveStatus::Unknown,
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn platform_name(&self) -> &str {
|
||
match self.platform {
|
||
Some(ref p) => p.name(),
|
||
None => "Unknown",
|
||
}
|
||
}
|
||
|
||
pub fn device_path(&self) -> &str {
|
||
&self.device_path
|
||
}
|
||
|
||
/// Initialize drive — unlock + firmware upload.
|
||
/// Optional. Adds features: removes riplock, enables UHD reads, speed control.
|
||
pub fn init(&mut self) -> Result<()> {
|
||
match self.driver {
|
||
Some(ref mut d) => d.init(self.scsi.as_mut()),
|
||
None => Err(Error::UnsupportedDrive {
|
||
vendor_id: self.drive_id.vendor_id.trim().to_string(),
|
||
product_id: self.drive_id.product_id.trim().to_string(),
|
||
product_revision: self.drive_id.product_revision.trim().to_string(),
|
||
}),
|
||
}
|
||
}
|
||
|
||
/// Probe disc surface so the drive firmware learns optimal read speeds
|
||
/// per region. After this the host reads at max speed and the drive
|
||
/// manages zones internally.
|
||
pub fn probe_disc(&mut self) -> Result<()> {
|
||
match self.driver {
|
||
Some(ref mut d) => d.probe_disc(self.scsi.as_mut()),
|
||
None => Err(Error::UnsupportedDrive {
|
||
vendor_id: self.drive_id.vendor_id.trim().to_string(),
|
||
product_id: self.drive_id.product_id.trim().to_string(),
|
||
product_revision: self.drive_id.product_revision.trim().to_string(),
|
||
}),
|
||
}
|
||
}
|
||
|
||
/// Query a specific GET CONFIGURATION feature by code.
|
||
/// Returns the feature data (without the 8-byte header), or None if not available.
|
||
pub fn get_config_feature(&mut self, feature_code: u16) -> Option<Vec<u8>> {
|
||
let cdb = [
|
||
crate::scsi::SCSI_GET_CONFIGURATION,
|
||
0x02,
|
||
(feature_code >> 8) as u8,
|
||
feature_code as u8,
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
0x01,
|
||
0x00,
|
||
0x00,
|
||
];
|
||
let mut buf = vec![0u8; 256];
|
||
let r = self
|
||
.scsi
|
||
.as_mut()
|
||
.execute(
|
||
&cdb,
|
||
crate::scsi::DataDirection::FromDevice,
|
||
&mut buf,
|
||
5_000,
|
||
)
|
||
.ok()?;
|
||
if r.bytes_transferred > 8 {
|
||
Some(buf[8..r.bytes_transferred].to_vec())
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// Read REPORT KEY RPC state (region playback control).
|
||
pub fn report_key_rpc_state(&mut self) -> Option<Vec<u8>> {
|
||
let cdb = [
|
||
SCSI_REPORT_KEY,
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
0x08,
|
||
0x08,
|
||
0x00,
|
||
];
|
||
let mut buf = vec![0u8; 8];
|
||
let r = self
|
||
.scsi
|
||
.as_mut()
|
||
.execute(
|
||
&cdb,
|
||
crate::scsi::DataDirection::FromDevice,
|
||
&mut buf,
|
||
5_000,
|
||
)
|
||
.ok()?;
|
||
if r.bytes_transferred > 0 {
|
||
Some(buf[..r.bytes_transferred].to_vec())
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// Read MODE SENSE page data.
|
||
pub fn mode_sense_page(&mut self, page: u8) -> Option<Vec<u8>> {
|
||
let cdb = [
|
||
SCSI_MODE_SENSE,
|
||
0x00,
|
||
page,
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
0xFC,
|
||
0x00,
|
||
];
|
||
let mut buf = vec![0u8; 252];
|
||
let r = self
|
||
.scsi
|
||
.as_mut()
|
||
.execute(
|
||
&cdb,
|
||
crate::scsi::DataDirection::FromDevice,
|
||
&mut buf,
|
||
5_000,
|
||
)
|
||
.ok()?;
|
||
if r.bytes_transferred > 0 {
|
||
Some(buf[..r.bytes_transferred].to_vec())
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// Read vendor-specific READ BUFFER data.
|
||
pub fn read_buffer(&mut self, mode: u8, buffer_id: u8, length: u16) -> Option<Vec<u8>> {
|
||
let cdb = crate::scsi::build_read_buffer(mode, buffer_id, 0, length as u32);
|
||
let mut buf = vec![0u8; length as usize];
|
||
let r = self
|
||
.scsi
|
||
.as_mut()
|
||
.execute(
|
||
&cdb,
|
||
crate::scsi::DataDirection::FromDevice,
|
||
&mut buf,
|
||
5_000,
|
||
)
|
||
.ok()?;
|
||
if r.bytes_transferred > 0 {
|
||
Some(buf[..r.bytes_transferred].to_vec())
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
pub fn is_ready(&self) -> bool {
|
||
match self.driver {
|
||
Some(ref d) => d.is_ready(),
|
||
None => false,
|
||
}
|
||
}
|
||
|
||
/// Read sectors from the disc. Single-shot — no inline retries, no
|
||
/// SCSI reset.
|
||
///
|
||
/// `recovery=true` uses [`crate::scsi::READ_RECOVERY_TIMEOUT_MS`] (60 s,
|
||
/// matches sg_dd) for the `Disc::patch` pass; `recovery=false` uses
|
||
/// [`crate::scsi::READ_TIMEOUT_MS`] (30 s, matches the kernel's
|
||
/// `/sys/block/sr*/device/timeout` default) for `Disc::copy`'s fast
|
||
/// skip-forward sweep. Both budgets are generous enough that the drive
|
||
/// can finish ECC recovery on a marginal sector — pre-0.13.21 this was
|
||
/// 1.5 s on the fast path which forced the kernel mid-layer to time
|
||
/// out and escalate while we waited anyway. On any failure returns
|
||
/// `Err(DiscRead)` immediately; orchestration (`Disc::patch` multi-pass,
|
||
/// `DiscStream` adaptive batch halving) handles retry policy.
|
||
///
|
||
/// Inline retry phases (5× gentle + reset+reopen + 5× more) were
|
||
/// removed in 0.13.6. Per
|
||
/// the stop-wedge postmortem (2026-04-25),
|
||
/// the inline reset on the LG BU40N (Initio bridge) wedged drive
|
||
/// firmware without ever recovering a sector. The remaining recovery
|
||
/// layers (Disc::patch multi-pass, DiscStream batch halving) do not
|
||
/// touch the wedge-prone reset path.
|
||
pub fn read(&mut self, lba: u32, count: u16, buf: &mut [u8], recovery: bool) -> Result<usize> {
|
||
let timeout_ms = if recovery {
|
||
crate::scsi::READ_RECOVERY_TIMEOUT_MS
|
||
} else {
|
||
crate::scsi::READ_TIMEOUT_MS
|
||
};
|
||
tracing::debug!(
|
||
target: "freemkv::drive",
|
||
lba,
|
||
count,
|
||
recovery,
|
||
timeout_ms,
|
||
"Drive::read enter"
|
||
);
|
||
let cdb = [
|
||
crate::scsi::SCSI_READ_10,
|
||
0x00,
|
||
(lba >> 24) as u8,
|
||
(lba >> 16) as u8,
|
||
(lba >> 8) as u8,
|
||
lba as u8,
|
||
0x00,
|
||
(count >> 8) as u8,
|
||
count as u8,
|
||
0x00,
|
||
];
|
||
|
||
match self.checked_exec(
|
||
&cdb,
|
||
crate::scsi::DataDirection::FromDevice,
|
||
buf,
|
||
timeout_ms,
|
||
) {
|
||
Ok(result) => Ok(result.bytes_transferred),
|
||
Err(Error::Halted) => Err(Error::Halted),
|
||
Err(e) => {
|
||
let (status, sense) = extract_scsi_context(&e);
|
||
tracing::warn!(
|
||
target: "freemkv::drive",
|
||
lba,
|
||
count,
|
||
inner_error = %e,
|
||
scsi_status = status,
|
||
"Drive::read checked_exec failed"
|
||
);
|
||
|
||
// /dev/sr0 pread fallback (Linux only). The kernel
|
||
// sr_mod driver auto-retries failed reads (~5× per
|
||
// command). Empirically (BU40N + Dune Part 2 UHD,
|
||
// 2026-05-08) dd via /dev/sr0 recovers ~50% of bad
|
||
// sectors that a single-shot SG_IO READ misses.
|
||
#[cfg(target_os = "linux")]
|
||
if recovery {
|
||
if let Some(fd) = self.block_dev_fd {
|
||
let len = count as usize * 2048;
|
||
if buf.len() >= len {
|
||
let offset = lba as i64 * 2048;
|
||
// Drop kernel cache for this region so we get
|
||
// a fresh device read, not stale page-cache
|
||
// data from a prior successful neighbour read.
|
||
let _ = unsafe {
|
||
libc::posix_fadvise(
|
||
fd,
|
||
offset,
|
||
len as i64,
|
||
libc::POSIX_FADV_DONTNEED,
|
||
)
|
||
};
|
||
let n = unsafe {
|
||
libc::pread(fd, buf.as_mut_ptr() as *mut libc::c_void, len, offset)
|
||
};
|
||
if n == len as isize {
|
||
tracing::info!(
|
||
target: "freemkv::drive",
|
||
lba,
|
||
count,
|
||
bytes = len,
|
||
"Drive::read recovered via /dev/sr0 pread fallback"
|
||
);
|
||
return Ok(len);
|
||
}
|
||
tracing::debug!(
|
||
target: "freemkv::drive",
|
||
lba,
|
||
count,
|
||
pread_ret = n as i64,
|
||
errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
|
||
"/dev/sr0 pread fallback also failed"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
Err(Error::DiscRead {
|
||
sector: lba as u64,
|
||
status: Some(status),
|
||
sense,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Read the disc capacity in sectors (2048 bytes each).
|
||
pub fn read_capacity(&mut self) -> Result<u32> {
|
||
let cdb = [
|
||
crate::scsi::SCSI_READ_CAPACITY,
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
];
|
||
let mut buf = [0u8; 8];
|
||
self.scsi.as_mut().execute(
|
||
&cdb,
|
||
crate::scsi::DataDirection::FromDevice,
|
||
&mut buf,
|
||
5_000,
|
||
)?;
|
||
let last_lba = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]);
|
||
Ok(last_lba + 1)
|
||
}
|
||
|
||
pub fn set_speed(&mut self, speed_kbs: u16) {
|
||
let cdb = crate::scsi::build_set_cd_speed(speed_kbs);
|
||
let mut dummy = [0u8; 0];
|
||
let _ = self.scsi_execute(&cdb, crate::scsi::DataDirection::None, &mut dummy, 5_000);
|
||
}
|
||
|
||
/// Lock the tray so the disc cannot be ejected during a rip.
|
||
pub fn lock_tray(&mut self) {
|
||
let prevent = [
|
||
SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL,
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
0x01,
|
||
0x00,
|
||
];
|
||
let mut buf = [0u8; 0];
|
||
let _ =
|
||
self.scsi
|
||
.as_mut()
|
||
.execute(&prevent, crate::scsi::DataDirection::None, &mut buf, 5_000);
|
||
}
|
||
|
||
/// Unlock the tray so the user can manually eject the disc.
|
||
pub fn unlock_tray(&mut self) {
|
||
let allow = [
|
||
SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL,
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
];
|
||
let mut buf = [0u8; 0];
|
||
let _ =
|
||
self.scsi
|
||
.as_mut()
|
||
.execute(&allow, crate::scsi::DataDirection::None, &mut buf, 5_000);
|
||
}
|
||
|
||
/// Eject the disc tray. Unlocks first, then ejects.
|
||
pub fn eject(&mut self) -> Result<()> {
|
||
self.unlock_tray();
|
||
let eject_cdb = [SCSI_START_STOP_UNIT, 0, 0, 0, 0x02, 0];
|
||
let mut buf = [0u8; 0];
|
||
self.scsi.as_mut().execute(
|
||
&eject_cdb,
|
||
crate::scsi::DataDirection::None,
|
||
&mut buf,
|
||
30_000,
|
||
)?;
|
||
Ok(())
|
||
}
|
||
|
||
pub fn scsi_execute(
|
||
&mut self,
|
||
cdb: &[u8],
|
||
direction: crate::scsi::DataDirection,
|
||
buf: &mut [u8],
|
||
timeout_ms: u32,
|
||
) -> Result<crate::scsi::ScsiResult> {
|
||
self.scsi.as_mut().execute(cdb, direction, buf, timeout_ms)
|
||
}
|
||
}
|
||
|
||
impl Drop for Drive {
|
||
fn drop(&mut self) {
|
||
self.cleanup();
|
||
// SgIoTransport::drop() runs next, calling libc::close(fd)
|
||
#[cfg(target_os = "linux")]
|
||
if let Some(fd) = self.block_dev_fd.take() {
|
||
unsafe { libc::close(fd) };
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Resolve a `/dev/sg*` path to the corresponding `/dev/sr*` block
|
||
/// device by walking sysfs, then open it for read (no `O_DIRECT` —
|
||
/// `posix_fadvise(POSIX_FADV_DONTNEED)` flushes the cache before each
|
||
/// pread, which avoids buffer-alignment requirements while still
|
||
/// forcing fresh device reads).
|
||
///
|
||
/// Returns `None` on any error (sysfs not present, no matching block
|
||
/// device, open failed). Callers treat that as "no fallback available"
|
||
/// and propagate the original SCSI READ error.
|
||
#[cfg(target_os = "linux")]
|
||
fn open_block_device_for_sg(sg_path: &Path) -> Option<std::os::unix::io::RawFd> {
|
||
let basename = sg_path.file_name()?.to_str()?;
|
||
if !basename.starts_with("sg") {
|
||
return None;
|
||
}
|
||
let sysfs_dir = format!("/sys/class/scsi_generic/{}/device/block", basename);
|
||
let entries = std::fs::read_dir(&sysfs_dir).ok()?;
|
||
let block_name = entries
|
||
.flatten()
|
||
.find_map(|e| e.file_name().into_string().ok())?;
|
||
let block_path = format!("/dev/{}", block_name);
|
||
|
||
let mut bytes = block_path.as_bytes().to_vec();
|
||
bytes.push(0);
|
||
let fd = unsafe {
|
||
libc::open(
|
||
bytes.as_ptr() as *const libc::c_char,
|
||
libc::O_RDONLY | libc::O_CLOEXEC,
|
||
)
|
||
};
|
||
if fd < 0 {
|
||
tracing::debug!(
|
||
target: "freemkv::drive",
|
||
sg = basename,
|
||
block_path,
|
||
errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
|
||
"Failed to open block device for fallback; sr0 fallback disabled"
|
||
);
|
||
None
|
||
} else {
|
||
tracing::info!(
|
||
target: "freemkv::drive",
|
||
sg = basename,
|
||
block_path,
|
||
fd,
|
||
"Opened /dev/sr* as recovery fallback for failed SCSI reads"
|
||
);
|
||
Some(fd)
|
||
}
|
||
}
|
||
|
||
impl SectorSource for Drive {
|
||
fn read_sectors(
|
||
&mut self,
|
||
lba: u32,
|
||
count: u16,
|
||
buf: &mut [u8],
|
||
recovery: bool,
|
||
) -> Result<usize> {
|
||
self.read(lba, count, buf, recovery)
|
||
}
|
||
|
||
fn set_speed(&mut self, kbs: u16) {
|
||
Drive::set_speed(self, kbs);
|
||
}
|
||
}
|
||
|
||
/// Find the first optical drive on this system and open it.
|
||
///
|
||
/// For just listing drives without opening (e.g. UI sidebar), use
|
||
/// `scsi::list_drives()` — that returns `DriveInfo` (path + identity)
|
||
/// without the cost of running every drive's profile + identity probe.
|
||
pub fn find_drive() -> Option<Drive> {
|
||
discover_drives()
|
||
.into_iter()
|
||
.find_map(|(path, _)| Drive::open(std::path::Path::new(&path)).ok())
|
||
}
|
||
|
||
/// Halt-aware sleep primitive — wakes within ~100 ms of `halt` flipping
|
||
/// to true. Kept for the unit tests that cover the slicing behaviour;
|
||
/// production code paths no longer sleep on the recovery hot path
|
||
/// (recovery loop removed in 0.13.6).
|
||
#[cfg(test)]
|
||
fn sleep_until_halted(halt: &AtomicBool, total: std::time::Duration) -> Result<()> {
|
||
const SLICE: std::time::Duration = std::time::Duration::from_millis(100);
|
||
let deadline = std::time::Instant::now() + total;
|
||
loop {
|
||
if halt.load(Ordering::Relaxed) {
|
||
return Err(Error::Halted);
|
||
}
|
||
let now = std::time::Instant::now();
|
||
if now >= deadline {
|
||
return Ok(());
|
||
}
|
||
let remaining = deadline - now;
|
||
std::thread::sleep(remaining.min(SLICE));
|
||
}
|
||
}
|
||
|
||
/// Internal: discover drive paths + IDs without opening full Drive objects.
|
||
fn discover_drives() -> Vec<(String, DriveId)> {
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
linux::find_drives()
|
||
}
|
||
#[cfg(target_os = "macos")]
|
||
{
|
||
macos::find_drives()
|
||
}
|
||
#[cfg(windows)]
|
||
{
|
||
windows::find_drives()
|
||
}
|
||
}
|
||
|
||
/// Resolve a device path to its raw SCSI device, with optional warning message.
|
||
#[allow(dead_code)]
|
||
pub(crate) fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
linux::resolve_device(path)
|
||
}
|
||
#[cfg(target_os = "macos")]
|
||
{
|
||
macos::resolve_device(path)
|
||
}
|
||
#[cfg(windows)]
|
||
{
|
||
windows::resolve_device(path)
|
||
}
|
||
}
|
||
|
||
fn create_driver(
|
||
platform: profile::Platform,
|
||
profile: &DriveProfile,
|
||
) -> Result<Box<dyn PlatformDriver>> {
|
||
match platform {
|
||
profile::Platform::Mt1959A => Ok(Box::new(Mt1959::new(profile.clone(), false))),
|
||
profile::Platform::Mt1959B => Ok(Box::new(Mt1959::new(profile.clone(), true))),
|
||
profile::Platform::Renesas => Err(Error::PlatformNotImplemented {
|
||
platform: "renesas".to_string(),
|
||
}),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod halt_tests {
|
||
use super::*;
|
||
use std::time::{Duration, Instant};
|
||
|
||
#[test]
|
||
fn sleep_until_halted_completes_when_not_halted() {
|
||
let flag = AtomicBool::new(false);
|
||
let t0 = Instant::now();
|
||
let r = sleep_until_halted(&flag, Duration::from_millis(150));
|
||
assert!(r.is_ok());
|
||
assert!(t0.elapsed() >= Duration::from_millis(140));
|
||
}
|
||
|
||
#[test]
|
||
fn sleep_until_halted_returns_immediately_if_preflagged() {
|
||
let flag = AtomicBool::new(true);
|
||
let t0 = Instant::now();
|
||
let r = sleep_until_halted(&flag, Duration::from_secs(10));
|
||
assert!(matches!(r, Err(Error::Halted)));
|
||
// Must wake within one slice (100 ms) — the whole point of the
|
||
// primitive is that a 30 s sleep doesn't block Stop.
|
||
assert!(t0.elapsed() < Duration::from_millis(200));
|
||
}
|
||
|
||
#[test]
|
||
fn sleep_until_halted_wakes_mid_sleep() {
|
||
let flag = Arc::new(AtomicBool::new(false));
|
||
let f2 = flag.clone();
|
||
let t0 = Instant::now();
|
||
std::thread::spawn(move || {
|
||
std::thread::sleep(Duration::from_millis(150));
|
||
f2.store(true, Ordering::Relaxed);
|
||
});
|
||
let r = sleep_until_halted(&flag, Duration::from_secs(10));
|
||
assert!(matches!(r, Err(Error::Halted)));
|
||
let waited = t0.elapsed();
|
||
// Flag flipped at ~150 ms; we wake within one 100 ms slice → <300 ms.
|
||
assert!(waited < Duration::from_millis(350), "waited {waited:?}");
|
||
assert!(waited >= Duration::from_millis(140), "waited {waited:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn sleep_until_halted_zero_duration_is_noop_when_not_halted() {
|
||
let flag = AtomicBool::new(false);
|
||
let r = sleep_until_halted(&flag, Duration::ZERO);
|
||
assert!(r.is_ok());
|
||
}
|
||
}
|