use std::fs::File;
use std::io::{self};
#[cfg(test)]
use std::sync::Mutex;
use std::path::{Path, PathBuf};
const TV_MAGIC: &[u8; 4] = b"TVPI";
const TVIM_MAGIC: &[u8; 4] = b"TVIM";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Durability {
#[default]
Durable,
Fast,
}
pub(crate) fn legacy_format_error(path: &Path) -> io::Error {
let mut head = [0u8; 5];
let opened = match File::open(path) {
Ok(f) => f,
Err(e) => return e,
};
let version = read_exact_at(&opened, &mut head, 0)
.ok()
.and_then(|()| (&head[0..4] == TV_MAGIC || &head[0..4] == TVIM_MAGIC).then_some(head[4]));
let detail = match version {
Some(v @ 5..=6) => format!(
"is a version {v} turbovec index; this build reads only the v7 \
format. Convert it with turbovec::convert (or the `convert` \
example), which reads v5, v6 and v7 and writes any of them"
),
Some(v @ 1..=4) => format!(
"is a version {v} turbovec index, which predates the v5 rotation \
change and cannot be decoded by any current build; rebuild it \
from the source vectors"
),
Some(v) => format!(
"claims turbovec format version {v}, which this build does not \
recognise"
),
None => "is not a turbovec index".to_string(),
};
io::Error::new(
io::ErrorKind::InvalidData,
format!("{} {detail}.", path.display()),
)
}
static TMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
const TMP_NAME_MAX: usize = 255;
pub(crate) fn file_nonce() -> u64 {
loop {
let n = ((tmp_rand() as u64) << 32) | tmp_rand() as u64;
if n != crate::io_v7::UNCLAIMED_NONCE {
return n;
}
}
}
fn tmp_rand() -> u32 {
use std::hash::{BuildHasher, Hasher};
let mut h = std::collections::hash_map::RandomState::new().build_hasher();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
h.write_u64(now);
h.finish() as u32
}
fn tmp_sibling(path: &Path, rand: u32) -> PathBuf {
let suffix = format!(
".tmp.{}.{}.{:08x}",
std::process::id(),
TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
rand,
);
let base = path
.file_name()
.map(std::ffi::OsStr::to_os_string)
.unwrap_or_default();
if base.len() + suffix.len() <= TMP_NAME_MAX {
let mut name = base;
name.push(suffix);
return path.with_file_name(name);
}
let s = base.to_string_lossy();
let mut cut = (TMP_NAME_MAX - suffix.len()).min(s.len());
while !s.is_char_boundary(cut) {
cut -= 1;
}
path.with_file_name(format!("{}{}", &s[..cut], suffix))
}
pub(crate) fn create_tmp(path: &Path) -> io::Result<(File, PathBuf)> {
let mut last_err = None;
for _ in 0..8 {
let tmp = tmp_sibling(path, tmp_rand());
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp)
{
Ok(f) => return Ok((f, tmp)),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => last_err = Some(e),
Err(e) => return Err(e),
}
}
Err(last_err.expect("retry loop ran"))
}
#[cfg_attr(not(windows), allow(dead_code))]
fn is_transient_rename_error(raw_os_error: Option<i32>) -> bool {
matches!(raw_os_error, Some(5) | Some(32))
}
pub(crate) fn rename_atomic(tmp: &Path, path: &Path) -> io::Result<()> {
#[cfg(windows)]
{
let mut delay_ms = 1u64;
for _ in 0..10 {
match std::fs::rename(tmp, path) {
Err(e) if is_transient_rename_error(e.raw_os_error()) => {
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
delay_ms = (delay_ms * 2).min(64);
}
r => return r,
}
}
}
std::fs::rename(tmp, path)
}
fn is_our_tmp_suffix(s: &str) -> bool {
let mut parts = s.split('.');
let (Some(pid), Some(seq)) = (parts.next(), parts.next()) else {
return false;
};
if pid.is_empty() || seq.is_empty() {
return false;
}
if !pid.bytes().all(|b| b.is_ascii_digit()) || !seq.bytes().all(|b| b.is_ascii_digit()) {
return false;
}
match (parts.next(), parts.next()) {
(None, _) => true,
(Some(rand), None) => rand.len() == 8 && rand.bytes().all(|b| b.is_ascii_hexdigit()),
_ => false,
}
}
static SWEPT: std::sync::Mutex<Option<std::collections::HashSet<PathBuf>>> =
std::sync::Mutex::new(None);
#[cfg(test)]
static SWEPT_TEST_SERIAL: Mutex<()> = Mutex::new(());
fn claim_first_sweep(path: &Path) -> bool {
let Ok(mut guard) = SWEPT.try_lock() else {
return false;
};
let seen = guard.get_or_insert_with(std::collections::HashSet::new);
if seen.len() >= 4096 {
return true;
}
seen.insert(path.to_path_buf())
}
pub(crate) fn sweep_stale_tmps(path: &Path) {
if path
.file_name()
.and_then(std::ffi::OsStr::to_str)
.and_then(|n| n.rsplit_once(".tmp."))
.is_some_and(|(_, suffix)| is_our_tmp_suffix(suffix))
{
return;
}
if !claim_first_sweep(path) {
return;
}
const STALE_AGE: std::time::Duration = std::time::Duration::from_secs(60 * 60);
let Some(base) = path.file_name().and_then(std::ffi::OsStr::to_str) else {
return;
};
let parent = match path.parent() {
Some(p) if !p.as_os_str().is_empty() => p,
_ => Path::new("."),
};
let Ok(entries) = std::fs::read_dir(parent) else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
let Some((stem, suffix)) = name.rsplit_once(".tmp.") else {
continue;
};
if !is_our_tmp_suffix(suffix) {
continue;
}
let budget = TMP_NAME_MAX.saturating_sub(".tmp.".len() + suffix.len());
let ours = stem == base || {
let want = budget.min(base.len());
let cut = (0..=want).rev().find(|&c| base.is_char_boundary(c));
cut.is_some_and(|cut| {
!stem.is_empty()
&& stem == &base[..cut]
&& !entry.path().with_file_name(stem).exists()
})
};
if !ours {
continue;
}
let Ok(meta) = entry.metadata() else { continue };
if !meta.is_file() {
continue;
}
let stale = meta.modified().is_ok_and(|m| {
std::time::SystemTime::now()
.duration_since(m)
.is_ok_and(|age| age > STALE_AGE)
});
if stale {
let _ = std::fs::remove_file(entry.path());
}
}
}
fn sync_parent_dir(path: &Path) -> io::Result<()> {
#[cfg(unix)]
{
if let Some(parent) = path.parent() {
let dir = if parent.as_os_str().is_empty() {
Path::new(".")
} else {
parent
};
File::open(dir)?.sync_all()?;
}
}
#[cfg(not(unix))]
let _ = path;
Ok(())
}
pub(crate) fn sync_parent_dir_after_commit(path: &Path) {
if let Err(e) = sync_parent_dir(path) {
crate::warning::warn(&format!(
"{} was written and committed, but syncing its parent directory \
failed ({e}); the file is visible now but the rename may not \
survive power loss",
path.display(),
));
}
}
pub(crate) fn min_tqplus_scale(dim: usize) -> f32 {
let need = (dim.max(1) as f32) * crate::MAX_INPUT_MAGNITUDE / f32::MAX;
need * 10.0
}
pub(crate) fn max_tqplus_shift(dim: usize) -> f32 {
let cap = f32::MAX / ((dim.max(1) as f32) * crate::MAX_INPUT_MAGNITUDE);
cap / 10.0
}
pub(crate) const MAX_VECTOR_SCALE: f32 = 1e22;
pub(crate) fn validate_calibration(
tqplus_shift: &[f32],
tqplus_scale: &[f32],
) -> io::Result<()> {
if let Some((i, &v)) = tqplus_shift
.iter()
.enumerate()
.find(|(_, v)| !v.is_finite() || v.abs() > max_tqplus_shift(tqplus_shift.len()))
{
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"invalid TQ+ shift at coord {i}: {v} (must be finite and \
|shift| <= {:e} at dim {})",
max_tqplus_shift(tqplus_shift.len()),
tqplus_shift.len()
),
));
}
if let Some((i, &v)) = tqplus_scale
.iter()
.enumerate()
.find(|(_, v)| !v.is_finite() || **v < min_tqplus_scale(tqplus_scale.len()))
{
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"invalid TQ+ scale at coord {i}: {v} (must be finite and \
>= {:e} at dim {}; search divides by it and sums across \
every coordinate, so a smaller value turns every score \
into Inf/NaN)",
min_tqplus_scale(tqplus_scale.len()),
tqplus_scale.len()
),
));
}
Ok(())
}
#[cfg(unix)]
pub(crate) fn read_exact_at(f: &File, buf: &mut [u8], off: u64) -> io::Result<()> {
use std::os::unix::fs::FileExt;
f.read_exact_at(buf, off)
}
#[cfg(windows)]
pub(crate) fn read_exact_at(f: &File, mut buf: &mut [u8], mut off: u64) -> io::Result<()> {
use std::os::windows::fs::FileExt;
while !buf.is_empty() {
let n = f.seek_read(buf, off)?;
if n == 0 {
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "truncated file"));
}
buf = &mut buf[n..];
off += n as u64;
}
Ok(())
}
#[cfg(test)]
mod rename_retry_tests {
use super::*;
#[test]
fn transient_windows_rename_errors_are_retried() {
assert!(is_transient_rename_error(Some(5)), "ERROR_ACCESS_DENIED");
assert!(
is_transient_rename_error(Some(32)),
"ERROR_SHARING_VIOLATION"
);
}
#[test]
fn permanent_windows_rename_errors_are_not_retried() {
for code in [2, 3, 19, 267, 1314] {
assert!(
!is_transient_rename_error(Some(code)),
"os error {code} is permanent and must not be retried"
);
}
assert!(!is_transient_rename_error(None));
}
}
#[cfg(test)]
mod tmp_protocol_tests {
use super::*;
fn test_dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("turbovec_io_{}_{}", name, std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn tmp_sibling_names_are_unique_and_recognizable() {
let p = Path::new("/some/dir/index.tv");
let a = tmp_sibling(p, 0xdeadbeef);
let b = tmp_sibling(p, 0xdeadbeef);
assert_ne!(a, b, "process-wide counter must distinguish same-rand names");
let name = a.file_name().unwrap().to_str().unwrap();
assert!(name.starts_with("index.tv.tmp."));
assert!(is_our_tmp_suffix(name.strip_prefix("index.tv.tmp.").unwrap()));
}
#[test]
fn tmp_sibling_truncates_to_name_max() {
let long = "x".repeat(250);
let p = std::env::temp_dir().join(&long);
let tmp = tmp_sibling(&p, 1);
let name = tmp.file_name().unwrap().to_str().unwrap();
assert!(name.len() <= TMP_NAME_MAX, "temp name {} bytes", name.len());
assert!(name.starts_with("xxx"));
assert!(name.contains(".tmp."));
let short = tmp_sibling(Path::new("a.tv"), 1);
assert!(short.file_name().unwrap().to_str().unwrap().starts_with("a.tv.tmp."));
}
#[test]
fn is_our_tmp_suffix_matches_only_our_pattern() {
assert!(is_our_tmp_suffix("1234.0"));
assert!(is_our_tmp_suffix("1234.7.deadbeef"));
assert!(!is_our_tmp_suffix("1234"));
assert!(!is_our_tmp_suffix("1234.x"));
assert!(!is_our_tmp_suffix("1234.7.deadbee")); assert!(!is_our_tmp_suffix("1234.7.deadbeef.9"));
assert!(!is_our_tmp_suffix("abc.0"));
assert!(!is_our_tmp_suffix(""));
}
#[cfg(unix)]
#[test]
fn create_new_refuses_planted_symlink() {
let dir = test_dir("symlink_excl");
let victim = dir.join("victim.txt");
std::fs::write(&victim, b"precious").unwrap();
let planted = dir.join("index.tv.tmp.999.0.00000000");
std::os::unix::fs::symlink(&victim, &planted).unwrap();
let err = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&planted)
.unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(std::fs::read(&victim).unwrap(), b"precious");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn create_tmp_skips_colliding_names() {
let dir = test_dir("create_tmp");
let dest = dir.join("index.tv");
let (f, tmp) = create_tmp(&dest).unwrap();
drop(f);
let (f2, tmp2) = create_tmp(&dest).unwrap();
drop(f2);
assert_ne!(tmp, tmp2);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn sweep_skips_destinations_that_are_themselves_temps() {
let _serial = super::SWEPT_TEST_SERIAL
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = test_dir("sweep_nested");
let staged = dir.join(format!("index.tvim.tmp.{}.0.deadbeef", std::process::id()));
sweep_stale_tmps(&staged);
assert!(
claim_first_sweep(&staged),
"a staged temp destination must not be memoized — it was never swept"
);
let real = dir.join("index.tvim");
sweep_stale_tmps(&real);
assert!(!claim_first_sweep(&real), "a real destination is swept and memoized");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn sweep_runs_once_per_destination_per_process() {
let _serial = super::SWEPT_TEST_SERIAL
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = test_dir("sweep_once");
let dest = dir.join("index.tv");
assert!(claim_first_sweep(&dest), "first save sweeps");
assert!(!claim_first_sweep(&dest), "later saves skip the scan");
assert!(claim_first_sweep(&dir.join("other.tv")), "a new destination sweeps");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn sweep_removes_only_stale_matching_temps() {
let dir = test_dir("sweep");
let dest = dir.join("index.tv");
std::fs::write(&dest, b"dest").unwrap();
let stale = dir.join("index.tv.tmp.4242.0.deadbeef");
let stale_legacy = dir.join("index.tv.tmp.4242.1");
let fresh = dir.join("index.tv.tmp.4242.2.deadbeef");
let other = dir.join("other.tv.tmp.4242.0.deadbeef");
let non_pattern = dir.join("index.tv.tmp.notes");
for p in [&stale, &stale_legacy, &fresh, &other, &non_pattern] {
std::fs::write(p, b"x").unwrap();
}
for p in [&stale, &stale_legacy, &other] {
let out = std::process::Command::new("touch")
.args(["-t", "202001010000", p.to_str().unwrap()])
.status()
.unwrap();
assert!(out.success());
}
sweep_stale_tmps(&dest);
assert!(!stale.exists(), "stale matching temp must be swept");
assert!(!stale_legacy.exists(), "stale legacy-pattern temp must be swept");
assert!(fresh.exists(), "fresh temp (possible live writer) must survive");
assert!(other.exists(), "another destination's temp must survive");
assert!(non_pattern.exists(), "non-matching name must survive");
assert!(dest.exists());
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod fork_safety_tests {
use super::{claim_first_sweep, SWEPT};
use std::path::Path;
use std::sync::mpsc;
use std::time::Duration;
fn completes_while_lock_held<T: Send + 'static>(
hold: impl FnOnce() -> Box<dyn std::any::Any>,
body: impl FnOnce() -> T + Send + 'static,
) -> bool {
let held = hold();
let (tx, rx) = mpsc::channel();
let worker = std::thread::spawn(move || {
let _ = tx.send(body());
});
let finished = rx.recv_timeout(Duration::from_secs(30)).is_ok();
drop(held);
let _ = worker.join();
finished
}
#[test]
fn sweep_claim_does_not_block_on_a_held_lock() {
let _serial = super::SWEPT_TEST_SERIAL
.lock()
.unwrap_or_else(|e| e.into_inner());
let ok = completes_while_lock_held(
|| Box::new(SWEPT.lock().unwrap_or_else(|e| e.into_inner())),
|| claim_first_sweep(Path::new("/nonexistent/fork-safety-probe.tv")),
);
assert!(
ok,
"claim_first_sweep blocked on the swept-destination lock — a forked \
worker would hang on its first write",
);
}
}