use eyre::{Context, Result};
use mbx_cache_core::CacheDigest;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{Duration, SystemTime};
pub(crate) const ROOT_ENV: &str = "MBX_OUT_DIR_ROOT";
pub(crate) const ROOT: &str = "out-dirs/v1";
pub(crate) const PLACEHOLDER: &str = "out_dir";
const USE_STAMP_INTERVAL: Duration = Duration::from_secs(60 * 60);
const SCAN_FILE_LIMIT: usize = 10_000;
const STAGING_PREFIX: &str = ".tmp-";
const STAGING_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
const REGISTRAR: &str = ".registrar.lock";
struct Lease {
path: PathBuf,
lock: fslock::LockFile,
}
static LEASES: Mutex<std::collections::BTreeMap<PathBuf, Lease>> =
Mutex::new(std::collections::BTreeMap::new());
static ORIGINAL: Mutex<Option<std::ffi::OsString>> = Mutex::new(None);
pub(crate) fn stabilize_for(source: &Path) -> Option<PathBuf> {
if !crate::session::share_out_dir_requested() {
return None;
}
let real = PathBuf::from(std::env::var_os("OUT_DIR")?);
if !real.is_absolute() || !real.is_dir() {
return None;
}
let root = PathBuf::from(std::env::var_os(ROOT_ENV)?);
if !root.is_absolute() || real.starts_with(&root) {
return None;
}
let sources = source.parent()?;
if !sources_mention_out_dir(sources) {
return None;
}
match stabilize(&real, &root) {
Ok(Some(stable)) => {
*ORIGINAL.lock().unwrap() = Some(real.into_os_string());
unsafe { std::env::set_var("OUT_DIR", &stable) };
Some(stable)
}
Ok(None) => None,
Err(error) => {
crate::session::report_shim_warning(&format!(
"OUT_DIR was not made stable for this compilation: {error:#}"
));
None
}
}
}
pub(crate) fn restore() {
let Some(original) = ORIGINAL.lock().unwrap().take() else {
return;
};
unsafe { std::env::set_var("OUT_DIR", original) };
LEASES.lock().unwrap().clear();
}
pub(crate) fn sources_mention_out_dir(directory: &Path) -> bool {
let mut pending = vec![directory.to_path_buf()];
let mut scanned = 0;
while let Some(directory) = pending.pop() {
let Ok(entries) = std::fs::read_dir(&directory) else {
continue;
};
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
let Ok(kind) = entry.file_type() else {
continue;
};
if kind.is_dir() {
if !name.starts_with('.') && name != "target" && name != "node_modules" {
pending.push(entry.path());
}
} else if kind.is_file() && name.ends_with(".rs") {
scanned += 1;
if scanned > SCAN_FILE_LIMIT {
return false;
}
if std::fs::read(entry.path())
.is_ok_and(|contents| memchr::memmem::find(&contents, b"OUT_DIR").is_some())
{
return true;
}
}
}
}
false
}
pub(crate) fn stabilize(real: &Path, root: &Path) -> Result<Option<PathBuf>> {
let mut manifest = Vec::new();
let mut files = Vec::new();
let mut directories = Vec::new();
if !describe_tree(
real,
Path::new(""),
&mut manifest,
&mut files,
&mut directories,
)? {
return Ok(None);
}
let digest = CacheDigest::blake3(&manifest).hash;
let stable = root.join(&digest);
lease(root, &digest)?;
let published = (|| -> Result<()> {
let mut publishing = fslock::LockFile::open(&publish_lock_path(root, &digest))?;
publishing.lock()?;
if !stable.is_dir() {
materialize(real, root, &stable, &manifest, &files, &directories)?;
}
Ok(())
})();
if let Err(error) = published {
release(root, &digest);
return Err(error);
}
stamp_use(root, &digest);
Ok(Some(stable))
}
fn publish_lock_path(root: &Path, digest: &str) -> PathBuf {
root.join(format!("{digest}.publish.lock"))
}
fn release(root: &Path, digest: &str) {
let leases = leases_dir(root, digest);
if let Some(lease) = LEASES.lock().unwrap().remove(&leases) {
drop(lease.lock);
let _ = std::fs::remove_file(&lease.path);
let _ = std::fs::remove_dir(&leases);
}
}
fn leases_dir(root: &Path, digest: &str) -> PathBuf {
root.join(format!("{digest}.leases"))
}
fn registrar(root: &Path) -> Result<fslock::LockFile> {
std::fs::create_dir_all(root)?;
let mut lock = fslock::LockFile::open(&root.join(REGISTRAR))?;
lock.lock()?;
Ok(lock)
}
fn lease(root: &Path, digest: &str) -> Result<()> {
let leases = leases_dir(root, digest);
let mut held = LEASES.lock().unwrap();
if held.contains_key(&leases) {
return Ok(());
}
let _registrar = registrar(root)?;
std::fs::create_dir_all(&leases)?;
let path = leases.join(format!(
"{}-{}.lease",
std::process::id(),
crate::util::random_string(12)
));
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)?;
let mut lock = fslock::LockFile::open(&path)?;
if !lock.try_lock()? {
eyre::bail!("the OUT_DIR lease {} is already held", path.display());
}
held.insert(leases, Lease { path, lock });
Ok(())
}
fn leased(root: &Path, digest: &str, prune: bool) -> std::io::Result<bool> {
let leases = leases_dir(root, digest);
let entries = match std::fs::read_dir(&leases) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(error),
};
for entry in entries {
let entry = entry?;
let mut lock = fslock::LockFile::open(&entry.path())?;
if !lock.try_lock()? {
return Ok(true);
}
drop(lock);
if prune {
let _ = std::fs::remove_file(entry.path());
}
}
Ok(false)
}
fn describe_tree(
root: &Path,
relative: &Path,
manifest: &mut Vec<u8>,
files: &mut Vec<(PathBuf, bool)>,
directories: &mut Vec<PathBuf>,
) -> Result<bool> {
let directory = root.join(relative);
let mut entries = std::fs::read_dir(&directory)
.wrap_err_with(|| format!("failed to read {}", directory.display()))?
.collect::<std::io::Result<Vec<_>>>()?;
entries.sort_by_key(std::fs::DirEntry::file_name);
for entry in entries {
let name = entry.file_name();
let Some(name) = name.to_str() else {
return Ok(false);
};
if name.contains('\\') || name.contains('\n') {
return Ok(false);
}
let path = relative.join(name);
let spelled = path.to_string_lossy().replace('\\', "/");
let metadata = std::fs::symlink_metadata(entry.path())?;
if metadata.file_type().is_symlink() {
return Ok(false);
} else if metadata.is_dir() {
manifest.extend_from_slice(b"d ");
manifest.extend_from_slice(format!("{}:", spelled.len()).as_bytes());
manifest.extend_from_slice(spelled.as_bytes());
manifest.push(b'\n');
directories.push(path.clone());
if !describe_tree(root, &path, manifest, files, directories)? {
return Ok(false);
}
} else if metadata.is_file() {
let executable = is_executable(&metadata);
let digest = CacheDigest::blake3_file(&entry.path())?;
manifest.extend_from_slice(if executable { b"x " } else { b"f " });
manifest.extend_from_slice(digest.hash.as_bytes());
manifest.push(b' ');
manifest.extend_from_slice(format!("{}:", spelled.len()).as_bytes());
manifest.extend_from_slice(spelled.as_bytes());
manifest.push(b'\n');
files.push((path, executable));
} else {
return Ok(false);
}
}
Ok(true)
}
fn materialize(
real: &Path,
root: &Path,
stable: &Path,
manifest: &[u8],
files: &[(PathBuf, bool)],
directories: &[PathBuf],
) -> Result<()> {
std::fs::create_dir_all(root)?;
let name = stable
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
let staging = root.join(format!("{STAGING_PREFIX}{name}-{}", std::process::id()));
let _ = remove_tree(&staging);
std::fs::create_dir(&staging)?;
let copied = (|| -> Result<()> {
for directory in directories {
std::fs::create_dir_all(staging.join(directory))?;
}
for (file, executable) in files {
let source = real.join(file);
let destination = staging.join(file);
reflink_copy::reflink_or_copy(&source, &destination)
.wrap_err_with(|| format!("failed to copy {}", source.display()))?;
let modified = std::fs::metadata(&source)?.modified()?;
crate::materialize::set_modified(&destination, modified)?;
make_read_only(&destination, *executable)?;
}
for directory in directories.iter().rev() {
make_directory_read_only(&staging.join(directory))?;
}
Ok(())
})();
if let Err(error) = copied {
let _ = remove_tree(&staging);
return Err(error);
}
let mut copied_manifest = Vec::new();
let described = describe_tree(
&staging,
Path::new(""),
&mut copied_manifest,
&mut Vec::new(),
&mut Vec::new(),
);
if !matches!(described, Ok(true)) || copied_manifest != manifest {
let _ = remove_tree(&staging);
eyre::bail!("the build-script output changed while it was being copied");
}
match std::fs::rename(&staging, stable) {
Ok(()) => match make_directory_read_only(stable) {
Ok(()) => Ok(()),
Err(error) => {
let _ = remove_tree(stable);
Err(error).wrap_err_with(|| format!("failed to protect {}", stable.display()))
}
},
Err(_) if stable.is_dir() => {
let _ = remove_tree(&staging);
Ok(())
}
Err(error) => {
let _ = remove_tree(&staging);
Err(error).wrap_err_with(|| format!("failed to place {}", stable.display()))
}
}
}
#[cfg(unix)]
fn is_executable(metadata: &std::fs::Metadata) -> bool {
use std::os::unix::fs::PermissionsExt as _;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
fn is_executable(_: &std::fs::Metadata) -> bool {
false
}
#[cfg(unix)]
fn make_read_only(path: &Path, executable: bool) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt as _;
let mode = if executable { 0o555 } else { 0o444 };
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
}
#[cfg(not(unix))]
fn make_read_only(path: &Path, _executable: bool) -> std::io::Result<()> {
let mut permissions = std::fs::metadata(path)?.permissions();
permissions.set_readonly(true);
std::fs::set_permissions(path, permissions)
}
#[cfg(unix)]
fn make_directory_read_only(path: &Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o555))
}
#[cfg(not(unix))]
fn make_directory_read_only(_path: &Path) -> std::io::Result<()> {
Ok(())
}
fn marker_path(root: &Path, digest: &str) -> PathBuf {
root.join(format!("{digest}.used"))
}
fn stamp_use(root: &Path, digest: &str) {
let marker = marker_path(root, digest);
let fresh = std::fs::metadata(&marker)
.and_then(|metadata| metadata.modified())
.ok()
.and_then(|modified| modified.elapsed().ok())
.is_some_and(|since| since < USE_STAMP_INTERVAL);
if !fresh {
let _ =
std::fs::File::create(&marker).and_then(|file| file.set_modified(SystemTime::now()));
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct PruneOutcome {
pub removed_directories: u64,
pub removed_bytes: u64,
pub remaining_directories: u64,
pub remaining_bytes: u64,
}
pub(crate) fn collect(
root: &Path,
max_bytes: Option<u64>,
max_age: Option<Duration>,
dry_run: bool,
) -> Result<PruneOutcome> {
let mut outcome = PruneOutcome::default();
let entries = match std::fs::read_dir(root) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(outcome),
Err(error) => {
return Err(error).wrap_err_with(|| format!("failed to read {}", root.display()));
}
};
let now = SystemTime::now();
let mut trees: Vec<(String, PathBuf, u64, Option<Duration>)> = Vec::new();
let mut lease_dirs = Vec::new();
for entry in entries {
let Ok(entry) = entry else {
outcome.remaining_directories += 1;
continue;
};
let name = entry.file_name().to_string_lossy().into_owned();
let path = entry.path();
let is_dir = match entry.file_type() {
Ok(kind) => kind.is_dir(),
Err(_) => {
outcome.remaining_directories += 1;
outcome.remaining_bytes = outcome.remaining_bytes.saturating_add(tree_bytes(&path));
continue;
}
};
if !is_dir {
continue;
}
if name.starts_with(STAGING_PREFIX) {
let abandoned = age_of(&path, now).is_some_and(|age| age > STAGING_MAX_AGE);
if abandoned && !dry_run {
let _ = remove_tree(&path);
}
continue;
}
if let Some(digest) = name.strip_suffix(".leases") {
if is_digest_name(digest) {
lease_dirs.push(digest.to_owned());
}
continue;
}
if !is_digest_name(&name) {
continue;
}
let age = age_of(&marker_path(root, &name), now).or_else(|| age_of(&path, now));
trees.push((name, path, 0, age));
}
trees.sort_by_key(|tree| std::cmp::Reverse(tree.3));
let mut remaining_bytes = 0_u64;
for tree in &mut trees {
tree.2 = tree_bytes(&tree.1);
remaining_bytes = remaining_bytes.saturating_add(tree.2);
}
let mut kept = trees.len() as u64;
for (name, path, bytes, age) in &trees {
let expired = max_age.is_some_and(|max_age| age.is_some_and(|age| age > max_age));
let over_budget = max_bytes.is_some_and(|max_bytes| remaining_bytes > max_bytes);
if !expired && !over_budget {
continue;
}
let _registrar = registrar(root)?;
if leased(root, name, !dry_run).unwrap_or(true) {
continue;
}
if !dry_run {
let _ = std::fs::remove_file(marker_path(root, name));
if let Err(error) = remove_tree(path) {
log::warn!(
"could not remove the generated source tree {}: {error}",
path.display()
);
continue;
}
let _ = std::fs::remove_dir_all(leases_dir(root, name));
let _ = std::fs::remove_file(publish_lock_path(root, name));
}
kept -= 1;
remaining_bytes = remaining_bytes.saturating_sub(*bytes);
outcome.removed_directories += 1;
outcome.removed_bytes = outcome.removed_bytes.saturating_add(*bytes);
}
outcome.remaining_directories += kept;
outcome.remaining_bytes = outcome.remaining_bytes.saturating_add(remaining_bytes);
if !dry_run {
for digest in lease_dirs {
if root.join(&digest).is_dir() {
continue;
}
let _registrar = registrar(root)?;
if !leased(root, &digest, true).unwrap_or(true) {
let _ = std::fs::remove_dir_all(leases_dir(root, &digest));
let _ = std::fs::remove_file(publish_lock_path(root, &digest));
}
}
}
Ok(outcome)
}
pub(crate) fn stats(root: &Path) -> Option<PruneOutcome> {
collect(root, None, None, true).ok()
}
fn is_digest_name(name: &str) -> bool {
name.len() == 64 && name.bytes().all(|byte| byte.is_ascii_hexdigit())
}
fn age_of(path: &Path, now: SystemTime) -> Option<Duration> {
let modified = std::fs::metadata(path)
.and_then(|metadata| metadata.modified())
.ok()?;
now.duration_since(modified).ok()
}
fn remove_tree(path: &Path) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
if !path.is_dir() {
return std::fs::remove_dir_all(path);
}
let mut pending = vec![path.to_path_buf()];
while let Some(directory) = pending.pop() {
std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o755))?;
for entry in std::fs::read_dir(&directory)?.flatten() {
if entry.file_type()?.is_dir() {
pending.push(entry.path());
}
}
}
}
#[cfg(not(unix))]
{
let mut pending = vec![path.to_path_buf()];
while let Some(directory) = pending.pop() {
for entry in std::fs::read_dir(&directory)?.flatten() {
let kind = entry.file_type()?;
if kind.is_dir() {
pending.push(entry.path());
} else {
let mut permissions = entry.metadata()?.permissions();
permissions.set_readonly(false);
std::fs::set_permissions(entry.path(), permissions)?;
}
}
}
}
std::fs::remove_dir_all(path)
}
fn tree_bytes(directory: &Path) -> u64 {
let mut total = 0;
let mut pending = vec![directory.to_path_buf()];
while let Some(directory) = pending.pop() {
let Ok(entries) = std::fs::read_dir(&directory) else {
continue;
};
for entry in entries.flatten() {
let Ok(metadata) = entry.metadata() else {
continue;
};
if metadata.is_dir() {
pending.push(entry.path());
} else {
total += metadata.len();
}
}
}
total
}
#[cfg(test)]
fn release_all_under(root: &Path) {
LEASES
.lock()
.unwrap()
.retain(|path, _| !path.starts_with(root));
}
#[cfg(test)]
#[path = "out_dir_tests.rs"]
mod tests;