use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use anyhow::Context;
use super::housekeeping::{
TmpDirGuard, atomic_swap_dirs, clean_orphaned_tmp_dirs, read_metadata, validate_cache_key,
validate_filename,
};
#[cfg(test)]
use super::metadata::KconfigStatus;
use super::metadata::{
CacheArtifacts, CacheEntry, KernelMetadata, ListedEntry, format_image_missing_reason,
};
use super::resolve::resolve_cache_root;
use super::vmlinux_strip::strip_vmlinux_debug;
use super::{LOCK_DIR_NAME, TMP_DIR_PREFIX};
use crate::flock::{FlockMode, acquire_flock_with_timeout};
const SHARED_LOCK_DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const STORE_EXCLUSIVE_LOCK_DEFAULT_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(300);
const STORE_EXCLUSIVE_LOCK_TIMEOUT_ENV: &str = "KTSTR_CACHE_STORE_LOCK_TIMEOUT";
fn store_exclusive_lock_timeout() -> std::time::Duration {
match std::env::var(STORE_EXCLUSIVE_LOCK_TIMEOUT_ENV) {
Ok(v) if !v.is_empty() => match humantime::parse_duration(&v) {
Ok(d) => d,
Err(e) => {
tracing::warn!(
env = %STORE_EXCLUSIVE_LOCK_TIMEOUT_ENV,
value = %v,
err = %e,
"invalid cache-store lock timeout env value; \
falling back to default timeout",
);
STORE_EXCLUSIVE_LOCK_DEFAULT_TIMEOUT
}
},
_ => STORE_EXCLUSIVE_LOCK_DEFAULT_TIMEOUT,
}
}
#[derive(Debug)]
#[non_exhaustive]
pub struct CacheDir {
root: PathBuf,
}
fn warned_keys() -> &'static Mutex<HashSet<String>> {
static SET: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
SET.get_or_init(|| Mutex::new(HashSet::new()))
}
fn should_emit_unstripped_warn(entry: &CacheEntry, set: &Mutex<HashSet<String>>) -> bool {
if !should_warn_unstripped(entry) {
return false;
}
let mut guard = set.lock().unwrap_or_else(|e| e.into_inner());
guard.insert(entry.key.clone())
}
fn warn_if_unstripped_vmlinux(entry: &CacheEntry) {
if should_emit_unstripped_warn(entry, warned_keys()) {
tracing::warn!(
cache_key = %entry.key,
"cache: using unstripped vmlinux (strip failed on a prior build; \
re-run with a clean cache to retry)",
);
}
}
pub(crate) fn should_warn_unstripped(entry: &CacheEntry) -> bool {
entry.metadata.has_vmlinux() && !entry.metadata.vmlinux_stripped()
}
pub(crate) fn cache_content_matches(
cached: &KernelMetadata,
caller: &KernelMetadata,
caller_has_vmlinux: bool,
) -> bool {
cached.config_hash == caller.config_hash
&& cached.ktstr_kconfig_hash == caller.ktstr_kconfig_hash
&& cached.extra_kconfig_hash == caller.extra_kconfig_hash
&& cached.has_vmlinux() == caller_has_vmlinux
}
impl CacheDir {
pub fn new() -> anyhow::Result<Self> {
let root = resolve_cache_root()?;
Ok(CacheDir { root })
}
pub fn with_root(root: PathBuf) -> Self {
CacheDir { root }
}
pub fn default_root() -> anyhow::Result<PathBuf> {
resolve_cache_root()
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn lookup(&self, cache_key: &str) -> Option<CacheEntry> {
let entry = self.lookup_silent(cache_key)?;
warn_if_unstripped_vmlinux(&entry);
Some(entry)
}
fn lookup_silent(&self, cache_key: &str) -> Option<CacheEntry> {
if let Err(e) = validate_cache_key(cache_key) {
tracing::warn!("invalid cache key: {e}");
return None;
}
let entry_dir = self.root.join(cache_key);
if !entry_dir.is_dir() {
return None;
}
let metadata = read_metadata(&entry_dir).ok()?;
if !entry_dir.join(&metadata.image_name).exists() {
return None;
}
Some(CacheEntry {
key: cache_key.to_string(),
path: entry_dir,
metadata,
})
}
pub fn list(&self) -> anyhow::Result<Vec<ListedEntry>> {
let mut entries: Vec<ListedEntry> = Vec::new();
let read_dir = match fs::read_dir(&self.root) {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(entries),
Err(e) => return Err(e.into()),
};
for dir_entry in read_dir {
let dir_entry = dir_entry?;
let path = dir_entry.path();
let name = match dir_entry.file_name().into_string() {
Ok(n) => n,
Err(_) => continue,
};
if name.starts_with('.') {
continue;
}
if !path.is_dir() {
continue;
}
match read_metadata(&path) {
Ok(metadata) => {
let image_path = path.join(&metadata.image_name);
if image_path.exists() {
entries.push(ListedEntry::Valid(Box::new(CacheEntry {
key: name,
path,
metadata,
})));
} else {
entries.push(ListedEntry::Corrupt {
key: name,
path,
reason: format_image_missing_reason(&metadata.image_name),
});
}
}
Err(reason) => {
tracing::info!(
entry = %name,
path = %path.display(),
%reason,
"cache entry corrupt at list-time",
);
entries.push(ListedEntry::Corrupt {
key: name,
path,
reason,
});
}
}
}
entries.sort_by(|a, b| {
let a_time = a.as_valid().map(|e| e.metadata.built_at.as_str());
let b_time = b.as_valid().map(|e| e.metadata.built_at.as_str());
b_time.cmp(&a_time)
});
Ok(entries)
}
pub fn store(
&self,
cache_key: &str,
artifacts: &CacheArtifacts<'_>,
metadata: &KernelMetadata,
) -> anyhow::Result<CacheEntry> {
validate_cache_key(cache_key)?;
validate_filename(&metadata.image_name)?;
let _store_lock =
self.acquire_exclusive_lock_blocking(cache_key, store_exclusive_lock_timeout())?;
if let Some(existing) = self.lookup_silent(cache_key)
&& cache_content_matches(&existing.metadata, metadata, artifacts.vmlinux.is_some())
{
tracing::debug!(
cache_key = cache_key,
"cache.store: in-lock recheck hit; skipping copy/strip/publish",
);
return Ok(existing);
}
let final_dir = self.root.join(cache_key);
let tmp_dir = self.root.join(format!(
"{TMP_DIR_PREFIX}{}-{}",
cache_key,
std::process::id(),
));
if tmp_dir.exists() {
fs::remove_dir_all(&tmp_dir)?;
}
if let Err(e) = clean_orphaned_tmp_dirs(&self.root) {
tracing::warn!(err = %format!("{e:#}"), "clean_orphaned_tmp_dirs failed; continuing store");
}
fs::create_dir_all(&tmp_dir)?;
let _guard = TmpDirGuard(&tmp_dir);
let image_dest = tmp_dir.join(&metadata.image_name);
fs::copy(artifacts.image, &image_dest)
.map_err(|e| anyhow::anyhow!("copy kernel image to cache: {e}"))?;
let (has_vmlinux, vmlinux_stripped) = if let Some(vmlinux) = artifacts.vmlinux {
let vmlinux_dest = tmp_dir.join("vmlinux");
match strip_vmlinux_debug(vmlinux) {
Ok(stripped) => {
fs::copy(stripped.path(), &vmlinux_dest)
.map_err(|e| anyhow::anyhow!("copy stripped vmlinux to cache: {e}"))?;
(true, true)
}
Err(e) => {
tracing::warn!(
cache_key = cache_key,
err = %format!("{e:#}"),
"vmlinux strip failed, caching unstripped \
(larger on-disk payload). See \
`cargo ktstr kernel list --json` \
vmlinux_stripped field.",
);
fs::copy(vmlinux, &vmlinux_dest)
.map_err(|e| anyhow::anyhow!("copy vmlinux to cache: {e}"))?;
(true, false)
}
}
} else {
(false, false)
};
let mut meta = metadata.clone();
meta.set_has_vmlinux(has_vmlinux);
meta.set_vmlinux_stripped(vmlinux_stripped);
let meta_json = serde_json::to_string_pretty(&meta)?;
fs::write(tmp_dir.join("metadata.json"), meta_json)
.map_err(|e| anyhow::anyhow!("write cache metadata: {e}"))?;
match fs::rename(&tmp_dir, &final_dir) {
Ok(()) => {}
Err(e)
if e.raw_os_error() == Some(libc::ENOTEMPTY)
|| e.raw_os_error() == Some(libc::EEXIST) =>
{
atomic_swap_dirs(&tmp_dir, &final_dir)?;
}
Err(e) => {
return Err(anyhow::anyhow!("atomic rename cache entry: {e}"));
}
}
Ok(CacheEntry {
key: cache_key.to_string(),
path: final_dir,
metadata: meta,
})
}
pub fn clean_all(&self) -> anyhow::Result<usize> {
self.remove_entries(self.list()?)
}
pub fn clean_keep(&self, keep: usize) -> anyhow::Result<usize> {
self.remove_entries(self.list()?.into_iter().skip(keep))
}
fn remove_entries<I: IntoIterator<Item = ListedEntry>>(
&self,
iter: I,
) -> anyhow::Result<usize> {
let to_remove: Vec<_> = iter.into_iter().collect();
let count = to_remove.len();
for entry in &to_remove {
fs::remove_dir_all(entry.path())?;
}
Ok(count)
}
pub(crate) fn lock_path(&self, cache_key: &str) -> PathBuf {
self.root
.join(LOCK_DIR_NAME)
.join(format!("{cache_key}.lock"))
}
pub(crate) fn ensure_lock_dir(&self) -> anyhow::Result<()> {
let dir = self.root.join(LOCK_DIR_NAME);
fs::create_dir_all(&dir)
.with_context(|| format!("create lock subdirectory {}", dir.display()))
}
pub fn acquire_shared_lock(&self, cache_key: &str) -> anyhow::Result<SharedLockGuard> {
validate_cache_key(cache_key)?;
let path = self.lock_path(cache_key);
let fd = acquire_flock_with_timeout(
&path,
FlockMode::Shared,
SHARED_LOCK_DEFAULT_TIMEOUT,
&format!("cache entry {cache_key:?}"),
None,
)?;
Ok(SharedLockGuard { fd })
}
pub fn acquire_exclusive_lock_blocking(
&self,
cache_key: &str,
timeout: std::time::Duration,
) -> anyhow::Result<ExclusiveLockGuard> {
validate_cache_key(cache_key)?;
let path = self.lock_path(cache_key);
let fd = acquire_flock_with_timeout(
&path,
FlockMode::Exclusive,
timeout,
&format!("cache entry {cache_key:?}"),
Some(
"override the timeout via KTSTR_CACHE_STORE_LOCK_TIMEOUT (humantime: 30s, 2m, 1h)",
),
)?;
Ok(ExclusiveLockGuard { fd })
}
pub fn try_acquire_exclusive_lock(
&self,
cache_key: &str,
) -> anyhow::Result<ExclusiveLockGuard> {
validate_cache_key(cache_key)?;
self.ensure_lock_dir()?;
let path = self.lock_path(cache_key);
match crate::flock::try_flock(&path, crate::flock::FlockMode::Exclusive)? {
Some(fd) => Ok(ExclusiveLockGuard { fd }),
None => {
let holders = crate::flock::read_holders(&path).unwrap_or_default();
anyhow::bail!(
"cache entry {cache_key:?} is locked by active test runs \
(lockfile {lockfile}, holders: {holders}). Wait for \
those tests to finish, or kill them, then retry.",
lockfile = path.display(),
holders = crate::flock::format_holder_list(&holders),
);
}
}
}
}
#[derive(Debug)]
pub struct SharedLockGuard {
#[allow(dead_code)]
fd: std::os::fd::OwnedFd,
}
#[derive(Debug)]
pub struct ExclusiveLockGuard {
#[allow(dead_code)]
fd: std::os::fd::OwnedFd,
}
#[cfg(test)]
#[path = "cache_dir_tests.rs"]
mod tests;