add transport feature: meta-share transport-plugin contract, client, harness, focus replica, testkit
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
//! Filesystem helpers shared by the hull and the plugins that own a corner of
|
||||
//! the shared `/data` volume: free space, on-disk size, and the grace-period
|
||||
//! removal both `reclaim` passes (cache containers in the hull, `bt-temp/` in
|
||||
//! the torrent plugin) are built on. Moved verbatim from meta-share
|
||||
//! `reclaim.rs` / `eviction.rs`.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use tracing::warn;
|
||||
|
||||
/// Free space (bytes available to an unprivileged process) on the filesystem
|
||||
/// backing `path`; `None` when the query fails or the platform isn't supported.
|
||||
#[cfg(unix)]
|
||||
pub fn available_bytes(path: &Path) -> Option<u64> {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
|
||||
// SAFETY: `c_path` is a valid NUL-terminated path; `statvfs` only writes
|
||||
// into the zeroed-out struct and returns 0 on success.
|
||||
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
|
||||
let rc = unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) };
|
||||
if rc != 0 {
|
||||
return None;
|
||||
}
|
||||
#[allow(clippy::unnecessary_cast)]
|
||||
Some((stat.f_bavail as u64).saturating_mul(stat.f_frsize as u64))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn available_bytes(_path: &Path) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Apparent bytes under `dir` (file lengths, subdirectories walked, symlinks
|
||||
/// skipped). `0` when absent. Blocking.
|
||||
pub fn dir_size_blocking(dir: &Path) -> u64 {
|
||||
let mut total = 0u64;
|
||||
let mut stack = vec![dir.to_path_buf()];
|
||||
while let Some(d) = stack.pop() {
|
||||
let Ok(rd) = std::fs::read_dir(&d) else { continue };
|
||||
for entry in rd.flatten() {
|
||||
match entry.file_type() {
|
||||
Ok(ft) if ft.is_dir() => stack.push(entry.path()),
|
||||
Ok(ft) if ft.is_file() => {
|
||||
if let Ok(meta) = entry.metadata() {
|
||||
total = total.saturating_add(meta.len());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/// Bytes `path` actually occupies: allocated blocks, not apparent length (a
|
||||
/// streaming-only torrent's file is sparse and its length is the whole film).
|
||||
/// Blocking.
|
||||
pub fn disk_usage_blocking(path: &Path) -> u64 {
|
||||
let mut total = 0u64;
|
||||
let mut stack = vec![path.to_path_buf()];
|
||||
while let Some(p) = stack.pop() {
|
||||
let Ok(meta) = std::fs::symlink_metadata(&p) else { continue };
|
||||
if meta.is_dir() {
|
||||
if let Ok(rd) = std::fs::read_dir(&p) {
|
||||
stack.extend(rd.flatten().map(|e| e.path()));
|
||||
}
|
||||
} else if meta.is_file() {
|
||||
total = total.saturating_add(allocated(&meta));
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn allocated(meta: &std::fs::Metadata) -> u64 {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
meta.blocks().saturating_mul(512)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn allocated(meta: &std::fs::Metadata) -> u64 {
|
||||
meta.len()
|
||||
}
|
||||
|
||||
/// Top-level entries of `dir` that `is_owned` doesn't claim.
|
||||
pub async fn unowned_entries(
|
||||
dir: &Path,
|
||||
is_owned: impl Fn(&std::ffi::OsStr) -> bool,
|
||||
) -> Vec<(PathBuf, OsString)> {
|
||||
let mut out = Vec::new();
|
||||
let Ok(mut rd) = tokio::fs::read_dir(dir).await else {
|
||||
return out;
|
||||
};
|
||||
while let Ok(Some(entry)) = rd.next_entry().await {
|
||||
let name = entry.file_name();
|
||||
if !is_owned(&name) {
|
||||
out.push((entry.path(), name));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Outcome of [`remove_if_stale`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Removal {
|
||||
Removed(u64),
|
||||
TooYoung,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Has nothing under `path` changed for `grace`? Unreadable → `false` (leave it).
|
||||
pub async fn is_stale(path: &Path, grace: Duration, now: SystemTime) -> bool {
|
||||
let p = path.to_path_buf();
|
||||
let newest = tokio::task::spawn_blocking(move || newest_change_blocking(&p))
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
match newest {
|
||||
Some(t) => now.duration_since(t).map(|age| age >= grace).unwrap_or(false),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a file or directory tree once nothing under it changed for `grace`.
|
||||
/// Returns the bytes it occupied on disk.
|
||||
pub async fn remove_if_stale(path: &Path, grace: Duration, now: SystemTime) -> Removal {
|
||||
if !grace.is_zero() && !is_stale(path, grace, now).await {
|
||||
return Removal::TooYoung;
|
||||
}
|
||||
let p = path.to_path_buf();
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
let bytes = disk_usage_blocking(&p);
|
||||
let r = match std::fs::symlink_metadata(&p) {
|
||||
Ok(m) if m.is_dir() => std::fs::remove_dir_all(&p),
|
||||
Ok(_) => std::fs::remove_file(&p),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
r.map(|()| bytes)
|
||||
})
|
||||
.await;
|
||||
match res {
|
||||
Ok(Ok(b)) => Removal::Removed(b),
|
||||
Ok(Err(e)) if e.kind() == std::io::ErrorKind::NotFound => Removal::Removed(0),
|
||||
Ok(Err(e)) => {
|
||||
warn!(path = %path.display(), error = %e, "reclaim: removal failed");
|
||||
Removal::Failed
|
||||
}
|
||||
Err(_) => Removal::Failed,
|
||||
}
|
||||
}
|
||||
|
||||
/// The most recent modification or status change of `path` or anything under
|
||||
/// it. ctime matters: a container renamed from `tmp/` into `cache/` keeps its
|
||||
/// old mtime, and only ctime records the move.
|
||||
pub fn newest_change_blocking(path: &Path) -> Option<SystemTime> {
|
||||
let mut newest: Option<SystemTime> = None;
|
||||
let mut stack = vec![path.to_path_buf()];
|
||||
while let Some(p) = stack.pop() {
|
||||
let Ok(meta) = std::fs::symlink_metadata(&p) else {
|
||||
if p == path {
|
||||
return None;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
for t in [meta.modified().ok(), change_time(&meta)].into_iter().flatten() {
|
||||
newest = Some(newest.map_or(t, |n| n.max(t)));
|
||||
}
|
||||
if meta.is_dir() {
|
||||
if let Ok(rd) = std::fs::read_dir(&p) {
|
||||
stack.extend(rd.flatten().map(|e| e.path()));
|
||||
}
|
||||
}
|
||||
}
|
||||
newest
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn change_time(meta: &std::fs::Metadata) -> Option<SystemTime> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let secs = u64::try_from(meta.ctime()).ok()?;
|
||||
Some(SystemTime::UNIX_EPOCH + Duration::from_secs(secs))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn change_time(_meta: &std::fs::Metadata) -> Option<SystemTime> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn young_entries_survive_and_zero_grace_removes() {
|
||||
let d = std::env::temp_dir().join(format!("fsutil-{}", std::process::id()));
|
||||
std::fs::create_dir_all(d.join("a")).unwrap();
|
||||
std::fs::write(d.join("a/f"), vec![1u8; 4096]).unwrap();
|
||||
let now = SystemTime::now();
|
||||
assert_eq!(
|
||||
remove_if_stale(&d.join("a"), Duration::from_secs(3600), now).await,
|
||||
Removal::TooYoung
|
||||
);
|
||||
assert!(matches!(
|
||||
remove_if_stale(&d.join("a"), Duration::ZERO, now).await,
|
||||
Removal::Removed(_)
|
||||
));
|
||||
assert!(!d.join("a").exists());
|
||||
assert_eq!(remove_if_stale(&d.join("gone"), Duration::ZERO, now).await, Removal::Removed(0));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unowned_lists_only_unclaimed_names() {
|
||||
let d = std::env::temp_dir().join(format!("fsutil-own-{}", std::process::id()));
|
||||
std::fs::create_dir_all(d.join("keep")).unwrap();
|
||||
std::fs::create_dir_all(d.join("drop")).unwrap();
|
||||
let got = unowned_entries(&d, |n| n == "keep").await;
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].1, "drop");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sizes_of_a_missing_dir_are_zero() {
|
||||
assert_eq!(dir_size_blocking(Path::new("/definitely/not/here")), 0);
|
||||
assert_eq!(disk_usage_blocking(Path::new("/definitely/not/here")), 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user