session, io: Stop through held sessions, kept partials, <final>.lock

Key-source contexts carry the op token and progress; DiscSession gains
open_with, scan_with and finish on the held handle. A consumer that
closes after a cancel keeps *.partial, and an artifact lock sidecar
guards each final output. Stop rules are enforced as lint tests.
This commit is contained in:
Matthew Jackson
2026-09-28 09:11:34 -07:00
parent fdab162bc4
commit 25fd393c04
28 changed files with 2426 additions and 39 deletions
+30 -11
View File
@@ -609,18 +609,11 @@ impl FeRereads {
}
}
// Sleep `pause` in short slices; a Stop during it is `Halted`.
// A Stop during the pause is `Halted`; with no token nothing can cancel it.
fn pause(&self) -> Result<()> {
let end = std::time::Instant::now() + self.pause;
loop {
if self.halt.as_ref().is_some_and(|h| h.is_cancelled()) {
return Err(Error::Halted);
}
let now = std::time::Instant::now();
if now >= end {
return Ok(());
}
std::thread::sleep((end - now).min(std::time::Duration::from_millis(50)));
match &self.halt {
Some(h) => h.wait(self.pause),
None => crate::halt::Halt::new().wait(self.pause),
}
}
}
@@ -9794,6 +9787,32 @@ mod tests {
assert_eq!(r.reads.len(), 1, "no re-read after the Stop");
}
// §5.8 "Use `Halt::wait` / `pause` instead" of `thread::sleep`: with or without a
// token the re-read pause is a `Halt::wait`, so a NoBlockingScope refuses it.
#[cfg(debug_assertions)]
#[test]
fn the_reread_pause_is_a_halt_wait() {
for halt in [Some(crate::halt::Halt::new()), None] {
let rereads = FeRereads::with_pause(halt, std::time::Duration::from_millis(1));
let _scope = crate::halt::diag::NoBlockingScope::enter();
let got = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| rereads.pause()));
let msg = match got {
Err(p) => p.downcast_ref::<String>().cloned().unwrap_or_default(),
Ok(r) => format!("returned {r:?}"),
};
assert_eq!(msg, "Halt::wait (sleep) under NoBlockingScope");
}
}
// No token: nothing can end the pause early, so it is waited out in full.
#[test]
fn the_reread_pause_without_a_token_is_waited_out() {
let pause = std::time::Duration::from_millis(60);
let t0 = std::time::Instant::now();
assert!(FeRereads::with_pause(None, pause).pause().is_ok());
assert!(t0.elapsed() >= pause, "{:?}", t0.elapsed());
}
#[test]
fn the_reread_policy_is_two_per_file_four_per_scan_after_5s() {
assert_eq!((FE_REREADS_PER_FILE, FE_REREADS_PER_SCAN), (2, 4));
+75
View File
@@ -1018,3 +1018,78 @@ fn bus_step_guard_holds_busy_across_host_certs() {
assert!(!seen.is_empty() && seen.iter().all(|b| *b), "{seen:?}");
assert!(!p.is_busy(), "released after the step");
}
// ── Stop design §5.1 "Scan" LS7 and the session alias rule (ST-L3) ──
// A `DiscSession` brought up over `t`: under `halt` as `open_with` does, else as `open`.
fn session_over(
t: crate::test_util::FakeTransport,
halt: Option<&crate::halt::Halt>,
) -> crate::session::DiscSession {
let drive = match halt {
Some(h) => Drive::from_transport_with(Box::new(t), h),
None => Drive::from_transport(Box::new(t)),
};
crate::session::DiscSession::bring_up(drive, Default::default(), halt.cloned())
.expect("brought up")
}
/// LS7 (the scan half; KU's LK18 covers the library half): a Stop during
/// `scan_with` returns `Halted` and stores no `Disc`, and a key resolution on that
/// session then returns `Halted` too and builds no set.
#[test]
fn cancelled_scan_then_resolve_returns_halted_and_builds_no_set() {
use crate::test_util::FakeTransport;
let h = crate::halt::Halt::new();
let (t, fake) = FakeTransport::new();
let t = t
.with_inner(Box::new(ake(bd_disc(None), 0x0040)))
.cancel_on(nth(1, is_read10), &h)
.watch(&h);
let mut s = session_over(t, Some(&h));
let r = s.scan_with(with_hc()).map(|d| d.titles.len());
assert!(matches!(r, Err(Error::Halted)), "{r:?}");
assert!(s.disc().is_none(), "a stopped scan stores no Disc");
let sources: crate::session::KeySourceFactory = Arc::new(Vec::new);
let r = s.resolve_key_set(
crate::keys::KeyScope::WholeDisc,
&sources,
Default::default(),
);
assert!(matches!(r, Err(Error::Halted)), "{:?}", r.err());
let reads = fake.count(is_read10);
assert_eq!(
reads,
1,
"nothing read after the Stop: {:02x?}",
fake.cdbs()
);
}
/// Guard (LD8/LD9 at the session layer, §2.2 alias rule): a session from `open` (own
/// token) scans under the `ScanOptions.halt` alias and gets its own token back; a
/// session from `open_with` keeps its attached token, which wins over the alias.
#[test]
fn session_scan_with_follows_the_alias_rule() {
use crate::test_util::FakeTransport;
let alias = crate::halt::Halt::new();
let (t, _) = FakeTransport::new();
let t = t
.with_inner(Box::new(ake(bd_disc(None), 0x0040)))
.cancel_on(nth(1, is_read10), &alias);
let mut own = session_over(t, None);
let r = own.scan_with(with_halt(&alias)).map(|d| d.titles.len());
assert!(matches!(r, Err(Error::Halted)), "the alias stops it: {r:?}");
let drive = own.into_drive().expect("drive");
let tok = drive.token().expect("own token restored");
assert!(!Arc::ptr_eq(tok.as_arc(), alias.as_arc()) && !tok.is_cancelled());
let (op, alias) = (crate::halt::Halt::new(), crate::halt::Halt::new());
let (t, _) = FakeTransport::new();
let t = t
.with_inner(Box::new(ake(bd_disc(None), 0x0040)))
.cancel_on(nth(1, is_read10), &alias);
let mut attached = session_over(t, Some(&op));
let d = attached.scan_with(with_halt(&alias));
assert!(d.is_ok(), "the attached op token wins over the alias");
}
+13
View File
@@ -1438,6 +1438,19 @@ impl Drive {
Ok(())
}
/// `DiscSession::finish(Finish::Eject)` (§2.4 row 2): the ALLOW (after a Stop only
/// for a tray this Drive locked), then the eject. Both are clean-up CDBs, so a Stop
/// never refuses them; the eject is the one START STOP UNIT a Stop still admits.
pub(crate) fn finish_eject(&mut self) -> Result<()> {
self.unlock_tray();
// SS-6 MMC-6 Table 633: LoEj 1, Start 0 = "Eject the disc if permitted".
let eject_cdb = [SCSI_START_STOP_UNIT, 0, 0, 0, 0x02, 0];
let mut buf = [0u8; 0];
let dir = crate::scsi::DataDirection::None;
self.exec_cleanup(&eject_cdb, dir, &mut buf, 30_000, CleanupCtx::FinishEject)?;
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<()> {
-2
View File
@@ -39,7 +39,6 @@ pub struct DvdTitleSet {
/// A single title (from PGC + TT_SRPT chapter count).
#[derive(Debug)]
#[allow(dead_code)]
pub struct DvdTitle {
/// Number of chapters (PTTs)
pub chapters: u16,
@@ -185,7 +184,6 @@ pub enum DvdAspect {
/// DVD video stream attributes.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct DvdVideoAttr {
pub codec: Codec,
pub resolution: Resolution,
+389
View File
@@ -0,0 +1,389 @@
//! The artifact lock (stop design §2.5, T10): a sidecar `<final>.lock` next to a
//! resumable output, held for the whole op, so two freemkv processes on one host never
//! write one `.partial` (+ mapfile) at once.
//!
//! The sidecar is named after the FINAL artifact (`Movie.iso.lock`), the same before and
//! after the `.partial` → final rename, so a Resume that knows only `Movie.iso` and a live
//! op writing `Movie.iso.partial` contend on one file. It is not the mapfile (renamed on
//! every flush) and not the `.partial` (renamed or deleted). Exclusion is same-host only
//! (J-5.5-1): across NFS clients it depends on the mount's locking and is not claimed.
//!
//! Acquire = open read-write, exclusive lock, then check the open file is still the one
//! the path names (the holder deletes the sidecar only while holding it), else retry.
//! The wait is halt-aware and stall-based: it fails `TimedOut { op: "artifact_lock" }`
//! (E9073) only after 30 s in which neither the `.partial` nor a watched file changed.
use crate::error::{Error, Result};
use crate::halt::{Halt, Progress, Stall, StallTimer, WAIT_SLICE};
use std::fs::File;
use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
/// T10: the lock wait fails after this long with no change in the holder's output.
pub const ARTIFACT_LOCK_WINDOW: Duration = Duration::from_secs(30);
/// The sidecar for the artifact whose final name is `final_path`: `<final>.lock`.
pub fn lock_path(final_path: &Path) -> PathBuf {
with_suffix(final_path, ".lock")
}
fn with_suffix(p: &Path, suffix: &str) -> PathBuf {
let mut name = p.as_os_str().to_owned();
name.push(suffix);
PathBuf::from(name)
}
/// A held `<final>.lock`. Dropping it releases the lock and keeps the file, which then
/// guards the resumable artifact (after a Stop, a failure or a crash);
/// [`delete`](Self::delete) removes it on success or Discard.
#[derive(Debug)]
pub struct ArtifactLock {
file: File,
path: PathBuf,
}
impl ArtifactLock {
/// Take the sidecar lock for `final_path`, waiting while another process holds it.
/// The holder's progress is a change in the size or mtime of `<final>.partial` or of
/// any `watch` path (e.g. the mapfile), read by path on each poll (T10).
///
/// # Errors
///
/// [`Error::Halted`] on a Stop (within one [`WAIT_SLICE`]);
/// [`Error::TimedOut`] `{ op: "artifact_lock" }` after [`ARTIFACT_LOCK_WINDOW`] with
/// no progress (on Windows also the surface of a permission failure);
/// [`Error::IoError`] when the sidecar cannot be opened or locked.
pub fn acquire(final_path: &Path, watch: &[&Path], halt: &Halt) -> Result<ArtifactLock> {
Self::acquire_within(final_path, watch, halt, ARTIFACT_LOCK_WINDOW)
}
// `acquire` with the T10 window as a parameter, so tests run in milliseconds.
pub(crate) fn acquire_within(
final_path: &Path,
watch: &[&Path],
halt: &Halt,
window: Duration,
) -> Result<ArtifactLock> {
Self::acquire_ids(final_path, watch, halt, window, &|f, p| {
(os::file_id(f), os::path_id(p))
})
}
// `acquire_within` with the identity check as a parameter (tests inject ESTALE and
// a lasting mismatch): `ids` is (the open file's id, the path's id).
fn acquire_ids(
final_path: &Path,
watch: &[&Path],
halt: &Halt,
window: Duration,
ids: &Ids<'_>,
) -> Result<ArtifactLock> {
let path = lock_path(final_path);
let mut wait = LockWait::new(final_path, watch, window);
loop {
halt.check()?;
let file = match os::open_rw(&path) {
Ok(f) => f,
// Windows: a delete-pending sidecar refuses the open; the holder is leaving.
Err(e) if os::open_retryable(&e) => {
wait.slice(halt, &e)?;
continue;
}
Err(e) => return Err(Error::IoError { source: e }),
};
// SS-8 flock(2): "Only one process may hold an exclusive lock for a given file at
// a given time." SS-10 LockFileEx: LOCKFILE_EXCLUSIVE_LOCK, FAIL_IMMEDIATELY.
while !os::try_lock_exclusive(&file).map_err(|source| Error::IoError { source })? {
wait.slice(halt, &io::Error::from(io::ErrorKind::WouldBlock))?;
}
// SS-9 XBD <sys/stat.h>: "A file identity is uniquely determined by the combination
// of st_dev and st_ino." SS-10: "the identifier … and the volume serial number".
let retry = match ids(&file, &path) {
(Ok(held), Ok(named)) if held == named => return Ok(ArtifactLock { file, path }),
// The holder deleted it while we waited: we hold an unlinked file.
(Ok(_), Ok(_)) => io::Error::other("the sidecar was replaced"),
// §2.5: ENOENT/ESTALE from `fstat` or `stat` (another client deleted it).
(Err(e), _) | (_, Err(e)) if os::id_retryable(&e) => e,
(Err(e), _) | (_, Err(e)) => return Err(Error::IoError { source: e }),
};
// A retry is a wait like any other: halt-aware, and under T10 (never a spin).
drop(file);
wait.slice(halt, &retry)?;
}
}
/// The sidecar's path.
pub fn path(&self) -> &Path {
&self.path
}
/// Delete the sidecar while still holding it (success or Discard, §2.5), then release.
///
/// # Errors
///
/// [`Error::IoError`] if the file exists but cannot be removed.
pub fn delete(self) -> Result<()> {
let ArtifactLock { file, path } = self;
let removed = match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(source) => Err(Error::IoError { source }),
};
// Released only now: nobody can lock the path between the check and the unlink.
drop(file);
removed
}
}
// (the open file's identity, the path's identity).
type Ids<'a> = dyn Fn(&File, &Path) -> (io::Result<(u64, u64)>, io::Result<(u64, u64)>) + 'a;
// The T10 wait between lock attempts: halt-aware, failing only after `window` with no
// change in any watched file.
struct LockWait {
watched: Vec<PathBuf>,
seen: Vec<Option<(u64, Option<SystemTime>)>>,
progress: Progress,
timer: StallTimer,
}
impl LockWait {
fn new(final_path: &Path, watch: &[&Path], window: Duration) -> Self {
let mut watched = vec![with_suffix(final_path, ".partial")];
watched.extend(watch.iter().map(|p| p.to_path_buf()));
let seen = signatures(&watched);
let progress = Progress::new();
let timer = StallTimer::new(window, &progress);
LockWait {
watched,
seen,
progress,
timer,
}
}
// One `WAIT_SLICE`: `Halted` on a Stop, `TimedOut` once the window passes with no
// change. `last` is the OS answer that made us wait (logged on expiry).
fn slice(&mut self, halt: &Halt, last: &io::Error) -> Result<()> {
let now = signatures(&self.watched);
if now != self.seen {
self.seen = now;
self.progress.bump();
}
if self.timer.poll(&self.progress) == Stall::Expired {
tracing::warn!(
target: "freemkv::io",
phase = "artifact_lock",
last_os_error = %last,
"another process holds the artifact lock and its output has not changed"
);
return Err(Error::TimedOut {
op: "artifact_lock",
});
}
halt.wait(WAIT_SLICE)
}
}
// Size and mtime of each path, read by path (a path `stat` follows a rename).
fn signatures(paths: &[PathBuf]) -> Vec<Option<(u64, Option<SystemTime>)>> {
paths
.iter()
.map(|p| {
std::fs::metadata(p)
.ok()
.map(|m| (m.len(), m.modified().ok()))
})
.collect()
}
#[cfg(unix)]
mod os {
use std::fs::File;
use std::io;
use std::os::unix::fs::MetadataExt;
use std::path::Path;
// Read-write, always: an NFS client emulates flock as a byte-range lock, which needs
// the file open for writing (SS-8).
pub(super) fn open_rw(p: &Path) -> io::Result<File> {
std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(p)
}
pub(super) fn open_retryable(_e: &io::Error) -> bool {
false
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub(super) fn try_lock_exclusive(f: &File) -> io::Result<bool> {
use std::os::fd::AsRawFd;
loop {
// SAFETY: the fd is open for the whole call (it is borrowed from `f`).
let rc = unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if rc == 0 {
return Ok(true);
}
let e = io::Error::last_os_error();
match e.raw_os_error() {
Some(libc::EWOULDBLOCK) => return Ok(false),
Some(libc::EINTR) => continue,
_ => return Err(e),
}
}
}
// No `libc` on this target: no OS lock, so no exclusion (not a shipped platform).
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
pub(super) fn try_lock_exclusive(_f: &File) -> io::Result<bool> {
Ok(true)
}
pub(super) fn file_id(f: &File) -> io::Result<(u64, u64)> {
let m = f.metadata()?;
Ok((m.dev(), m.ino()))
}
pub(super) fn path_id(p: &Path) -> io::Result<(u64, u64)> {
let m = std::fs::metadata(p)?;
Ok((m.dev(), m.ino()))
}
// Gone (ENOENT), or deleted by another NFS client (ESTALE): retry.
pub(super) fn id_retryable(e: &io::Error) -> bool {
#[cfg(any(target_os = "linux", target_os = "macos"))]
if e.raw_os_error() == Some(libc::ESTALE) {
return true;
}
e.kind() == io::ErrorKind::NotFound
}
}
#[cfg(windows)]
mod os {
use std::ffi::c_void;
use std::fs::File;
use std::io;
use std::os::windows::fs::OpenOptionsExt;
use std::os::windows::io::AsRawHandle;
use std::path::Path;
const FILE_SHARE_READ_WRITE_DELETE: u32 = 0x1 | 0x2 | 0x4;
const LOCKFILE_FAIL_IMMEDIATELY: u32 = 0x1;
const LOCKFILE_EXCLUSIVE_LOCK: u32 = 0x2;
const ERROR_ACCESS_DENIED: i32 = 5;
const ERROR_LOCK_VIOLATION: i32 = 33;
#[repr(C)]
struct Overlapped {
internal: usize,
internal_high: usize,
offset: u32,
offset_high: u32,
event: *mut c_void,
}
#[repr(C)]
#[derive(Default)]
struct ByHandleFileInformation {
attributes: u32,
created: [u32; 2],
accessed: [u32; 2],
written: [u32; 2],
volume_serial: u32,
size_high: u32,
size_low: u32,
links: u32,
index_high: u32,
index_low: u32,
}
unsafe extern "system" {
fn LockFileEx(
file: *mut c_void,
flags: u32,
reserved: u32,
bytes_low: u32,
bytes_high: u32,
overlapped: *mut Overlapped,
) -> i32;
fn GetFileInformationByHandle(file: *mut c_void, info: *mut ByHandleFileInformation)
-> i32;
}
// GENERIC_READ | GENERIC_WRITE, sharing read, write and delete, so the holder can
// delete the sidecar while a waiter has it open (SS-10 FILE_SHARE_DELETE).
pub(super) fn open_rw(p: &Path) -> io::Result<File> {
std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.share_mode(FILE_SHARE_READ_WRITE_DELETE)
.open(p)
}
// A delete-pending file refuses the open with ERROR_ACCESS_DENIED, which a real
// permission error also returns: retry, and let T10 surface a lasting one (§2.5).
pub(super) fn open_retryable(e: &io::Error) -> bool {
e.raw_os_error() == Some(ERROR_ACCESS_DENIED)
}
pub(super) fn try_lock_exclusive(f: &File) -> io::Result<bool> {
let mut ov = Overlapped {
internal: 0,
internal_high: 0,
offset: 0,
offset_high: 0,
event: std::ptr::null_mut(),
};
let flags = LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY;
// SAFETY: the handle is open for the call; `ov` is a zeroed OVERLAPPED that
// outlives it (the handle is synchronous, so the call completes before return).
let ok = unsafe { LockFileEx(f.as_raw_handle(), flags, 0, u32::MAX, u32::MAX, &mut ov) };
if ok != 0 {
return Ok(true);
}
let e = io::Error::last_os_error();
match e.raw_os_error() {
Some(ERROR_LOCK_VIOLATION) => Ok(false),
_ => Err(e),
}
}
fn handle_id(f: &File) -> io::Result<(u64, u64)> {
let mut info = ByHandleFileInformation::default();
// SAFETY: the handle is open for the call; `info` is a valid out-pointer.
let ok = unsafe { GetFileInformationByHandle(f.as_raw_handle(), &mut info) };
if ok == 0 {
return Err(io::Error::last_os_error());
}
let index = (u64::from(info.index_high) << 32) | u64::from(info.index_low);
Ok((u64::from(info.volume_serial), index))
}
pub(super) fn file_id(f: &File) -> io::Result<(u64, u64)> {
handle_id(f)
}
pub(super) fn path_id(p: &Path) -> io::Result<(u64, u64)> {
// No access rights: enough to query the file's identity.
let f = std::fs::OpenOptions::new()
.access_mode(0)
.share_mode(FILE_SHARE_READ_WRITE_DELETE)
.open(p)?;
handle_id(&f)
}
pub(super) fn id_retryable(e: &io::Error) -> bool {
e.kind() == io::ErrorKind::NotFound || e.raw_os_error() == Some(ERROR_ACCESS_DENIED)
}
}
#[cfg(test)]
mod tests;
+290
View File
@@ -0,0 +1,290 @@
//! Stop design v5.6 LP14a/b and the §2.5 acquire rules, on a scaled T10 window; SP6,
//! SP7 (SS-8, SS-9) and SP8 (SS-10, Windows: compile on dev, run on qa).
use super::*;
use std::io::Write;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::Instant;
const WINDOW: Duration = Duration::from_millis(200);
const SLACK: Duration = Duration::from_secs(1);
fn artifact() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("Movie.iso");
(dir, out)
}
fn take(out: &Path, window: Duration) -> Result<ArtifactLock> {
ArtifactLock::acquire_within(out, &[], &Halt::new(), window)
}
/// §2.5 name rule: "The sidecar is always named after the final artifact name,
/// `<final>.lock`" — the same before and after the rename; two artifacts sharing a
/// stem never share a lock.
#[test]
fn sidecar_is_named_after_the_final_artifact() {
let dir = Path::new("/srv/rips");
assert_eq!(
lock_path(&dir.join("Movie.iso")),
dir.join("Movie.iso.lock")
);
assert_ne!(
lock_path(&dir.join("Movie.iso")),
lock_path(&dir.join("Movie.mkv"))
);
}
/// LP14a (T10, stall pair a): the holder appends to the `.partial` every 0.5 × window
/// for 4 windows, then releases: the waiter is never timed out and takes the lock.
#[test]
fn artifact_lock_waits_while_holder_progresses() {
let (_dir, out) = artifact();
let holder = take(&out, WINDOW).expect("first taker");
let partial = with_suffix(&out, ".partial");
let writer = thread::spawn(move || {
let mut f = File::create(&partial).unwrap();
for _ in 0..8 {
thread::sleep(WINDOW / 2);
f.write_all(b"sector").unwrap();
}
drop(holder);
});
let t = Instant::now();
let got = take(&out, WINDOW);
let took = t.elapsed();
writer.join().unwrap();
assert!(got.is_ok(), "a progressing holder is waited for: {got:?}");
assert!(took >= WINDOW * 3, "waited past several windows: {took:?}");
}
/// LP14b (T10, stall pair b): a frozen holder → `TimedOut { op: "artifact_lock" }`
/// (E9073) within window + 1 s, never before the window.
#[test]
fn artifact_lock_times_out_on_frozen_holder() {
let (_dir, out) = artifact();
let _holder = take(&out, WINDOW).expect("first taker");
let t = Instant::now();
let r = take(&out, WINDOW);
let took = t.elapsed();
match r {
Err(
e @ Error::TimedOut {
op: "artifact_lock",
},
) => {
assert_eq!(e.code(), crate::error::E_TIMED_OUT)
}
other => panic!("expected TimedOut(artifact_lock), got {other:?}"),
}
assert!(took >= WINDOW, "fired before the window: {took:?}");
assert!(took <= WINDOW + SLACK, "fired late: {took:?}");
}
/// LP14b: "a cancel during the wait → `Halted`", within a slice (≤ 1 s asserted).
#[test]
fn artifact_lock_cancel_during_wait_is_halted() {
let (_dir, out) = artifact();
let _holder = take(&out, WINDOW).expect("first taker");
let halt = Halt::new();
let h2 = halt.clone();
let canceller = thread::spawn(move || {
thread::sleep(Duration::from_millis(50));
h2.cancel();
});
let t = Instant::now();
let r = ArtifactLock::acquire_within(&out, &[], &halt, Duration::from_secs(60));
canceller.join().unwrap();
assert!(matches!(r, Err(Error::Halted)), "{r:?}");
assert!(t.elapsed() <= SLACK, "{:?}", t.elapsed());
}
/// §2.5 acquire loop (the unlink race): the holder deletes the sidecar while holding it;
/// a waiter locked on the old, unlinked file re-checks identity and retries, so it ends
/// up holding the file the path names — which a third taker then cannot lock.
#[test]
fn artifact_lock_rechecks_identity_after_the_holder_deletes() {
let (_dir, out) = artifact();
let holder = take(&out, WINDOW).expect("first taker");
let (out2, got) = (out.clone(), Arc::new(AtomicBool::new(false)));
let flag = got.clone();
let waiter = thread::spawn(move || {
let lock = take(&out2, Duration::from_secs(10)).expect("second taker");
flag.store(true, Ordering::SeqCst);
thread::sleep(WINDOW * 2);
lock
});
thread::sleep(WINDOW / 2);
holder.delete().expect("deleted while held");
let t = Instant::now();
while !got.load(Ordering::SeqCst) {
assert!(t.elapsed() < SLACK * 5, "the waiter never took the lock");
thread::sleep(Duration::from_millis(5));
}
assert!(
lock_path(&out).exists(),
"the waiter re-created the sidecar"
);
let third = take(&out, Duration::from_millis(100));
assert!(
matches!(third, Err(Error::TimedOut { .. })),
"the waiter holds the file the path names: {third:?}"
);
drop(waiter.join().unwrap());
}
/// §2.5 lifetime: "Deleted on success … and on Discard, both while holding it. Kept
/// after Stop, a failure or a crash, where it guards the resumable artifact."
#[test]
fn artifact_lock_kept_on_drop_deleted_on_delete() {
let (_dir, out) = artifact();
drop(take(&out, WINDOW).expect("taken"));
assert!(lock_path(&out).exists(), "a dropped lock keeps its sidecar");
let again = take(&out, WINDOW).expect("a released lock can be taken again");
again.delete().expect("delete");
assert!(!lock_path(&out).exists(), "delete removes the sidecar");
}
/// T10's progress signal includes a watched file (the engine's mapfile), read by path.
#[test]
fn artifact_lock_watches_extra_paths() {
let (dir, out) = artifact();
let mapfile = dir.path().join("Movie.iso.mapfile");
let holder = take(&out, WINDOW).expect("first taker");
let m2 = mapfile.clone();
let writer = thread::spawn(move || {
for i in 0..8u8 {
thread::sleep(WINDOW / 2);
std::fs::write(&m2, vec![i; usize::from(i) + 1]).unwrap();
}
drop(holder);
});
let t = Instant::now();
let got = ArtifactLock::acquire_within(&out, &[&mapfile], &Halt::new(), WINDOW);
let took = t.elapsed();
writer.join().unwrap();
assert!(got.is_ok(), "mapfile progress re-arms T10: {got:?}");
assert!(took >= WINDOW * 3, "waited for the holder: {took:?}");
}
/// SP6 (SS-8, man7 flock(2)): "If a process uses open(2) (or similar) to obtain more
/// than one file descriptor for the same file, these file descriptors are treated
/// independently by flock()"; duplicates "refer to the same lock". Per spec.
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn flock_is_per_open_file_description() {
use std::os::fd::AsRawFd;
let (_dir, out) = artifact();
let p = lock_path(&out);
let a = os::open_rw(&p).unwrap();
let b = os::open_rw(&p).unwrap();
// SAFETY: both fds are open for the calls.
let rc = unsafe { libc::flock(a.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if rc != 0 {
let e = io::Error::last_os_error();
if matches!(e.raw_os_error(), Some(libc::ENOLCK | libc::EOPNOTSUPP)) {
eprintln!("SKIP SP6: flock unsupported on the tmpdir: {e}");
return;
}
panic!("flock: {e}");
}
assert!(
!os::try_lock_exclusive(&b).unwrap(),
"a second open conflicts"
);
let dup = a.try_clone().unwrap();
assert!(
os::try_lock_exclusive(&dup).unwrap(),
"a dup shares the lock"
);
}
/// SP7 (SS-9, XBD <sys/stat.h>): "A file identity is uniquely determined by the
/// combination of st_dev and st_ino" — so it follows the file across `rename`.
#[cfg(unix)]
#[test]
fn dev_ino_identify_file_across_rename() {
let (dir, out) = artifact();
let tmp = dir.path().join("Movie.iso.lock.tmp");
let f = os::open_rw(&tmp).unwrap();
let before = os::file_id(&f).unwrap();
std::fs::rename(&tmp, &out).unwrap();
assert_eq!(os::path_id(&out).unwrap(), before);
let other = os::open_rw(&tmp).unwrap();
assert_ne!(os::file_id(&other).unwrap(), before);
}
/// SP8 (SS-10, BY_HANDLE_FILE_INFORMATION): "The identifier (low and high parts) and the
/// volume serial number uniquely identify a file on a single computer." Runs on qa.
#[cfg(windows)]
#[test]
fn windows_file_id_identifies_file() {
let (dir, out) = artifact();
let a = os::open_rw(&out).unwrap();
assert_eq!(os::file_id(&a).unwrap(), os::path_id(&out).unwrap());
let other = dir.path().join("Other.iso");
let b = os::open_rw(&other).unwrap();
assert_ne!(os::file_id(&a).unwrap(), os::file_id(&b).unwrap());
assert!(os::try_lock_exclusive(&a).unwrap());
let second = os::open_rw(&out).unwrap();
assert!(
!os::try_lock_exclusive(&second).unwrap(),
"a second handle conflicts"
);
}
/// Review minor 4 (§2.5, T10): an identity mismatch that never clears is a wait, not a
/// spin — it ends `TimedOut { op: "artifact_lock" }` (E9073) after the window, having
/// re-checked about once per `WAIT_SLICE`.
#[test]
fn persistent_identity_mismatch_times_out_not_spins() {
let (_dir, out) = artifact();
let checks = std::sync::atomic::AtomicUsize::new(0);
let ids = |_: &File, _: &Path| {
checks.fetch_add(1, Ordering::SeqCst);
(Ok((1, 1)), Ok((1, 2)))
};
let t = Instant::now();
let r = ArtifactLock::acquire_ids(&out, &[], &Halt::new(), WINDOW, &ids);
let took = t.elapsed();
assert!(
matches!(
r,
Err(Error::TimedOut {
op: "artifact_lock"
})
),
"{r:?}"
);
assert!(took >= WINDOW && took <= WINDOW + SLACK, "{took:?}");
let n = checks.load(Ordering::SeqCst);
let per_slice = (WINDOW.as_millis() / WAIT_SLICE.as_millis()) as usize;
assert!(
n <= per_slice * 2 + 2,
"{n} re-checks in one window: a spin"
);
}
/// Review minor 3 (§2.5): "`ESTALE` from `fstat`/`stat` … is treated as 'retry'" — on
/// the open file's own id too; the next attempt then takes the lock.
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn estale_from_fstat_retries() {
let (_dir, out) = artifact();
let calls = std::sync::atomic::AtomicUsize::new(0);
let ids = |f: &File, p: &Path| {
if calls.fetch_add(1, Ordering::SeqCst) == 0 {
(
Err(io::Error::from_raw_os_error(libc::ESTALE)),
os::path_id(p),
)
} else {
(os::file_id(f), os::path_id(p))
}
};
let lock = ArtifactLock::acquire_ids(&out, &[], &Halt::new(), WINDOW, &ids);
assert!(lock.is_ok(), "{lock:?}");
assert_eq!(calls.load(Ordering::SeqCst), 2, "one retry, then held");
}
+2 -2
View File
@@ -321,7 +321,7 @@ fn chunk_halves_on_slow_flush() {
let ops = Arc::new(FakeFlushOps {
chunk_delay: Some(Box::new(|i| {
if i >= 2 {
Duration::from_millis(40)
Duration::from_millis(400)
} else {
Duration::ZERO
}
@@ -329,7 +329,7 @@ fn chunk_halves_on_slow_flush() {
..FakeFlushOps::default()
});
let t = FlushTiming {
slow_chunk: Duration::from_millis(20),
slow_chunk: Duration::from_millis(200),
chunk_min: K,
chunk_max: 4 * K,
..timing(Duration::from_secs(30))
+2
View File
@@ -7,6 +7,7 @@
//! channel + consumer thread. `byte_prefetcher` is the read-ahead
//! producer feeding the mux pipeline for `io::Read`-backed sources.
pub mod artifact_lock;
pub(crate) mod bounded;
pub mod byte_prefetcher;
pub mod file_sector_source;
@@ -22,6 +23,7 @@ pub(crate) mod platform_macos;
pub mod pipeline;
pub use artifact_lock::ArtifactLock;
pub use flush::{FlushProgress, durable_sync_file};
pub use writeback_file::WritebackFile;
+77 -5
View File
@@ -5,8 +5,8 @@
//! The consumer's behaviour is supplied by a [`Sink`] implementation:
//! `apply` is called once per item, `close` is called once at the end.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::sync::{Arc, OnceLock};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
@@ -81,6 +81,9 @@ mod state {
/// can no longer abandon it; it must wait for the result it is about to
/// produce.
pub const CLOSING: u8 = 2;
/// The consumer committed to finalising after the op token was cancelled: it runs
/// `Sink::close_stopped` (the output stays `*.partial`), and gets one grace (§2.5).
pub const CLOSING_STOPPED: u8 = 3;
}
fn join_result<R>(r: thread::Result<Result<R, Error>>) -> Result<R, Error> {
@@ -134,6 +137,11 @@ fn finish_with_grace<R: Send + 'static>(
drop(handle);
return Err(leak_err);
}
if state.load(Ordering::Acquire) == state::CLOSING_STOPPED {
// T8 (D2): a close begun after the Stop gets one grace, then is leaked; it can
// only leave `*.partial` behind.
return grace_then_leak(handle, grace, leak_err, on_poll);
}
tracing::warn!(
target: "freemkv::pipeline",
phase = "finish_with_halt_close_in_flight",
@@ -155,6 +163,29 @@ fn finish_with_grace<R: Send + 'static>(
}
}
// Wait `grace` for the consumer, then leak it with `leak_err`.
fn grace_then_leak<R: Send + 'static>(
handle: thread::JoinHandle<Result<R, Error>>,
grace: Duration,
leak_err: Error,
on_poll: &mut dyn FnMut(),
) -> Result<R, Error> {
let end = Instant::now().checked_add(grace);
let mut handle = handle;
loop {
let left = end.map_or(WAIT_SLICE, |e| e.saturating_duration_since(Instant::now()));
handle = match join_within(handle, left.min(WAIT_SLICE), None) {
Joined::Done(r) => return join_result(r),
Joined::Halted(h) | Joined::Pending(h) => h,
};
on_poll();
if end.is_some_and(|e| Instant::now() >= e) {
drop(handle);
return Err(leak_err);
}
}
}
/// Default channel depth for callers without a specific reason to
/// pick another value. Kept conservative (4) — most callers should
/// use WRITE_PIPELINE_DEPTH instead.
@@ -178,10 +209,9 @@ pub const WRITE_THROUGH_DEPTH: usize = 1;
///
/// `Stop` currently has no in-tree caller (sweep always processes its
/// full work-list; the mux highway drains to EOF), but it's part of the
/// fixed `Sink` contract, so `#[allow(dead_code)]` is intentional.
/// fixed `Sink` contract.
pub enum Flow {
Continue,
#[allow(dead_code)]
Stop,
}
@@ -206,6 +236,18 @@ pub trait Sink<I>: Send + 'static {
/// this to flush, fsync, finalise. Skipped if any prior `apply`
/// returned `Err`.
fn close(self) -> Result<Self::Output, Error>;
/// `close` for a consumer that committed to finalising only after the op was stopped
/// (stop design §2.5, §2.6: "A post-cancel `close()` leaves `*.partial`"): finish
/// writing, but keep the output under its `*.partial` name. Its result is the
/// pipeline's; the caller classifies the op by its token (§2.6). Defaults to `close`,
/// right for a sink that never renames its output.
fn close_stopped(self) -> Result<Self::Output, Error>
where
Self: Sized,
{
self.close()
}
}
/// Bounded producer/consumer pipeline. Holds the producer-side
@@ -230,6 +272,8 @@ pub struct Pipeline<I: Send + 'static, R: Send + 'static> {
failed: Arc<AtomicBool>,
/// The consumer's forward progress (T7): shared with the sink's output.
progress: Progress,
/// The op's token, read by the consumer when it commits to `close()` (§2.5).
op: Arc<OnceLock<Halt>>,
}
impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
@@ -268,6 +312,8 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
let failed = Arc::new(AtomicBool::new(false));
let failed_consumer = failed.clone();
let progress_consumer = progress.clone();
let op: Arc<OnceLock<Halt>> = Arc::default();
let op_consumer = op.clone();
let handle = thread::Builder::new()
.name(name.into())
.spawn(move || -> Result<R, Error> {
@@ -394,10 +440,16 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
// `abandoned`, letting `close()` finalise output already reported
// interrupted. Compare-exchange: loser skips `close()`, waits for winner.
None => {
// §2.6: Done only if the commit comes before the cancel.
let stopped = op_consumer.get().is_some_and(Halt::is_cancelled);
let commit = match stopped {
true => state::CLOSING_STOPPED,
false => state::CLOSING,
};
if state_consumer
.compare_exchange(
state::RUNNING,
state::CLOSING,
commit,
Ordering::AcqRel,
Ordering::Acquire,
)
@@ -406,7 +458,11 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
return Err(Error::Halted);
}
progress_consumer.bump();
let closed = sink.close();
// §2.5: after the cancel the output stays `*.partial`.
let closed = match stopped {
true => sink.close_stopped(),
false => sink.close(),
};
progress_consumer.bump();
closed
}
@@ -420,9 +476,19 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
state,
failed,
progress,
op,
})
}
/// Give the consumer the op's token (stop design §2.5): a consumer that reaches
/// `close()` after it is cancelled runs [`Sink::close_stopped`] instead and returns
/// that close's own result; only a close leaked after its grace gives `Halted`. The
/// first token set wins;
/// [`finish_with_halt`](Self::finish_with_halt) sets its `halt` if none was.
pub fn set_op_token(&self, halt: &Halt) {
let _ = self.op.set(halt.clone());
}
/// The consumer's forward-progress counter: bumped per item applied and at
/// `close()` entry and exit; [`finish_with_halt`](Self::finish_with_halt) waits on it.
pub fn progress(&self) -> &Progress {
@@ -565,6 +631,7 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
state: _,
failed: _,
progress: _,
op: _,
} = self;
// Explicit drop, although the destructure already drops `tx`
// at end-of-scope. Being explicit keeps the intent obvious.
@@ -601,7 +668,12 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
state,
failed: _,
progress,
op,
} = self;
// Before the consumer can commit to `close()`: it must see the op's token.
if let Some(h) = halt {
let _ = op.set(h.clone());
}
drop(tx);
// T7: a stall window over the consumer's progress, not a total from here.
let mut timer = StallTimer::new(timing.join_window, &progress);
+185
View File
@@ -322,3 +322,188 @@ fn consumer_bumps_progress_per_item_and_close() {
pipe.finish().unwrap();
assert!(progress.get() >= 5, "{}", progress.get());
}
// ── The ST-L3 `.partial` rule (§2.5, §2.6) ──
// Writes each item to `<out>.partial`; `close` renames it to `<out>` (the final name),
// `close_stopped` keeps it. Each close bumps `progress` per `step` for `steps` steps.
struct PartialFile {
file: std::fs::File,
partial: std::path::PathBuf,
out: std::path::PathBuf,
progress: Progress,
step: Duration,
steps: u32,
done: Arc<AtomicBool>,
}
impl PartialFile {
fn slow_close(&mut self) {
use std::io::Write;
for _ in 0..self.steps {
thread::sleep(self.step);
self.progress.bump();
}
self.file.flush().unwrap();
}
}
impl Sink<u64> for PartialFile {
type Output = ();
fn apply(&mut self, v: u64) -> Result<Flow, Error> {
use std::io::Write;
self.file.write_all(&v.to_le_bytes()).unwrap();
Ok(Flow::Continue)
}
fn close(mut self) -> Result<(), Error> {
self.slow_close();
std::fs::rename(&self.partial, &self.out).unwrap();
self.done.store(true, Ordering::SeqCst);
Ok(())
}
fn close_stopped(mut self) -> Result<(), Error> {
self.slow_close();
self.done.store(true, Ordering::SeqCst);
Ok(())
}
}
struct PartialRun {
_dir: tempfile::TempDir,
partial: std::path::PathBuf,
out: std::path::PathBuf,
done: Arc<AtomicBool>,
pipe: Pipeline<u64, ()>,
}
// A pipeline over a `PartialFile` sink whose closes take `steps` × GRACE / 2.
fn partial_run(steps: u32) -> PartialRun {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("Movie.mkv");
let partial = dir.path().join("Movie.mkv.partial");
let progress = Progress::new();
let done = Arc::new(AtomicBool::new(false));
let sink = PartialFile {
file: std::fs::File::create(&partial).unwrap(),
partial: partial.clone(),
out: out.clone(),
progress: progress.clone(),
step: GRACE / 2,
steps,
done: done.clone(),
};
let pipe = Pipeline::spawn_named_with_progress("t-partial", 4, sink, progress).unwrap();
for i in 0..4 {
pipe.send(i).unwrap();
}
PartialRun {
_dir: dir,
partial,
out,
done,
pipe,
}
}
/// LP3 (the ST-L3 rule, §2.5): "A consumer that entered CLOSING **after** the cancel
/// keeps its output under `*.partial`". It runs `close_stopped`: it finishes writing and
/// never renames (§2.6 "A post-cancel `close()` leaves `*.partial`").
#[test]
fn closing_after_cancel_keeps_partial() {
let run = partial_run(0);
let halt = Halt::new();
halt.cancel();
let r = run.pipe.finish_with_halt_timing(Some(&halt), timing());
assert!(r.is_ok(), "the stopped close's own result: {r:?}");
assert!(run.done.load(Ordering::SeqCst), "the stopped close ran");
assert!(!run.out.exists(), "never a final-named file after a Stop");
let kept = std::fs::metadata(&run.partial).expect("`.partial` kept");
assert_eq!(kept.len(), 32, "every applied item is in the `.partial`");
}
/// LP3 with T8 (D2: "5 s for a CLOSING one"): a post-cancel close gets one fixed grace,
/// not the re-arming wait a pre-cancel close gets (LP2). The leaked consumer still
/// cannot produce a final-named file.
#[test]
fn closing_after_cancel_gets_one_grace_and_never_renames() {
let run = partial_run(8);
let halt = Halt::new();
halt.cancel();
let t = Instant::now();
let r = run.pipe.finish_with_halt_timing(Some(&halt), timing());
let took = t.elapsed();
assert!(matches!(r, Err(Error::Halted)), "{r:?}");
assert!(took <= GRACE * 2 + SLACK, "no re-arming wait: {took:?}");
let end = Instant::now();
while !run.done.load(Ordering::SeqCst) {
assert!(end.elapsed() < SLACK * 5, "the leaked close never ended");
thread::sleep(Duration::from_millis(5));
}
assert!(!run.out.exists(), "the leaked consumer kept `*.partial`");
assert!(run.partial.exists());
}
/// LP3 guard (GUARD): the default `close_stopped` is `close`, so a sink that never
/// renames (the engine's sweep keeps its summary after a Stop) returns its output
/// unchanged. Per spec; do not change without a spec citation proving otherwise.
#[test]
fn closing_after_cancel_default_sink_keeps_its_output() {
let pipe = Pipeline::spawn(4, Sum(0)).unwrap();
pipe.send(7).unwrap();
let halt = Halt::new();
halt.cancel();
let r = pipe.finish_with_halt_timing(Some(&halt), timing());
assert!(matches!(r, Ok(7)), "{r:?}");
}
/// LP1 (the `*.partial` half, GUARD): a halted RUNNING consumer is abandoned after the
/// grace and never runs `close()`, so its output stays `*.partial`.
#[test]
fn halted_running_consumer_output_stays_partial() {
let dir = tempfile::tempdir().unwrap();
let partial = dir.path().join("Movie.iso.partial");
let out = dir.path().join("Movie.iso");
struct Wedged {
inner: PartialFile,
release: Arc<AtomicBool>,
started: Arc<AtomicBool>,
}
impl Sink<u64> for Wedged {
type Output = ();
fn apply(&mut self, v: u64) -> Result<Flow, Error> {
self.started.store(true, Ordering::SeqCst);
while !self.release.load(Ordering::SeqCst) {
thread::sleep(Duration::from_millis(5));
}
self.inner.apply(v)
}
fn close(self) -> Result<(), Error> {
self.inner.close()
}
}
let (release, started) = (Arc::new(AtomicBool::new(false)), Arc::default());
let sink = Wedged {
inner: PartialFile {
file: std::fs::File::create(&partial).unwrap(),
partial: partial.clone(),
out: out.clone(),
progress: Progress::new(),
step: Duration::ZERO,
steps: 0,
done: Arc::default(),
},
release: release.clone(),
started: Arc::clone(&started),
};
let pipe = Pipeline::spawn(4, sink).unwrap();
pipe.send(1).unwrap();
wait_until(&started);
let halt = Halt::new();
halt.cancel();
let r = pipe.finish_with_halt_timing(Some(&halt), timing());
assert!(matches!(r, Err(Error::Halted)), "{r:?}");
release.store(true, Ordering::SeqCst);
thread::sleep(GRACE);
assert!(!out.exists(), "an abandoned consumer never finalises");
assert!(partial.exists());
}
-1
View File
@@ -205,7 +205,6 @@ impl WritebackFile {
/// Callers that know the target output size should prefer
/// [`Self::create_with_size_hint`] so the kernel can pre-reserve
/// extents.
#[allow(dead_code)]
pub fn create(path: &Path) -> io::Result<Self> {
let file = File::create(path)?;
Self::new(file)
+15
View File
@@ -316,6 +316,21 @@ impl ResolvedKeySet {
)
}
/// [`resolve`](Self::resolve), reporting to the op's `progress` (stop design §2.1,
/// T29): every source call runs [`busy`](crate::halt::Progress::busy) on it, and each
/// source's ctx hands it out as [`ResolveCtx::progress`](crate::keysource::ResolveCtx::progress).
pub fn resolve_with_progress(
disc: &Disc,
reader: &mut dyn SectorSource,
scope: KeyScope,
sources: &KeySourceFactory,
opts: ResolveKeysOptions,
progress: &crate::halt::Progress,
) -> Result<KeyResolution> {
let clock = resolve::RealClock::new();
resolve::resolve_observed(disc, reader, scope, sources, opts, &clock, Some(progress))
}
/// A set holding no keys: for a raw copy, or a disc with no AACS.
pub fn none() -> ResolvedKeySet {
ResolvedKeySet(Arc::new(Inner::empty()))
+27 -2
View File
@@ -9,7 +9,7 @@ use crate::aacs::trace::{KeyNode, KeyOutcome, KeyStep, ResolutionTrace};
use crate::decrypt::{AacsKeyMap, Phase};
use crate::disc::{ContentFormat, Disc, DiscFormat, Extent};
use crate::error::{Error, Result};
use crate::halt::Halt;
use crate::halt::{Halt, Progress};
use crate::keysource::{DiscInputs, DiscInputsCtx, KeySource, MIN_SAMPLE_UNITS};
use crate::sector::SectorSource;
use crate::session::KeySourceFactory;
@@ -115,6 +115,8 @@ enum Asked {
// The state of one `resolve` run.
struct Run<'a> {
halt: Option<&'a Halt>,
// The op's progress: busy around each source call (stop design §2.1 item 2).
progress: Option<&'a Progress>,
clock: &'a dyn Clock,
format: ContentFormat,
sources: Vec<Box<dyn KeySource>>,
@@ -165,7 +167,7 @@ impl Run<'_> {
self.requests += 1;
let mut inputs = self.inputs.clone();
inputs.samples = samples.to_vec();
let ctx = DiscInputsCtx::new(&inputs);
let ctx = DiscInputsCtx::new(&inputs).with_stop(self.halt, self.progress);
let start = self.clock.now();
let mut wait = RETRY_FIRST;
let mut attempted = false;
@@ -179,6 +181,9 @@ impl Run<'_> {
attempted = true;
let src = &self.sources[i];
let who = src.label().to_string();
// Stop §2.1 item 2 (ST4-2): a source call (a keydb parse, a key-service call)
// moves no CDB, so it is busy for the idle-only T29 probe.
let busy = self.progress.map(Progress::busy);
let answer = if forensic {
src.get_fmts_indexes(&ctx).map(|k| (k, None))
} else {
@@ -187,6 +192,7 @@ impl Run<'_> {
(r.keys, Some(info))
})
};
drop(busy);
match answer {
Ok((keys, info)) => {
let (matched, entry, store, miss) =
@@ -462,6 +468,19 @@ pub(crate) fn resolve(
sources: &KeySourceFactory,
opts: ResolveKeysOptions,
clock: &dyn Clock,
) -> Result<KeyResolution> {
resolve_observed(disc, reader, scope, sources, opts, clock, None)
}
/// [`resolve`] reporting to the op's `progress` (stop design §2.1, T29).
pub(crate) fn resolve_observed(
disc: &Disc,
reader: &mut dyn SectorSource,
scope: KeyScope,
sources: &KeySourceFactory,
opts: ResolveKeysOptions,
clock: &dyn Clock,
progress: Option<&Progress>,
) -> Result<KeyResolution> {
let halt = opts.halt;
if let Some(h) = halt {
@@ -510,6 +529,7 @@ pub(crate) fn resolve(
let vid_consumer = built.iter().any(|s| s.uses_vid());
let mut run = Run {
halt,
progress,
clock,
format: disc.content_format,
sources: built,
@@ -543,6 +563,11 @@ pub(crate) fn resolve(
{
flag.store(true, std::sync::atomic::Ordering::SeqCst);
}
// The final check (Stop §6 ST-L3): a Stop during the last source call ends `Halted`,
// never a refusal or a set.
if let Some(h) = halt {
h.check()?;
}
let mut inner = result?;
inner.disc_hash = aacs.disc_hash.clone();
inner.capacity = disc.capacity_sectors;
+2
View File
@@ -2318,3 +2318,5 @@ fn a_source_halted_mid_request_keeps_the_asked_step() {
"the request that went out"
);
}
mod stop_tests;
+172
View File
@@ -0,0 +1,172 @@
//! Stop design (v5.6) ST-L3 over KU's `resolve`: the one ctx struct hands every source
//! the op's token and progress (§2.12, :575), and each source call is busy (§2.1 item 2).
use super::*;
use crate::halt::Progress;
// What one spy call saw: `ctx.halt()` is the op token, it was cancelled, and
// `ctx.progress()` was present and busy.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Seen {
halt_is_op: Option<bool>,
cancelled: bool,
busy: Option<bool>,
}
struct Spy {
who: &'static str,
op: Halt,
cancel: bool,
seen: Arc<Mutex<Vec<Seen>>>,
}
impl KeySource for Spy {
fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>> {
if self.cancel {
self.op.cancel();
}
self.seen.lock().unwrap().push(Seen {
halt_is_op: ctx
.halt()
.map(|h| Arc::ptr_eq(h.as_arc(), self.op.as_arc())),
cancelled: ctx.halt().is_some_and(Halt::is_cancelled),
busy: ctx.progress().map(Progress::is_busy),
});
Ok(Vec::new())
}
fn label(&self) -> &'static str {
self.who
}
}
// Two spies; the second cancels the op token inside its call when `cancel_last`.
fn spies(op: &Halt, cancel_last: bool) -> (KeySourceFactory, Arc<Mutex<Vec<Seen>>>) {
let seen: Arc<Mutex<Vec<Seen>>> = Arc::default();
let (op, log) = (op.clone(), seen.clone());
let f: KeySourceFactory = Arc::new(move || {
[("spy-a", false), ("spy-b", cancel_last)]
.into_iter()
.map(|(who, cancel)| {
Box::new(Spy {
who,
op: op.clone(),
cancel,
seen: log.clone(),
}) as Box<dyn KeySource>
})
.collect()
});
(f, seen)
}
/// LSe4 (stop design §5.1, retargeted by §2.12 onto `ResolvedKeySet::resolve`): "a spy
/// sees `ctx.halt()` cancelled". Every source's ctx carries the op token itself, so a
/// Stop reaches a source mid-call; `resolve` then ends `Halted` and builds no set.
#[test]
fn resolve_passes_halt_to_every_source_ctx() {
let fx = two_units();
let op = Halt::new();
let (f, seen) = spies(&op, true);
let opts = ResolveKeysOptions {
halt: Some(&op),
..Default::default()
};
let r = ResolvedKeySet::resolve(
&fx.disc,
&mut fx.source(),
KeyScope::Titles(vec![0]),
&f,
opts,
);
assert!(matches!(r, Err(Error::Halted)), "{:?}", r.err());
let seen = seen.lock().unwrap().clone();
assert_eq!(seen.len(), 2, "both sources were asked: {seen:?}");
assert!(
seen.iter().all(|s| s.halt_is_op == Some(true)),
"every ctx.halt() is the op token: {seen:?}"
);
assert!(
seen[1].cancelled,
"the spy sees ctx.halt() cancelled: {seen:?}"
);
}
/// ST4-2 / §2.1 item 2: "Each source call and each CSS crack in KU's `resolve` holds
/// `busy()` on `ResolveCtx::progress()`" — so the idle-only T29 probe never counts a
/// slow keydb parse as idle. The op's `Progress` is the ctx's, busy inside each call only.
#[test]
fn resolve_holds_busy_around_every_source_call() {
let fx = two_units();
let (op, p) = (Halt::new(), Progress::new());
let (f, seen) = spies(&op, false);
let r = ResolvedKeySet::resolve_with_progress(
&fx.disc,
&mut fx.source(),
KeyScope::Titles(vec![0]),
&f,
ResolveKeysOptions::default(),
&p,
);
assert!(r.is_err(), "no source holds a key: a refusal");
let seen = seen.lock().unwrap().clone();
assert_eq!(seen.len(), 2, "{seen:?}");
assert!(
seen.iter().all(|s| s.busy == Some(true)),
"busy() is held on ctx.progress() across every source call: {seen:?}"
);
assert!(!p.is_busy(), "no busy span outlives resolve");
}
/// Guard: a ctx built with no token or progress hands out `None` for both (defaults).
#[test]
fn resolve_without_stop_hands_out_no_token_or_progress() {
let fx = two_units();
let (f, seen) = spies(&Halt::new(), false);
let r = ResolvedKeySet::resolve(
&fx.disc,
&mut fx.source(),
KeyScope::Titles(vec![0]),
&f,
ResolveKeysOptions::default(),
);
assert!(r.is_err());
let seen = seen.lock().unwrap().clone();
assert!(
seen.iter()
.all(|s| s.halt_is_op.is_none() && s.busy.is_none()),
"{seen:?}"
);
}
/// Review minor 1 (§2.2 alias rule, as `Drive::alias`): under `open_with` the session's
/// op token wins over a different caller token, so a Stop on the session token reaches
/// a key call in flight and the resolve ends `Halted`.
#[test]
fn session_token_wins_over_the_callers_in_resolve() {
let fx = two_units();
let reader = fx.source();
let (session_tok, caller_tok) = (Halt::new(), Halt::new());
let (f, seen) = spies(&session_tok, true);
let mut s = crate::session::DiscSession::from_parts_for_test(
Some(fx.disc),
Some(Box::new(reader)),
None,
);
s.set_halt_for_test(&session_tok);
let opts = ResolveKeysOptions {
halt: Some(&caller_tok),
..Default::default()
};
let r = s.resolve_key_set(KeyScope::Titles(vec![0]), &f, opts);
assert!(matches!(r, Err(Error::Halted)), "{:?}", r.err());
let seen = seen.lock().unwrap().clone();
assert!(
seen.iter().all(|s| s.halt_is_op == Some(true)),
"every source sees the session token: {seen:?}"
);
assert!(
seen[1].cancelled,
"the call in flight sees the Stop: {seen:?}"
);
assert!(!caller_tok.is_cancelled());
}
+35
View File
@@ -155,6 +155,17 @@ pub trait ResolveCtx {
fn unit_key_ro(&self) -> &[u8] {
&[]
}
/// The op's Stop token (stop design §2.7): a source waits on it, never past a Stop.
/// `None` for a ctx built with no token. Defaulted so foreign impls compile unchanged.
fn halt(&self) -> Option<&crate::halt::Halt> {
None
}
/// The op's [`Progress`](crate::halt::Progress) (§2.7, T29): a source bumps it per
/// byte moved and holds [`busy`](crate::halt::Progress::busy) while a call is in
/// flight. `None` when nothing watches the op. Defaulted like [`Self::halt`].
fn progress(&self) -> Option<&crate::halt::Progress> {
None
}
}
/// [`ResolveCtx`] over a scan-time [`DiscInputs`].
@@ -166,6 +177,8 @@ pub trait ResolveCtx {
pub struct DiscInputsCtx<'a> {
inner: &'a DiscInputs,
enc_keys: Vec<[u8; 16]>,
halt: Option<&'a crate::halt::Halt>,
progress: Option<&'a crate::halt::Progress>,
}
impl<'a> DiscInputsCtx<'a> {
@@ -189,6 +202,22 @@ impl<'a> DiscInputsCtx<'a> {
Self {
inner: inputs,
enc_keys,
halt: None,
progress: None,
}
}
/// The ctx the `keys` module hands every source (§2.12): `halt` and `progress`
/// come back from [`ResolveCtx::halt`] and [`ResolveCtx::progress`].
pub(crate) fn with_stop(
self,
halt: Option<&'a crate::halt::Halt>,
progress: Option<&'a crate::halt::Progress>,
) -> Self {
Self {
halt,
progress,
..self
}
}
}
@@ -219,6 +248,12 @@ impl ResolveCtx for DiscInputsCtx<'_> {
fn unit_key_ro(&self) -> &[u8] {
&self.inner.unit_key_ro
}
fn halt(&self) -> Option<&crate::halt::Halt> {
self.halt
}
fn progress(&self) -> Option<&crate::halt::Progress> {
self.progress
}
}
/// The de-conflated result of a source's unit-key resolution: the keys it
-1
View File
@@ -57,7 +57,6 @@ pub struct StreamId {
/// A stream label extracted from disc config files.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct StreamLabel {
/// Which elementary stream this label describes, when its source stated
/// it outright.
+2 -2
View File
@@ -141,8 +141,8 @@ pub use drive::{Drive, DriveStatus, extract_scsi_context, find_drive};
// key material into `ScanOptions` (the library derives no certs; see `KeySpec`).
#[cfg(feature = "rip")]
pub use session::{
DeviceTarget, DiscSession, KeySourceFactory, KeySpec, ResolvedKeys, resolve_keys_for, scan_dir,
scan_iso,
DeviceTarget, DiscSession, Finish, KeySourceFactory, KeySpec, ResolvedKeys, resolve_keys_for,
scan_dir, scan_iso,
};
// ─── Errors ─────────────────────────────────────────────────────────────────
-1
View File
@@ -32,7 +32,6 @@ pub(crate) struct PlaylistMark {
/// Which play item this mark belongs to. Carries the per-PlayItem
/// timebase needed to place a mark in a multi-PlayItem playlist;
/// the chapter builder does not consume it yet.
#[allow(dead_code)]
pub play_item_ref: u16,
/// Timestamp in 45kHz PTS ticks
pub timestamp: u32,
+2
View File
@@ -1063,6 +1063,8 @@ fn drive_mux(
consumer_progress,
)
.map_err(std::io::Error::from)?;
// The op's token, not the wedge token `finish_pumped` may pass (§2.5 `.partial` rule).
pipe.set_op_token(halt);
// ── Frame pump ── Per-frame send deadline is a hard bound (autorip) or
// effectively unbounded (CLI). Either way `send_with_halt` re-checks halt
-1
View File
@@ -772,7 +772,6 @@ fn build_tkhd(track_id: u32, width: u32, height: u32, duration: u64, audio: bool
fullbox(b"tkhd", 1, 0x07, &body)
}
#[allow(clippy::too_many_arguments)]
fn build_mdia(
language: [u8; 2],
timescale: u32,
-1
View File
@@ -18,7 +18,6 @@ mod windows;
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) mod fd_handoff;
#[allow(unused_imports)]
use crate::error::{Error, Result};
use std::path::Path;
+166 -8
View File
@@ -11,6 +11,7 @@ use crate::aacs::trace::ResolutionTrace;
use crate::disc::{Disc, DiscId, DriveCredentials, ScanOptions};
use crate::drive::{Drive, find_drive};
use crate::error::{Error, Result};
use crate::halt::{Halt, Progress};
use crate::keysource::{KeySource, MIN_SAMPLE_UNITS, key_fetch, resolve_and_apply_traced};
use crate::sector::{FileSectorSource, KeyFetch, SectorSource};
use std::path::{Path, PathBuf};
@@ -95,6 +96,18 @@ pub enum DeviceTarget {
Autodetect,
}
/// How [`DiscSession::finish`] ends a session on the handle it already holds (stop
/// design §2.5), instead of re-opening the drive.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Finish {
/// Close the handle; a tray this session locked is unlocked on the way out.
Release,
/// Unlock the tray (PREVENT ALLOW, Prevent 0), then close the handle.
Unlock,
/// Unlock the tray, then eject it (START STOP UNIT, LoEj 1). Allowed after a Stop.
Eject,
}
/// Consumer-supplied key material for the live-drive AACS handshake.
///
/// libfreemkv does NOT read `keydb.cfg`, build a `KeydbSource`, or extract host
@@ -145,6 +158,12 @@ pub struct DiscSession {
/// retained so a later mux (step 4) can install it into the decrypt
/// decorator. `None` until keys are resolved / for a non-AACS disc.
key_fetch: Option<KeyFetch>,
/// The op token from [`Self::open_with`] (stop design §2.2): every CDB, the scan and
/// the key resolution observe it. `None` for [`Self::open`].
halt: Option<Halt>,
/// The op's progress from [`Self::attach_progress`] (T29): the drive's CDBs and each
/// key-source call report to it.
progress: Option<Progress>,
}
// Overlay the session's key material onto `opts` without clobbering what the
@@ -170,7 +189,7 @@ impl DiscSession {
/// [`Self::scan`] is the authoritative gate. No scan, no identify, no key
/// resolution runs here.
pub fn open(target: DeviceTarget, spec: KeySpec) -> Result<DiscSession> {
let mut drive = match target {
let drive = match target {
DeviceTarget::Path(ref path) => Drive::open(path)?,
// Autodetect yields an already-opened drive; a missing drive is a
// typed `DeviceNotFound` the application maps to its own message.
@@ -178,18 +197,62 @@ impl DiscSession {
path: String::new(),
})?,
};
Self::bring_up(drive, spec, None)
}
/// [`Self::open`] under the caller's op token (stop design §2.2): the drive checks
/// `halt` on every CDB, a Stop during the bring-up ends it `Halted` (the handle is
/// closed), and the session keeps the token for [`Self::scan_with`] and
/// [`Self::resolve_key_set`].
pub fn open_with(target: DeviceTarget, spec: KeySpec, halt: &Halt) -> Result<DiscSession> {
let drive = match target {
DeviceTarget::Path(ref path) => Drive::open_with(path, halt)?,
DeviceTarget::Autodetect => {
let mut drive = find_drive().ok_or_else(|| Error::DeviceNotFound {
path: String::new(),
})?;
drive.attach(halt);
drive
}
};
Self::bring_up(drive, spec, Some(halt.clone()))
}
// Test-only: give a `from_parts_for_test` session the op token `open_with` would.
#[cfg(test)]
pub(crate) fn set_halt_for_test(&mut self, halt: &Halt) {
self.halt = Some(halt.clone());
}
// `wait_ready` → `init` → `probe_disc`, advisory. Under an op token (`open_with`) a
// Stop is the one failure that is not advisory.
pub(crate) fn bring_up(
mut drive: Drive,
spec: KeySpec,
halt: Option<Halt>,
) -> Result<DiscSession> {
// Advisory bring-up — non-fatal in every consumer today. Preserve that: log
// and continue, never propagate (CLI discarded these, autorip warned) — the
// advisory semantics are preserved identically; only the sink moved here.
if let Err(e) = drive.wait_ready() {
tracing::warn!(target: "freemkv::session", error = %e, "wait_ready advisory failed (continuing)");
type Step = fn(&mut Drive) -> Result<()>;
let steps: [(&str, Step); 3] = [
("wait_ready", Drive::wait_ready),
("init", Drive::init),
("probe_disc", Drive::probe_disc),
];
for (step, run) in steps {
match run(&mut drive) {
// Stop is always honoured: dropping `drive` closes the handle.
Err(Error::Halted) if halt.is_some() => return Err(Error::Halted),
Err(e) => {
tracing::warn!(target: "freemkv::session", step, error = %e, "advisory bring-up step failed (continuing)")
}
if let Err(e) = drive.init() {
tracing::warn!(target: "freemkv::session", error = %e, "init advisory failed (continuing)");
Ok(()) => {}
}
if let Err(e) = drive.probe_disc() {
tracing::warn!(target: "freemkv::session", error = %e, "probe_disc advisory failed (continuing)");
}
// The final check (§6 ST-L3): a Stop after the last CDB still ends the open.
if let Some(h) = &halt {
h.check()?;
}
let device = drive.device_path().to_string();
@@ -200,9 +263,25 @@ impl DiscSession {
disc: None,
reader: None,
key_fetch: None,
halt,
progress: None,
})
}
/// The op token from [`Self::open_with`]; `None` for [`Self::open`].
pub fn token(&self) -> Option<&Halt> {
self.halt.as_ref()
}
/// Report the op's forward progress to `p` (T29): every drive CDB (as
/// [`Drive::attach_progress`]) and every key-source call in [`Self::resolve_key_set`].
pub fn attach_progress(&mut self, p: &Progress) {
if let Some(drive) = self.drive.as_mut() {
drive.attach_progress(p);
}
self.progress = Some(p.clone());
}
/// Fast disc identification — name/format only, no playlist parse. Wraps
/// [`Disc::identify`].
pub fn identify(&mut self) -> Result<DiscId> {
@@ -231,6 +310,53 @@ impl DiscSession {
Ok(self.disc.as_ref().expect("disc just stored"))
}
/// [`Self::scan`] under the session's op token (stop design §2.2, §6 ST-L3): the
/// `ScanOptions.halt` alias rule applies (a token attached by [`Self::open_with`]
/// wins), and a Stop after the scan's last CDB still ends it `Halted`, storing no
/// [`Disc`].
pub fn scan_with(&mut self, opts: ScanOptions) -> Result<&Disc> {
let opts = forward_key_material(&mut self.spec, opts);
let drive = self.drive.as_mut().ok_or_else(|| Error::DeviceNotReady {
path: self.device.clone(),
})?;
// `Disc::scan` applies the alias rule and checks the drive's token last (LS6).
let disc = Disc::scan(drive, &opts)?;
// The session's own final check: a Stop on the op token stores no `Disc`.
if let Some(h) = &self.halt {
h.check()?;
}
self.disc = Some(disc);
Ok(self.disc.as_ref().expect("disc just stored"))
}
/// End the session on the handle it holds (stop design §2.5), never re-opening the
/// drive: see [`Finish`]. The handle is closed on return. [`Finish::Eject`] runs even
/// after a Stop: the tray's ALLOW (if this session locked it), then the eject.
///
/// # Errors
///
/// [`Error::DeviceNotReady`] for `Unlock`/`Eject` when the drive has left the session;
/// the eject's own error.
pub fn finish(mut self, how: Finish) -> Result<()> {
let Some(mut drive) = self.drive.take() else {
return match how {
Finish::Release => Ok(()),
Finish::Unlock | Finish::Eject => Err(Error::DeviceNotReady {
path: self.device.clone(),
}),
};
};
match how {
// `Drive::drop` sends the ALLOW for a tray this Drive locked.
Finish::Release => Ok(()),
Finish::Unlock => {
drive.unlock_tray();
Ok(())
}
Finish::Eject => drive.finish_eject(),
}
}
/// Resolve and bank the scanned disc's base AACS unit keys from the
/// consumer-supplied `sources`, and retain the read-time [`KeyFetch`] on the
/// session (see [`Self::key_fetch`]) for a later mux.
@@ -276,6 +402,26 @@ impl DiscSession {
sources: &KeySourceFactory,
opts: crate::keys::ResolveKeysOptions,
) -> Result<crate::keys::KeyResolution> {
// Under `open_with` the session's op token governs the resolve too (§2.12).
let (op, progress) = (self.halt.clone(), self.progress.clone());
if let Some(h) = &op {
h.check()?;
}
let mut opts = opts;
if let Some(h) = &op {
// As `Drive::alias` (§2.2): the session's op token wins over the caller's.
if opts
.halt
.is_some_and(|c| !Arc::ptr_eq(c.as_arc(), h.as_arc()))
{
tracing::warn!(
target: "freemkv::session",
phase = "halt_alias",
"ResolveKeysOptions.halt differs from the session's op token; the session token wins"
);
}
opts.halt = Some(h);
}
let Some(disc) = self.disc.as_ref() else {
return Err(Error::DeviceNotReady {
path: self.device.clone(),
@@ -290,7 +436,12 @@ impl DiscSession {
});
}
};
crate::keys::ResolvedKeySet::resolve(disc, reader, scope, sources, opts)
match &progress {
Some(p) => crate::keys::ResolvedKeySet::resolve_with_progress(
disc, reader, scope, sources, opts, p,
),
None => crate::keys::ResolvedKeySet::resolve(disc, reader, scope, sources, opts),
}
}
/// The read-time AACS fetch closure retained by [`Self::resolve_keys`], for a
@@ -389,6 +540,8 @@ impl DiscSession {
disc,
reader,
key_fetch,
halt: None,
progress: None,
}
}
@@ -405,6 +558,8 @@ impl DiscSession {
disc: None,
reader: None,
key_fetch: None,
halt: None,
progress: None,
}
}
}
@@ -522,6 +677,9 @@ fn probe_folder_encryption(reader: &mut dyn SectorSource, disc: &Disc) -> Result
Ok(false)
}
#[cfg(test)]
mod stop_tests;
#[cfg(test)]
mod tests {
use super::*;
+189
View File
@@ -0,0 +1,189 @@
//! Stop design v5.6 §5.1 "Session (ST-L3)": `DiscSession::{open_with, finish}` over a
//! [`FakeTransport`](crate::test_util::FakeTransport). `open_with` itself opens a device
//! path, so these drive its shared bring-up over a fake Drive under the op token.
use super::*;
use crate::test_util::{FakeHandle, FakeTransport};
use std::time::Duration;
fn is_allow(c: &[u8]) -> bool {
c[0] == crate::drive::allow::PREVENT_ALLOW && c[4] & 1 == 0
}
fn is_prevent(c: &[u8]) -> bool {
c[0] == crate::drive::allow::PREVENT_ALLOW && c[4] & 1 == 1
}
// SS-6 MMC-6 Table 633: LoEj 1, Start 0 = "Eject the disc if permitted".
fn is_eject(c: &[u8]) -> bool {
c[0] == crate::drive::allow::START_STOP_UNIT && c[4] & 0x03 == 0x02
}
// The `n`th (1-based) CDB.
fn nth(n: usize) -> impl Fn(&[u8]) -> bool + Send + 'static {
let seen = std::sync::atomic::AtomicUsize::new(0);
move |_| seen.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1 == n
}
// A session brought up over `t` under `halt`, as `open_with` does after its open.
fn session_over(t: FakeTransport, halt: &Halt) -> Result<DiscSession> {
let drive = Drive::from_transport_with(Box::new(t), halt);
DiscSession::bring_up(drive, KeySpec::default(), Some(halt.clone()))
}
// The CDBs the fake received after the watched token was cancelled.
fn after_cancel(fake: &FakeHandle) -> Vec<Vec<u8>> {
fake.log()
.into_iter()
.filter(|c| c.after_cancel)
.map(|c| c.cdb)
.collect()
}
/// LSe1 (§2.4 row 2, SS-6): `finish(Eject)` after a Stop sends "ALLOW (if locked), then
/// START STOP LoEj; no other CDB". SS-6 MMC-6 Table 633: "1 0 Eject the disc if
/// permitted." — so the ALLOW comes first.
#[test]
fn finish_eject_after_cancel_allowed() {
for locked in [true, false] {
let h = Halt::new();
let (t, fake) = FakeTransport::new();
let t = t.watch(&h).allow_after_cancel(is_eject);
let mut s = session_over(t, &h).expect("brought up");
if locked {
s.lock_tray();
assert!(fake.tray_locked());
}
h.cancel();
s.finish(Finish::Eject)
.expect("an eject after a Stop is allowed");
let after = after_cancel(&fake);
let want: &[fn(&[u8]) -> bool] = if locked {
&[is_allow, is_eject]
} else {
&[is_eject]
};
assert_eq!(after.len(), want.len(), "locked={locked}: {after:02x?}");
assert!(
after.iter().zip(want).all(|(c, p)| p(c)),
"locked={locked}: {after:02x?}"
);
assert_eq!(fake.live_handles(), 0, "the handle is closed");
}
}
/// LSe2: `finish(Release)` and `finish(Unlock)` close the handle and leave the tray
/// unlocked (open count 0). SS-5 MMC-6 Table 329: "0 0 Prevent State shall be cleared
/// (Unlocked)" — `Unlock` sends it even for a lock this session did not take.
#[test]
fn finish_release_and_unlock() {
let h = Halt::new();
let (t, fake) = FakeTransport::new();
let mut s = session_over(t, &h).expect("brought up");
s.lock_tray();
s.finish(Finish::Release).expect("release");
assert_eq!(fake.live_handles(), 0, "Release closes the handle");
assert!(
!fake.tray_locked(),
"a tray this session locked is unlocked"
);
let (t, fake) = FakeTransport::new();
let s = session_over(t, &Halt::new()).expect("brought up");
let allows = fake.count(is_allow);
s.finish(Finish::Unlock).expect("unlock");
assert_eq!(fake.count(is_allow), allows + 1, "Unlock sends one ALLOW");
assert_eq!(fake.count(is_prevent), 0);
assert_eq!(fake.live_handles(), 0, "Unlock closes the handle");
}
/// LSe2 (the no-drive case): `Unlock`/`Eject` on a session whose drive has left it is a
/// typed error, `Release` is a no-op.
#[test]
fn finish_without_a_drive() {
let s = DiscSession::from_parts_for_test(None, None, None);
assert!(s.finish(Finish::Release).is_ok());
for how in [Finish::Unlock, Finish::Eject] {
let s = DiscSession::from_parts_for_test(None, None, None);
assert!(
matches!(s.finish(how), Err(Error::DeviceNotReady { .. })),
"{how:?}"
);
}
}
/// LSe3: after a stopped op drops — its Drive-holding prefetcher joined (§2.5) — no
/// handle and no Drive holder is left, and a new `open_with` session comes up under
/// its own token.
#[test]
fn open_with_succeeds_after_stopped_op_drops() {
let _serial = crate::sector::prefetched::holder_test_lock();
let h1 = Halt::new();
let (t, fake1) = FakeTransport::new();
let t = t.with_image(vec![0u8; 2048 * 300]);
let drive = session_over(t, &h1)
.expect("brought up")
.into_drive()
.expect("drive");
let extents = vec![crate::disc::Extent {
start_lba: 0,
sector_count: 300,
}];
let pf = crate::sector::PrefetchedSectorSource::new(drive, extents, 3, Some(h1.clone()))
.expect("spawn");
std::thread::sleep(Duration::from_millis(20));
h1.cancel();
drop(pf);
assert_eq!(fake1.live_handles(), 0, "the stopped op's handle is closed");
assert_eq!(fake1.drive_holders(), 0, "the prefetcher was joined");
let h2 = Halt::new();
let (t, fake2) = FakeTransport::new();
let s = session_over(t.watch(&h2), &h2).expect("a new open succeeds");
let token = s.token().expect("open_with keeps its token");
assert!(Arc::ptr_eq(token.as_arc(), h2.as_arc()));
assert!(!token.is_cancelled());
drop(s);
assert_eq!(fake2.live_handles(), 0);
}
/// Stop is always honoured: a Stop during `open_with`'s advisory bring-up is the one
/// failure that is not advisory. The session is not built and the handle is closed.
#[test]
fn open_with_stop_during_bring_up_is_halted() {
let h = Halt::new();
let (t, fake) = FakeTransport::new();
let t = t.cancel_on(nth(1), &h).watch(&h);
let r = session_over(t, &h);
assert!(matches!(r, Err(Error::Halted)), "{:?}", r.err());
assert_eq!(
fake.log().len(),
1,
"no CDB after the Stop: {:02x?}",
fake.cdbs()
);
assert_eq!(fake.live_handles(), 0);
}
/// The final `check()` (§6 ST-L3): a Stop after the bring-up's last CDB still ends
/// `open_with` `Halted`.
#[test]
fn open_with_final_check() {
let (t, dry) = FakeTransport::new();
session_over(t, &Halt::new()).expect("dry run");
let last = dry.log().len();
let h = Halt::new();
let (t, fake) = FakeTransport::new();
let r = session_over(t.cancel_on(nth(last), &h), &h);
assert!(matches!(r, Err(Error::Halted)), "{:?}", r.err());
assert_eq!(fake.log().len(), last);
}
/// Guard: `open` (no op token) keeps today's advisory bring-up and has no token.
#[test]
fn open_without_a_token_stays_advisory() {
let (t, _fake) = FakeTransport::new();
let drive = Drive::from_transport(Box::new(t));
let s = DiscSession::bring_up(drive, KeySpec::default(), None).expect("advisory");
assert!(s.token().is_none());
}
+52
View File
@@ -112,6 +112,55 @@ pub const SS_7_AGID_INVALIDATE: SpecQuote = SpecQuote {
>> 6;",
};
const FLOCK_URL: &str = "https://man7.org/linux/man-pages/man2/flock.2.html";
pub const SS_8_FLOCK_NFS: SpecQuote = SpecQuote {
id: "SS-8",
kind: QuoteKind::Normative,
source: "Linux man-pages, flock(2)",
section: "DESCRIPTION; ERRORS; HISTORY, NFS details",
locator: "man7.org flock(2)",
url: FLOCK_URL,
text: "Only one process may hold an exclusive lock for a given file at a given time. … To \
make a nonblocking request, include LOCK_NB (by ORing) with any of the above \
operations. … Locks created by flock() are associated with an open file \
description (see open(2)). This means that duplicate file descriptors (created by, \
for example, fork(2) or dup(2)) refer to the same lock … If a process uses open(2) \
(or similar) to obtain more than one file descriptor for the same file, these file \
descriptors are treated independently by flock(). … EWOULDBLOCK The file is locked \
and the LOCK_NB flag was selected. … NFS clients support flock() locks by emulating \
them as fcntl(2) byte-range locks on the entire file. … in order to place an \
exclusive lock, the file must be opened for writing.",
};
pub const SS_9_FILE_IDENTITY: SpecQuote = SpecQuote {
id: "SS-9",
kind: QuoteKind::Normative,
source: "The Open Group Base Specifications Issue 8 (IEEE Std 1003.1-2024), XBD",
section: "<sys/stat.h> DESCRIPTION",
locator: "pubs.opengroup.org basedefs/sys_stat.h.html",
url: "https://pubs.opengroup.org/onlinepubs/9799919799/basedefs/sys_stat.h.html",
text: "A file identity is uniquely determined by the combination of st_dev and st_ino. At \
any given time in a system, distinct files shall have distinct file identities",
};
pub const SS_10_WINDOWS_LOCK_AND_ID: SpecQuote = SpecQuote {
id: "SS-10",
kind: QuoteKind::Normative,
source: "Microsoft Learn, Win32 API (fileapi.h): LockFileEx, CreateFileW, \
BY_HANDLE_FILE_INFORMATION",
section: "LockFileEx dwFlags; CreateFileW dwShareMode; BY_HANDLE_FILE_INFORMATION Members",
locator: "learn.microsoft.com nf-fileapi-lockfileex, nf-fileapi-createfilew, \
ns-fileapi-by_handle_file_information",
url: "https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-lockfileex",
text: "LOCKFILE_EXCLUSIVE_LOCK 0x00000002 The function requests an exclusive lock. … \
LOCKFILE_FAIL_IMMEDIATELY 0x00000001 The function returns immediately if it is \
unable to acquire the requested lock. … FILE_SHARE_DELETE 0x00000004 Enables \
subsequent open operations on a file or device to request delete access. … The \
identifier (low and high parts) and the volume serial number uniquely identify a \
file on a single computer.",
};
const RUST_STD: &str = "The Rust Standard Library, core::sync::atomic (Rust 1.98.1)";
const ORDERING_URL: &str = "https://doc.rust-lang.org/std/sync/atomic/enum.Ordering.html";
@@ -192,6 +241,9 @@ pub const ALL: &[&SpecQuote] = &[
&SS_5_PREVENT_ALLOW,
&SS_6_START_STOP_LOEJ,
&SS_7_AGID_INVALIDATE,
&SS_8_FLOCK_NFS,
&SS_9_FILE_IDENTITY,
&SS_10_WINDOWS_LOCK_AND_ID,
&SS_12_FSYNC_FDATASYNC,
&SS_13_SYNC_FILE_RANGE,
&SS_14_F_FULLFSYNC,
+692
View File
@@ -0,0 +1,692 @@
//! Stop lints (stop design §5.8, ST-X2): grep tests over the production source.
//!
//! "These replace v4's Sim-motivated `disallowed-methods` list": clippy's list is
//! crate-wide, and these rules are scoped by directory and exempt tests. The walk
//! follows `mod` declarations from `src/lib.rs`, drops every item or file gated on a
//! test-only `cfg` (`test`, `feature = "test-util"`), and masks comments and literals,
//! so only code that ships is checked. Every allow-list entry names its site and must
//! still match, so an entry cannot outlive its reason.
use std::collections::BTreeMap;
use std::path::Path;
struct Src {
path: String,
raw: String,
code: String,
fns: Vec<(usize, usize, String)>,
}
impl Src {
fn line(&self, at: usize) -> usize {
self.code[..at].matches('\n').count() + 1
}
// The innermost `fn` whose body contains `at`.
fn enclosing_fn(&self, at: usize) -> &str {
self.fns
.iter()
.filter(|(s, e, _)| *s <= at && at < *e)
.min_by_key(|(s, e, _)| e - s)
.map_or("", |(_, _, n)| n.as_str())
}
fn hits(&self, pat: &str) -> Vec<usize> {
self.code.match_indices(pat).map(|(i, _)| i).collect()
}
}
fn is_ident(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
fn blank(out: &mut [u8], from: usize, to: usize) {
for b in &mut out[from..to] {
if *b != b'\n' {
*b = b' ';
}
}
}
// Comments and string/char literal bodies become spaces; offsets and newlines are kept.
fn mask(src: &str) -> Vec<u8> {
let s = src.as_bytes();
let mut out = s.to_vec();
let mut i = 0;
while i < s.len() {
let prev_ident = i > 0 && is_ident(s[i - 1]);
if s[i..].starts_with(b"//") {
let end = s[i..]
.iter()
.position(|&b| b == b'\n')
.map_or(s.len(), |p| i + p);
blank(&mut out, i, end);
i = end;
} else if s[i..].starts_with(b"/*") {
let (mut depth, mut j) = (0, i);
while j < s.len() {
if s[j..].starts_with(b"/*") {
depth += 1;
j += 2;
} else if s[j..].starts_with(b"*/") {
depth -= 1;
j += 2;
if depth == 0 {
break;
}
} else {
j += 1;
}
}
blank(&mut out, i, j);
i = j;
} else if s[i] == b'r' && !prev_ident || s[i..].starts_with(b"br") && !prev_ident {
let start = if s[i] == b'b' { i + 2 } else { i + 1 };
let hashes = s[start..].iter().take_while(|&&b| b == b'#').count();
if s.get(start + hashes) != Some(&b'"') {
i += 1;
continue;
}
let body = start + hashes + 1;
let close: Vec<u8> = std::iter::once(b'"')
.chain(std::iter::repeat_n(b'#', hashes))
.collect();
let end = body
+ s[body..]
.windows(close.len())
.position(|w| w == close.as_slice())
.expect("unterminated raw string");
blank(&mut out, body, end);
i = end + close.len();
} else if s[i] == b'"' {
let mut j = i + 1;
while s[j] != b'"' {
j += if s[j] == b'\\' { 2 } else { 1 };
}
blank(&mut out, i + 1, j);
i = j + 1;
} else if s[i] == b'\'' {
let end = if s.get(i + 1) == Some(&b'\\') {
Some(i + 2 + s[i + 2..].iter().position(|&b| b == b'\'').unwrap())
} else {
let c = src[i + 1..].chars().next().map_or(1, char::len_utf8);
(s.get(i + 1 + c) == Some(&b'\'')).then_some(i + 1 + c)
};
match end {
Some(e) => {
blank(&mut out, i + 1, e);
i = e + 1;
}
None => i += 1, // a lifetime or label
}
} else {
i += 1;
}
}
out
}
// The index just past the `]` or `}` that closes the `[` or `{` at `open`.
fn close_of(code: &[u8], open: usize) -> usize {
let (o, c) = match code[open] {
b'[' => (b'[', b']'),
b'{' => (b'{', b'}'),
b => panic!("close_of on `{}`", b as char),
};
let mut depth = 0i32;
for (k, &b) in code[open..].iter().enumerate() {
if b == o {
depth += 1;
} else if b == c {
depth -= 1;
if depth == 0 {
return open + k + 1;
}
}
}
panic!("unbalanced `{}`", o as char);
}
// The end of the item starting at `from`: its `{…}` body, or the first top-level `;` or
// `,` (a field, variant or arm); an enclosing block's `}` also ends it.
fn item_end(code: &[u8], from: usize) -> usize {
let (mut depth, mut angle) = (0i32, 0i32);
for k in from..code.len() {
match code[k] {
b'(' | b'[' => depth += 1,
b')' | b']' => depth -= 1,
b'<' => angle += 1,
b'>' if k > 0 && !matches!(code[k - 1], b'-' | b'=') => angle -= 1,
b'}' if depth == 0 => return k,
b';' if depth == 0 => return k + 1,
b',' if depth == 0 && angle <= 0 => return k + 1,
b'{' if depth == 0 => {
let end = close_of(code, k);
return end + usize::from(code.get(end) == Some(&b';'));
}
_ => {}
}
}
code.len()
}
// Whether a `cfg` predicate holds only in test builds (`test`, the `test-util` fixtures).
fn test_only(pred: &str) -> bool {
let pred = pred.trim();
if pred == "test" || pred.replace(' ', "") == "feature=\"test-util\"" {
return true;
}
let Some((head, rest)) = pred.split_once('(') else {
return false;
};
let inner = rest.strip_suffix(')').unwrap_or(rest);
let mut args = Vec::new();
let (mut depth, mut start) = (0, 0);
for (k, c) in inner.char_indices() {
match c {
'(' => depth += 1,
')' => depth -= 1,
',' if depth == 0 => {
args.push(&inner[start..k]);
start = k + 1;
}
_ => {}
}
}
args.push(&inner[start..]);
args.retain(|a| !a.trim().is_empty());
match head.trim() {
"all" => args.iter().any(|a| test_only(a)),
"any" => !args.is_empty() && args.iter().all(|a| test_only(a)),
_ => false,
}
}
// The attributes `#[..]` / `#![..]` in `code`: (start, end, raw text inside the brackets).
fn attrs<'a>(code: &[u8], raw: &'a str) -> Vec<(usize, usize, &'a str)> {
let mut out = Vec::new();
for (i, _) in raw.match_indices('#') {
if code[i] != b'#' {
continue; // inside a masked comment or literal
}
let open = match (code.get(i + 1), code.get(i + 2)) {
(Some(b'['), _) => i + 1,
(Some(b'!'), Some(b'[')) => i + 2,
_ => continue,
};
let end = close_of(code, open);
out.push((i, end, &raw[open + 1..end - 1]));
}
out
}
fn cfg_pred(attr: &str) -> Option<&str> {
let a = attr
.trim()
.strip_prefix("cfg")?
.trim_start()
.strip_prefix('(')?;
a.strip_suffix(')')
}
// Blank every test-only item; `None` when the whole file is test-only (`#![cfg(test)]`).
fn drop_test_items(raw: &str, code: &mut [u8]) -> Option<()> {
for (start, end, attr) in attrs(code, raw) {
if code[start] != b'#' || !cfg_pred(attr).is_some_and(test_only) {
continue;
}
if code[start + 1] == b'!' {
return None;
}
let stop = item_end(code, end);
blank(code, start, stop);
}
Some(())
}
fn fn_spans(code: &[u8]) -> Vec<(usize, usize, String)> {
let text = std::str::from_utf8(code).unwrap();
let mut out = Vec::new();
for (i, _) in text.match_indices("fn ") {
if i > 0 && is_ident(code[i - 1]) {
continue;
}
let name: String = text[i + 3..]
.trim_start()
.chars()
.take_while(|&c| c.is_alphanumeric() || c == '_')
.collect();
if name.is_empty() {
continue;
}
let mut depth = 0i32;
for k in i..code.len() {
match code[k] {
b'(' | b'[' => depth += 1,
b')' | b']' => depth -= 1,
b';' if depth == 0 => break,
b'{' if depth == 0 => {
out.push((i, close_of(code, k), name));
break;
}
_ => {}
}
}
}
out
}
// Every production source file, reached from `src/lib.rs` through non-test `mod` items.
fn production() -> Vec<Src> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let mut out = Vec::new();
let mut todo = vec![root.join("src/lib.rs")];
while let Some(file) = todo.pop() {
let raw = std::fs::read_to_string(&file).unwrap_or_else(|e| panic!("{file:?}: {e}"));
let mut code = mask(&raw);
if drop_test_items(&raw, &mut code).is_none() {
continue;
}
let text = std::str::from_utf8(&code).unwrap();
let name = file.file_name().unwrap().to_str().unwrap();
let dir = if matches!(name, "lib.rs" | "mod.rs") {
file.parent().unwrap().to_path_buf()
} else {
file.with_extension("")
};
let attrs = attrs(&code, &raw);
for (i, _) in text.match_indices("mod ") {
if i > 0 && is_ident(code[i - 1]) {
continue;
}
let rest = &text[i + 4..];
let m: String = rest.chars().take_while(|&c| is_ident(c as u8)).collect();
let after = rest[m.len()..].trim_start();
if m.is_empty() || !after.starts_with(';') {
continue;
}
// `#[path]` in the attribute run just above this declaration.
let mut head = text[..i].trim_end();
head = head.strip_suffix("pub(crate)").unwrap_or(head);
head = head.strip_suffix("pub").unwrap_or(head).trim_end();
let mut path = None;
while let Some((s, _, a)) = attrs.iter().find(|(_, e, _)| *e == head.len()) {
if let Some(p) = a.trim().strip_prefix("path") {
path = p.split('"').nth(1);
}
head = text[..*s].trim_end();
}
let next = match path {
Some(p) => file.parent().unwrap().join(p),
None if dir.join(format!("{m}.rs")).exists() => dir.join(format!("{m}.rs")),
None => dir.join(&m).join("mod.rs"),
};
todo.push(next);
}
let rel = file
.strip_prefix(root)
.unwrap()
.to_string_lossy()
.replace('\\', "/");
let code = String::from_utf8(code).unwrap();
let fns = fn_spans(code.as_bytes());
out.push(Src {
path: rel,
raw,
code,
fns,
});
}
out.sort_by(|a, b| a.path.cmp(&b.path));
out
}
// An allow-listed site: (file, enclosing fn, why).
type Allow = (&'static str, &'static str, &'static str);
// Hits not on `allow`, plus allow entries that matched nothing (stale).
fn check(rule: &str, hits: Vec<(&Src, usize)>, allow: &[Allow]) {
let mut used = vec![false; allow.len()];
let mut bad = Vec::new();
for (src, at) in hits {
let f = src.enclosing_fn(at);
match allow.iter().position(|(p, n, _)| *p == src.path && *n == f) {
Some(k) => used[k] = true,
None => bad.push(format!("{}:{} (fn {f})", src.path, src.line(at))),
}
}
for (k, (p, n, _)) in allow.iter().enumerate() {
if !used[k] {
bad.push(format!("stale allow-list entry {p} fn {n}"));
}
}
assert!(bad.is_empty(), "{rule}:\n {}", bad.join("\n "));
}
fn in_dirs(src: &Src, dirs: &[&str]) -> bool {
dirs.iter().any(|d| src.path.starts_with(d))
}
fn not_halt(src: &Src) -> bool {
src.path != "src/halt.rs" && !src.path.starts_with("src/halt/")
}
/// §5.8 "Ban `std::thread::sleep` on op paths: libfreemkv `src/{drive,disc,sector,io,mux}`
/// … Use `Halt::wait` / `pause` instead." Also catches `use std::thread::{sleep, ..}`.
#[test]
fn no_thread_sleep_on_op_paths() {
const OP_DIRS: &[&str] = &[
"src/drive/",
"src/disc/",
"src/sector/",
"src/io/",
"src/mux/",
];
const ALLOW: &[Allow] = &[
(
"src/disc/extract.rs",
"probe_case_insensitive",
"20 ms filesystem remove retry; no drive wait, no token in reach",
),
(
"src/mux/network.rs",
"accept_from_with_halt",
"halt-checked accept poll; open item: convert to Halt::wait",
),
];
let srcs = production();
let mut hits = Vec::new();
for s in srcs.iter().filter(|s| in_dirs(s, OP_DIRS)) {
hits.extend(s.hits("thread::sleep").into_iter().map(|i| (s, i)));
for i in s.hits("thread::{") {
let group = &s.code[i..close_of(s.code.as_bytes(), i + 8)];
if group
.split(|c: char| !is_ident(c as u8))
.any(|w| w == "sleep")
{
hits.push((s, i));
}
}
}
check("thread::sleep on an op path", hits, ALLOW);
}
/// §2.2: "**Every** raw `self.scsi.as_mut().execute` in `drive/mod.rs` and `identity.rs`
/// goes through [`Drive::exec`]"; §5.8 widens the grep to all of `drive/`.
#[test]
fn no_raw_execute_in_drive_or_identity() {
const ALLOW: &[Allow] = &[
(
"src/drive/mod.rs",
"dispatch",
"the one transport call behind exec / exec_cleanup / exec_uncancellable",
),
(
"src/identity.rs",
"from_drive",
"public API over a bare transport; Drive::open uses identify(exec)",
),
];
let srcs = production();
let mut hits = Vec::new();
let mut scanned = 0;
for s in srcs
.iter()
.filter(|s| s.path.starts_with("src/drive/") || s.path == "src/identity.rs")
{
scanned += 1;
for pat in [".execute(", ".execute_cleanup("] {
hits.extend(s.hits(pat).into_iter().map(|i| (s, i)));
}
}
assert!(scanned >= 5, "the walk lost drive/ files: {scanned}");
check("raw transport execute outside Drive::exec", hits, ALLOW);
}
/// §5.8 "A raw-halt-load grep test. Its allow-list: the `from_arc` bridges and the patch
/// latch." A `Halt`'s flag is read via `check` / `is_cancelled`, never as a raw atomic.
/// The patch latch is the engine's (§4.2 `EngineHalt`); libfreemkv has none.
#[test]
fn no_raw_halt_loads_in_production() {
const ALLOW: &[Allow] = &[
(
"src/drive/mod.rs",
"halt_flag",
"from_arc bridge: the token as the pub Arc<AtomicBool> view",
),
(
"src/scsi/macos.rs",
"cancel_byte",
"from_arc bridge: the token as the macOS shim's cancel byte",
),
];
const ATOMIC_OPS: &[&str] = &[
".load(",
".store(",
".swap(",
".fetch_or(",
".fetch_and(",
".fetch_xor(",
".fetch_nand(",
".compare_exchange(",
".compare_exchange_weak(",
".get_mut(",
".into_inner(",
".as_ptr(",
];
let srcs = production();
let mut hits = Vec::new();
for s in srcs.iter().filter(|s| not_halt(s)) {
for pat in [".as_arc()", "from_arc(", ".halt_flag()"] {
for i in s.hits(pat) {
let line = s.code[..i].rfind('\n').map_or(0, |n| n + 1);
let eol = s.code[i..].find('\n').map_or(s.code.len(), |n| i + n);
// `Arc::ptr_eq` compares the two tokens' identity; it reads no flag.
if !s.code[line..eol].contains("Arc::ptr_eq(") {
hits.push((s, i));
}
}
}
for pat in ATOMIC_OPS {
for i in s.hits(pat) {
let recv = s.code[..i]
.rsplit(|c: char| c.is_whitespace() || "(&*!,{;=".contains(c))
.next()
.unwrap_or("")
.to_ascii_lowercase();
if recv.contains("halt") || recv.contains("cancel") {
hits.push((s, i));
}
}
}
}
check("raw halt flag access outside halt.rs", hits, ALLOW);
}
/// §5.8 "A `JoinHandle::is_finished` spin ban (use `join_within`)."
#[test]
fn no_is_finished_spins_outside_halt() {
let srcs = production();
let mut hits = Vec::new();
for s in srcs.iter().filter(|s| not_halt(s)) {
hits.extend(s.hits("is_finished").into_iter().map(|i| (s, i)));
}
check("is_finished (use halt::join_within)", hits, &[]);
}
// The `#[allow]` / `#[expect]` sites present when ST-X2 landed, per (file, attribute):
// each is a §5.8 violation awaiting a per-site fix. The list only shrinks.
const ALLOW_BASELINE: &[(&str, &str, usize)] = &[
("src/aacs/provider.rs", r#"allow(dead_code)"#, 1),
(
"src/disc/extract.rs",
r#"allow(clippy::too_many_arguments)"#,
1,
),
(
"src/disc/extract.rs",
r#"allow(clippy::unnecessary_cast,clippy::useless_conversion)"#,
1,
),
("src/drive/linux.rs", r#"allow(dead_code)"#, 1),
("src/drive/mod.rs", r#"allow(dead_code)"#, 1),
(
"src/drive/mod.rs",
r#"cfg_attr(not(target_os="linux"),allow(unused_variables))"#,
1,
),
("src/io/sink/mod.rs", r#"allow(dead_code)"#, 1),
(
"src/keys/arrival.rs",
r#"allow(clippy::too_many_arguments)"#,
1,
),
("src/labels/class_reader.rs", r#"allow(dead_code)"#, 2),
(
"src/labels/ctrm.rs",
r#"allow(clippy::items_after_test_module)"#,
1,
),
("src/mpls.rs", r#"allow(dead_code)"#, 2),
("src/mux/demux_sink.rs", r#"allow(dead_code)"#, 2),
("src/mux/demux_thread.rs", r#"allow(dead_code)"#, 1),
("src/mux/fmp4/mod.rs", r#"allow(dead_code)"#, 1),
("src/mux/hevc/mod.rs", r#"allow(dead_code)"#, 1),
("src/mux/m2ts_mux/mod.rs", r#"allow(dead_code)"#, 1),
("src/mux/mkv.rs", r#"allow(clippy::too_many_arguments)"#, 3),
("src/mux/mod.rs", r#"allow(dead_code)"#, 6),
("src/mux/pipelined_stream.rs", r#"allow(dead_code)"#, 1),
(
"src/mux/resolve.rs",
r#"allow(clippy::too_many_arguments)"#,
2,
),
(
"src/platform/fs_type/linux.rs",
r#"allow(clippy::unnecessary_cast)"#,
3,
),
("src/platform/fs_type/mod.rs", r#"allow(dead_code)"#, 1),
("src/scsi/linux.rs", r#"allow(non_camel_case_types)"#, 1),
("src/scsi/mod.rs", r#"allow(dead_code)"#, 1),
(
"src/scsi/mod.rs",
r#"cfg_attr(not(any(feature="rip",target_os="windows")),allow(dead_code))"#,
1,
),
(
"src/scsi/mod.rs",
r#"cfg_attr(not(any(target_os="linux",target_os="windows")),allow(dead_code))"#,
1,
),
(
"src/scsi/mod.rs",
r#"cfg_attr(not(feature="rip"),allow(dead_code))"#,
1,
),
(
"src/scsi/mod.rs",
r#"cfg_attr(not(target_os="linux"),allow(dead_code))"#,
5,
),
(
"src/scsi/mod.rs",
r#"cfg_attr(not(target_os="windows"),allow(dead_code))"#,
2,
),
(
"src/scsi/mod.rs",
r#"cfg_attr(target_os="macos",allow(dead_code))"#,
1,
),
("src/scsi/windows.rs", r#"allow(non_snake_case)"#, 3),
("src/udf.rs", r#"allow(clippy::only_used_in_recursion)"#, 1),
("src/udf.rs", r#"allow(clippy::too_many_arguments)"#, 1),
];
/// §5.8 "`#[allow]` only in `halt.rs` and tests." `#[expect]` and `cfg_attr(.., allow)`
/// suppress the same way, so they count too.
#[test]
fn allow_attributes_only_in_halt_and_tests() {
let srcs = production();
let mut found: BTreeMap<(String, String), usize> = BTreeMap::new();
for s in srcs.iter().filter(|s| not_halt(s)) {
for (start, end, _) in attrs(s.code.as_bytes(), &s.raw) {
let inner: String = s.raw[start..end].split_whitespace().collect();
let body = inner.trim_start_matches('#').trim_start_matches('!');
let body = &body[1..body.len() - 1];
let lint = body.starts_with("allow(")
|| body.starts_with("expect(")
|| body.starts_with("cfg_attr(")
&& (body.contains(",allow(") || body.contains(",expect("));
if lint {
*found.entry((s.path.clone(), body.to_string())).or_default() += 1;
}
}
}
let base: BTreeMap<(String, String), usize> = ALLOW_BASELINE
.iter()
.map(|(p, a, n)| ((p.to_string(), a.to_string()), *n))
.collect();
let new: Vec<_> = found
.iter()
.filter(|(k, n)| base.get(*k).is_none_or(|b| *n > b))
.map(|((p, a), n)| format!("(\"{p}\", r#\"{a}\"#, {n}),"))
.collect();
let gone: Vec<_> = base
.iter()
.filter(|(k, n)| found.get(*k).is_none_or(|f| f < n))
.map(|((p, a), n)| format!("{p} {a} x{n}"))
.collect();
assert!(
new.is_empty() && gone.is_empty(),
"#[allow] outside halt.rs and tests:\n {}\nfixed (shrink ALLOW_BASELINE):\n {}",
new.join("\n "),
gone.join("\n ")
);
}
// The scanner itself: masking, cfg evaluation and item blanking.
#[test]
fn scanner_sees_production_only() {
assert!(test_only("test"));
assert!(test_only("all(test, not(loom))"));
assert!(test_only("any(test, feature = \"test-util\")"));
assert!(!test_only("any(target_os = \"linux\", test)"));
assert!(!test_only("not(test)"));
let raw = "fn a() { std::thread::sleep(d); } // thread::sleep\n\
const S: &str = \"thread::sleep\"; const C: char = '{';\n\
#[cfg(all(test, unix))]\nmod tests { fn b() { thread::sleep(d); } }\n\
#[cfg(test)]\nuse std::thread::sleep;\nfn c<'a>(x: &'a u8) {}\n\
#[cfg(test)]\nimpl<A, B> T for S<A, B> { fn d() { thread::sleep(d); } }\n\
struct F { #[cfg(test)] g: Vec<u8>, h: u8 }\n";
let mut code = mask(raw);
drop_test_items(raw, &mut code).unwrap();
let code = String::from_utf8(code).unwrap();
assert_eq!(code.matches("thread::sleep").count(), 1, "{code}");
assert!(!code.contains("sleep;") && code.contains("fn c<'a>"));
assert!(!code.contains(" g:") && code.contains(" h: u8 }"), "{code}");
let spans = fn_spans(code.as_bytes());
assert_eq!(
spans.iter().map(|s| s.2.as_str()).collect::<Vec<_>>(),
["a", "c"]
);
let all = production();
for must in [
"src/drive/mod.rs",
"src/scsi/macos.rs",
"src/scsi/windows.rs",
"src/halt.rs",
] {
assert!(all.iter().any(|s| s.path == must), "walk lost {must}");
}
for never in [
"src/drive/stop_tests.rs",
"src/halt/tests.rs",
"src/test_util_tests.rs",
"src/whole_disc_tests.rs",
"src/test_util.rs",
] {
assert!(!all.iter().any(|s| s.path == never), "walk took {never}");
}
}
+3
View File
@@ -41,6 +41,9 @@ SS-4 The TEST UNIT READY command (see table 202) provides a means to check if th
SS-5 The Drive maintains two separate Prevent states: Prevent and Persistent Prevent as described in 4.1.7. The Persistent and Prevent bits are used to independently select values for these states. See Table 329. Table 329 — State Selection Persistent Prevent Meaning 0 0 Prevent State shall be cleared (Unlocked) 0 1 Prevent State shall be set (Locked) 1 0 Persistent Prevent State shall be cleared (Persistent Allow) 1 1 Persistent Prevent State shall be set (Persistent Prevent)
SS-6 When Power Conditions field is zero and FL is zero, the meanings of LoEj and Start are defined in Table 633. Table 633 — LoEj and Start Meanings when Power Conditions = 0 LoEj Start Operation 0 0 Stop the disc 0 1 Start the disc and make ready for access 1 0 Eject the disc if permitted. It is not an error If no media is present. See 6.13, PREVENT ALLOW MEDIUM REMOVAL Command 1 1 Load the disc, Start the disc and make ready for access. It is not an error if no media is present.
SS-7 static int _mmc_report_key(MMC *mmc, uint8_t agid, uint32_t addr, uint8_t blocks, uint8_t format, uint8_t *buf, uint16_t len) { uint8_t cmd[MMC_CDB_SIZE]; memset(cmd, 0, sizeof(cmd)); BD_DEBUG(DBG_MMC, "MMC report key...\n"); cmd[0] = MMC_REPORT_KEY; cmd[2] = (addr >> 24) & 0xff; cmd[3] = (addr >> 16) & 0xff; cmd[4] = (addr >> 8) & 0xff; cmd[5] = addr & 0xff; cmd[6] = blocks; cmd[7] = MMC_KEY_CLASS_AACS; cmd[8] = (len >> 8) & 0xff; cmd[9] = len & 0xff; cmd[10] = (agid << 6) | (format & 0x3f); return device_send_cmd(mmc->dev, cmd, buf, 0, len); } … static int _mmc_invalidate_agid(MMC *mmc, uint8_t agid) { uint8_t buf[2]; memset(buf, 0, sizeof(buf)); return _mmc_report_key(mmc, agid, 0, 0, MMC_REPORT_KEY_AACS_INVALIDATE_AGID, buf, sizeof(buf)); } static void _mmc_invalidate_agids(MMC *mmc) { int agid; /* invalidate all agids */ for (agid = 0; agid < 4; agid++) { _mmc_invalidate_agid(mmc, agid); } } static int _mmc_report_agid(MMC *mmc, uint8_t *agid) { uint8_t buf[8]; memset(buf, 0, sizeof(buf)); int result = _mmc_report_key(mmc, 0, 0, 0, MMC_REPORT_KEY_AACS_AGID, buf, sizeof(buf)); if (result) { *agid = (buf[7] & 0xff) >> 6; } return result; }
SS-8 LOCK_EX Place an exclusive lock. Only one process may hold an exclusive lock for a given file at a given time. LOCK_UN Remove an existing lock held by this process. A call to flock() may block if an incompatible lock is held by another process. To make a nonblocking request, include LOCK_NB (by ORing) with any of the above operations. A single file may not simultaneously have both shared and exclusive locks. Locks created by flock() are associated with an open file description (see open(2)). This means that duplicate file descriptors (created by, for example, fork(2) or dup(2)) refer to the same lock, and this lock may be modified or released using any of these file descriptors. Furthermore, the lock is released either by an explicit LOCK_UN operation on any of these duplicate file descriptors, or when all such file descriptors have been closed. If a process uses open(2) (or similar) to obtain more than one file descriptor for the same file, these file descriptors are treated independently by flock(). An attempt to lock the file using one of these file descriptors may be denied by a lock that the calling process has already placed via another file descriptor. EWOULDBLOCK The file is locked and the LOCK_NB flag was selected. Since Linux 2.6.12, NFS clients support flock() locks by emulating them as fcntl(2) byte-range locks on the entire file. This means that fcntl(2) and flock() locks do interact with one another over NFS. It also means that in order to place an exclusive lock, the file must be opened for writing.
SS-9 A file identity is uniquely determined by the combination of st_dev and st_ino. At any given time in a system, distinct files shall have distinct file identities; hard links to the same file shall have the same file identity. Over time, these file identities can be reused for different files.
SS-10 LOCKFILE_EXCLUSIVE_LOCK 0x00000002 The function requests an exclusive lock. Otherwise, it requests a shared lock. LOCKFILE_FAIL_IMMEDIATELY 0x00000001 The function returns immediately if it is unable to acquire the requested lock. Otherwise, it waits. FILE_SHARE_DELETE 0x00000004 Enables subsequent open operations on a file or device to request delete access. Otherwise, no process can open the file or device if it requests delete access. The identifier (low and high parts) and the volume serial number uniquely identify a file on a single computer. To determine whether two open handles represent the same file, combine the identifier and the volume serial number for each file and compare them.
SS-12 The fsync() function shall request that all data for the open file descriptor named by fildes is to be transferred to the storage device associated with the file described by fildes. The nature of the transfer is implementation-defined. The fsync() function shall not return until the system has completed that action or until an error is detected. The fdatasync() function shall force all currently queued I/O operations associated with the file indicated by file descriptor fildes to the synchronized I/O completion state. The functionality shall be equivalent to fsync() with the symbol _POSIX_SYNCHRONIZED_IO defined, with the exception that all I/O operations shall be completed as defined for synchronized I/O data integrity completion.
SS-13 This system call is extremely dangerous and should not be used in portable programs. None of these operations writes out the file's metadata. Therefore, unless the application is strictly performing overwrites of already-instantiated disk blocks, there are no guarantees that the data will be available after a crash. There is no user interface to know if a write is purely an overwrite. On filesystems using copy-on-write semantics (e.g., btrfs) an overwrite of existing allocated blocks is impossible. When writing into preallocated space, many filesystems also require calls into the block allocator, which this system call does not sync out to disk. This system call does not flush disk write caches and thus does not provide any data integrity on systems with volatile disk write caches. Some details SYNC_FILE_RANGE_WAIT_BEFORE and SYNC_FILE_RANGE_WAIT_AFTER will detect any I/O errors or ENOSPC conditions and will return these to the caller. Useful combinations of the flags bits are: SYNC_FILE_RANGE_WAIT_BEFORE | SYNC_FILE_RANGE_WRITE Ensures that all pages in the specified range which were dirty when sync_file_range() was called are placed under write-out. This is a start-write-for-data-integrity operation. SYNC_FILE_RANGE_WRITE Start write-out of all dirty pages in the specified range which are not presently under write-out. This is an asynchronous flush-to-disk operation. This is not suitable for data integrity operations. SYNC_FILE_RANGE_WAIT_BEFORE (or SYNC_FILE_RANGE_WAIT_AFTER) Wait for completion of write-out of all pages in the specified range. This can be used after an earlier SYNC_FILE_RANGE_WAIT_BEFORE | SYNC_FILE_RANGE_WRITE operation to wait for completion of that operation, and obtain its result. SYNC_FILE_RANGE_WAIT_BEFORE | SYNC_FILE_RANGE_WRITE | SYNC_FILE_RANGE_WAIT_AFTER This is a write-for-data-integrity operation that will ensure that all pages in the specified range which were dirty when sync_file_range() was called are committed to disk.
SS-14 Does the same thing as fsync(2) then asks the drive to flush all buffered data to the permanent storage device (arg is ignored). As this drains the entire queue of the device and acts as a barrier, data that had been fsync'd on the same device before is guaranteed to be persisted when this call returns. This is currently implemented on HFS, MS-DOS (FAT), Universal Disk Format (UDF) and APFS file systems. The operation may take quite a while to complete. Certain FireWire drives have also been known to ignore the request to flush their buffered data.
+5 -1
View File
@@ -24,9 +24,13 @@ src/drive/mod.rs lock_tray SS-5
src/drive/mod.rs unlock_tray SS-5
src/drive/allow.rs is_eject SS-6
src/drive/mod.rs eject SS-6
src/drive/mod.rs finish_eject SS-6
src/drive/mod.rs start_unit SS-6
src/drive/mod.rs note_ledger SS-7
src/keys/resolve.rs resolve KS-14
src/io/artifact_lock.rs acquire_ids SS-8
src/io/artifact_lock.rs acquire_ids SS-9
src/io/artifact_lock.rs acquire_ids SS-10
src/keys/resolve.rs resolve_observed KS-14
src/keys/resolve.rs borrow_samples KS-10
src/keys/resolve.rs apply_single_unit_rule KS-14
src/keys/arrival.rs decrypt_proven KS-5