use crate::transport::prelude::*;
use crate::transport::{ListenerOptions, TokioListener, TokioStream, socket_name};
use anyhow::{Context, Result};
use kache_core::{PrefetchDisposition, PrefetchPlan};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::num::NonZeroU64;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::sync::{Notify, RwLock};
use crate::config::{Config, UPLOAD_SPOOL_MAX_JOBS};
use crate::events;
use crate::remote_resilience::{
KeyedSingleflight, NegativeKeyCache, RemoteBreaker, RemoteDeadline, RemoteErrorClass,
RemoteOperation, SingleflightClaim, classify_remote_error,
};
use crate::store::Store;
const KEY_CACHE_AUTHORITATIVE_MULTIPLIER: u64 = 5;
const KEY_CACHE_AUTHORITATIVE_MAX_AGE: Duration = Duration::from_secs(300);
const REMOTE_CHECK_WARMING_GRACE: Duration = Duration::from_millis(750);
const REMOTE_CHECK_SINGLEFLIGHT_MAX_KEYS: usize = 4096;
const REMOTE_CHECK_LEGACY_BUDGET_MS: u64 = 3_000;
const UPLOAD_SPOOL_MAX_BYTES: u64 = 65_536;
const UPLOAD_RETRY_DELAY: Duration = Duration::from_secs(5);
fn remote_check_budget_ms(configured_secs: u64, client_ms: Option<u64>) -> NonZeroU64 {
let configured_ms = if configured_secs == 0 {
REMOTE_CHECK_LEGACY_BUDGET_MS
} else {
configured_secs
.saturating_mul(1_000)
.min(REMOTE_CHECK_LEGACY_BUDGET_MS)
};
let client_ms = client_ms
.filter(|milliseconds| *milliseconds != 0)
.unwrap_or(REMOTE_CHECK_LEGACY_BUDGET_MS)
.min(REMOTE_CHECK_LEGACY_BUDGET_MS);
NonZeroU64::new(configured_ms.min(client_ms))
.expect("the synchronous remote-check budget is always positive")
}
fn key_cache_miss_is_authoritative(refresh_secs: u64, age: Option<Duration>) -> bool {
if refresh_secs == 0 {
return false;
}
let refresh_window =
Duration::from_secs(refresh_secs.saturating_mul(KEY_CACHE_AUTHORITATIVE_MULTIPLIER));
let authoritative_for = refresh_window.min(KEY_CACHE_AUTHORITATIVE_MAX_AGE);
matches!(age, Some(age) if age <= authoritative_for)
}
fn speculative_prefetch_disabled(prefetch_enabled: bool) -> bool {
!prefetch_enabled
}
fn should_start_speculative_prefetch(remote_configured: bool, prefetch_enabled: bool) -> bool {
remote_configured && prefetch_enabled
}
fn key_cache_periodic_refresh_disabled(refresh_secs: u64) -> bool {
refresh_secs == 0
}
const DAEMON_START_TIMEOUT: Duration = Duration::from_secs(8);
const DAEMON_START_POLL_INTERVAL: Duration = Duration::from_millis(100);
const STATS_READ_TIMEOUT: Duration = Duration::from_secs(5);
const STATS_REFETCH_TIMEOUT: Duration = Duration::from_secs(3);
const DAEMON_COORD_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(2);
const DAEMON_CONFIG_WATCH_INTERVAL: Duration = Duration::from_secs(15);
const DAEMON_COORD_STALE_AFTER: Duration = Duration::from_secs(15);
const VERSION: &str = crate::VERSION;
const FILE_HASH_MEMORY_CACHE_CAP: usize = 4096;
pub fn build_epoch() -> u64 {
static BUILD_EPOCH: OnceLock<u64> = OnceLock::new();
*BUILD_EPOCH.get_or_init(|| {
std::env::current_exe()
.and_then(std::fs::metadata)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0)
})
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
enum DaemonPhase {
Starting,
Ready,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct DaemonCoordState {
pid: u32,
build_epoch: u64,
phase: DaemonPhase,
updated_at_ms: u64,
}
#[derive(Debug, Clone)]
struct DaemonCoordFile {
path: PathBuf,
pid: u32,
build_epoch: u64,
}
impl DaemonCoordFile {
fn for_socket(socket_path: &Path) -> Self {
Self {
path: daemon_state_path(socket_path),
pid: std::process::id(),
build_epoch: build_epoch(),
}
}
fn write_phase(&self, phase: DaemonPhase) -> Result<()> {
let state = DaemonCoordState {
pid: self.pid,
build_epoch: self.build_epoch,
phase,
updated_at_ms: now_millis(),
};
write_json_atomically(&self.path, &state)
}
}
struct DaemonCoordGuard {
path: PathBuf,
}
struct SocketCleanupGuard {
path: PathBuf,
}
impl DaemonCoordGuard {
fn new(path: PathBuf) -> Self {
Self { path }
}
}
impl Drop for DaemonCoordGuard {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
impl Drop for SocketCleanupGuard {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
fn daemon_state_path(socket_path: &Path) -> PathBuf {
socket_path.with_extension("state.json")
}
fn now_millis() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
fn write_json_atomically<T: Serialize>(path: &Path, value: &T) -> Result<()> {
let parent = path
.parent()
.ok_or_else(|| anyhow::anyhow!("state file has no parent directory"))?;
std::fs::create_dir_all(parent)?;
let file_name = path
.file_name()
.ok_or_else(|| anyhow::anyhow!("state file has no file name"))?
.to_string_lossy();
let tmp_path = parent.join(format!("{file_name}.{}.tmp", std::process::id()));
let json = serde_json::to_vec(value)?;
std::fs::write(&tmp_path, json)?;
std::fs::rename(&tmp_path, path)?;
Ok(())
}
fn read_daemon_state(socket_path: &Path) -> Option<DaemonCoordState> {
let path = daemon_state_path(socket_path);
let bytes = std::fs::read(path).ok()?;
serde_json::from_slice(&bytes).ok()
}
pub fn daemon_is_live(config: &Config) -> bool {
crate::transport::is_reachable(&config.socket_path()) || starting_daemon_epoch(config).is_some()
}
pub fn starting_daemon_epoch(config: &Config) -> Option<u64> {
let socket_path = config.socket_path();
let state = read_daemon_state(&socket_path)?;
if state.phase != DaemonPhase::Starting
|| !daemon_state_is_recent(&state)
|| !process_is_alive(state.pid)
{
return None;
}
existing_daemon_run_lock_is_held(&socket_path)
.ok()?
.then_some(state.build_epoch)
}
fn daemon_state_is_recent(state: &DaemonCoordState) -> bool {
now_millis()
.checked_sub(state.updated_at_ms)
.is_some_and(|age_ms| age_ms <= DAEMON_COORD_STALE_AFTER.as_millis() as u64)
}
pub(crate) fn client_epoch_is_newer(client_epoch: u64, daemon_epoch: u64) -> bool {
client_epoch > 0 && daemon_epoch > 0 && client_epoch > daemon_epoch
}
use crate::platform::is_process_alive as process_is_alive;
fn wait_for_run_lock_release(socket_path: &Path, timeout: Duration) -> Result<bool> {
let deadline = Instant::now() + timeout;
loop {
if !daemon_run_lock_is_held(socket_path)? {
return Ok(true);
}
if Instant::now() >= deadline {
return Ok(false);
}
std::thread::sleep(DAEMON_START_POLL_INTERVAL);
}
}
fn terminate_daemon_pid(pid: u32, socket_path: &Path) -> Result<bool> {
crate::platform::terminate_process(pid);
if wait_for_run_lock_release(socket_path, Duration::from_secs(1))? {
return Ok(true);
}
crate::platform::kill_process(pid);
wait_for_run_lock_release(socket_path, Duration::from_secs(1))
}
fn recover_unhealthy_daemon(socket_path: &Path, reason: &str) -> Result<bool> {
let run_lock_held = daemon_run_lock_is_held(socket_path)?;
if let Some(state) = read_daemon_state(socket_path) {
let state_recent = daemon_state_is_recent(&state);
if run_lock_held && process_is_alive(state.pid) {
tracing::info!(
socket = %socket_path.display(),
pid = state.pid,
?state.phase,
heartbeat_fresh = state_recent,
reason,
"terminating unhealthy daemon coordinator"
);
if !terminate_daemon_pid(state.pid, socket_path)? {
tracing::warn!(
socket = %socket_path.display(),
pid = state.pid,
heartbeat_fresh = state_recent,
reason,
"daemon process did not release run lock during recovery"
);
return Ok(false);
}
}
}
if daemon_run_lock_is_held(socket_path)? {
tracing::warn!(
socket = %socket_path.display(),
reason,
"daemon run lock still held and no recoverable coordinator state was found"
);
return Ok(false);
}
let _ = std::fs::remove_file(socket_path);
let _ = std::fs::remove_file(daemon_state_path(socket_path));
Ok(true)
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum Request {
Upload(UploadJob),
Gc(GcRequest),
GcV2(GcRequest),
RemoteCheck(RemoteCheckRequest),
Stats(StatsRequest),
BatchRemoteCheck(BatchRemoteCheckRequest),
HashFiles(HashFilesRequest),
LocalLookup(LocalLookupRequest),
Prefetch(PrefetchRequest),
BuildStarted(BuildStartedRequest),
CompileStarted(CompileStartedRequest),
CompileFinished(CompileFinishedRequest),
Shutdown,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct UploadJob {
pub key: String,
pub entry_dir: String,
#[serde(default)]
pub crate_name: String,
#[serde(default)]
pub client_epoch: u64,
}
fn upload_spool_path(config: &Config, key: &str) -> PathBuf {
config.upload_spool_dir().join(format!("{key}.json"))
}
fn upload_spool_error_is_not_found(error: &std::io::Error) -> bool {
matches!(error.kind(), std::io::ErrorKind::NotFound)
}
fn upload_spool_error_is_already_exists(error: &std::io::Error) -> bool {
matches!(error.kind(), std::io::ErrorKind::AlreadyExists)
}
fn upload_intent_size_is_valid(size: u64) -> bool {
size <= UPLOAD_SPOOL_MAX_BYTES
}
fn upload_spool_has_capacity(existing_count: usize) -> bool {
existing_count < UPLOAD_SPOOL_MAX_JOBS
}
fn count_upload_spool_entries<I, T>(entries: I) -> Result<usize>
where
I: IntoIterator<Item = std::io::Result<T>>,
{
let mut count = 0usize;
for entry in entries.into_iter().take(UPLOAD_SPOOL_MAX_JOBS) {
entry.context("reading upload spool entry")?;
count = count.saturating_add(1);
}
Ok(count)
}
fn normalize_upload_job(config: &Config, job: &UploadJob) -> Result<UploadJob> {
if !crate::cache_key::is_valid_cache_key(&job.key) {
anyhow::bail!("invalid upload cache key");
}
if !crate::cache_key::is_valid_crate_name(&job.crate_name) {
anyhow::bail!("invalid upload crate name");
}
Ok(UploadJob {
key: job.key.clone(),
entry_dir: config.store_dir().join(&job.key).display().to_string(),
crate_name: job.crate_name.clone(),
client_epoch: job.client_epoch,
})
}
fn existing_upload_job(config: &Config, key: &str) -> Result<Option<UploadJob>> {
let path = upload_spool_path(config, key);
let metadata = match std::fs::symlink_metadata(&path) {
Ok(metadata) => metadata,
Err(error) => {
if upload_spool_error_is_not_found(&error) {
return Ok(None);
}
return Err(error).with_context(|| format!("reading {}", path.display()));
}
};
if !metadata.file_type().is_file() {
anyhow::bail!("upload intent is not a regular file: {}", path.display());
}
if !upload_intent_size_is_valid(metadata.len()) {
anyhow::bail!("upload intent exceeds {UPLOAD_SPOOL_MAX_BYTES} bytes");
}
let bytes = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
let job: UploadJob = serde_json::from_slice(&bytes)
.with_context(|| format!("parsing upload intent {}", path.display()))?;
if job.key != key {
anyhow::bail!("upload intent key does not match file name");
}
let normalized = normalize_upload_job(config, &job)?;
let parent = path
.parent()
.context("upload intent path has no parent directory")?;
crate::atomic::fsync_dir(parent).context("flushing existing upload intent directory")?;
Ok(Some(normalized))
}
fn publish_upload_job_create_only(path: &Path, bytes: &[u8]) -> Result<bool> {
use std::io::Write as _;
let parent = path
.parent()
.context("upload intent path has no parent directory")?;
let mut temp = tempfile::NamedTempFile::new_in(parent)
.with_context(|| format!("creating upload intent temp in {}", parent.display()))?;
temp.write_all(bytes).context("writing upload intent")?;
temp.as_file()
.sync_all()
.context("flushing upload intent")?;
match temp.persist_noclobber(path) {
Ok(_) => {
crate::atomic::fsync_dir(parent).context("flushing upload intent directory")?;
Ok(true)
}
Err(error) => {
if upload_spool_error_is_already_exists(&error.error) {
drop(error.file);
crate::atomic::fsync_dir(parent).context("flushing upload intent directory")?;
Ok(false)
} else {
Err(error.error).context("publishing upload intent")
}
}
}
}
fn ensure_upload_spool_dir_with<C, S>(dir: &Path, create_dir_all: C, sync_dir: S) -> Result<()>
where
C: FnOnce(&Path) -> std::io::Result<()>,
S: FnOnce(&Path) -> std::io::Result<()>,
{
create_dir_all(dir).with_context(|| format!("creating upload spool {}", dir.display()))?;
let parent = dir
.parent()
.context("upload spool path has no parent directory")?;
sync_dir(parent).with_context(|| format!("flushing upload spool parent {}", parent.display()))
}
fn persist_upload_job(config: &Config, job: &UploadJob) -> Result<UploadJob> {
let normalized = normalize_upload_job(config, job)?;
let dir = config.upload_spool_dir();
ensure_upload_spool_dir_with(
&dir,
|path| std::fs::create_dir_all(path),
crate::atomic::fsync_dir,
)?;
if let Some(mut existing) = existing_upload_job(config, &normalized.key)? {
existing.client_epoch = normalized.client_epoch;
return Ok(existing);
}
let store = Store::open(config).context("opening store for upload intent publication")?;
let _gc_lock = store
.acquire_gc_lock()
.context("locking GC for upload intent publication")?;
if let Some(mut existing) = existing_upload_job(config, &normalized.key)? {
existing.client_epoch = normalized.client_epoch;
return Ok(existing);
}
if !store.contains(&normalized.key) {
anyhow::bail!("local cache entry missing before upload intent publication");
}
let entries = std::fs::read_dir(&dir)
.with_context(|| format!("reading upload spool {}", dir.display()))?;
let existing_count = count_upload_spool_entries(entries)
.with_context(|| format!("reading upload spool {}", dir.display()))?;
if !upload_spool_has_capacity(existing_count) {
anyhow::bail!("upload spool is full ({UPLOAD_SPOOL_MAX_JOBS} jobs)");
}
let bytes = serde_json::to_vec(&normalized).context("serializing upload intent")?;
if !upload_intent_size_is_valid(bytes.len() as u64) {
anyhow::bail!("upload intent exceeds {UPLOAD_SPOOL_MAX_BYTES} bytes");
}
let path = upload_spool_path(config, &normalized.key);
if publish_upload_job_create_only(&path, &bytes)? {
Ok(normalized)
} else {
let mut existing = existing_upload_job(config, &normalized.key)?
.context("upload intent winner disappeared")?;
existing.client_epoch = normalized.client_epoch;
Ok(existing)
}
}
fn remove_upload_job(config: &Config, key: &str) -> Result<()> {
let path = upload_spool_path(config, key);
match std::fs::remove_file(&path) {
Ok(()) => {
if let Some(parent) = path.parent() {
crate::atomic::fsync_dir(parent).context("flushing upload spool removal")?;
}
Ok(())
}
Err(error) => {
if upload_spool_error_is_not_found(&error) {
Ok(())
} else {
Err(error).with_context(|| format!("removing {}", path.display()))
}
}
}
}
fn load_upload_jobs(config: &Config) -> Result<Vec<UploadJob>> {
let dir = config.upload_spool_dir();
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(error) => {
if upload_spool_error_is_not_found(&error) {
return Ok(Vec::new());
}
return Err(error).with_context(|| format!("reading {}", dir.display()));
}
};
let mut jobs = Vec::new();
for entry in entries.take(UPLOAD_SPOOL_MAX_JOBS) {
let entry = entry?;
if !entry.file_type()?.is_file() {
continue;
}
if !upload_intent_size_is_valid(entry.metadata()?.len()) {
continue;
}
let Some(file_name) = entry.file_name().to_str().map(str::to_owned) else {
continue;
};
let Some(key) = file_name.strip_suffix(".json") else {
continue;
};
if !crate::cache_key::is_valid_cache_key(key) {
continue;
}
let bytes = std::fs::read(entry.path())?;
let Ok(job) = serde_json::from_slice::<UploadJob>(&bytes) else {
tracing::warn!(path = %entry.path().display(), "ignoring malformed upload intent");
continue;
};
if job.key != key {
tracing::warn!(path = %entry.path().display(), "ignoring invalid upload intent");
continue;
}
if !crate::cache_key::is_valid_crate_name(&job.crate_name) {
tracing::warn!(path = %entry.path().display(), "ignoring invalid upload intent");
continue;
}
jobs.push(UploadJob {
entry_dir: config.store_dir().join(key).display().to_string(),
..job
});
}
Ok(jobs)
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GcRequest {
pub max_age_hours: Option<u64>,
#[serde(default)]
pub mode: GcRequestMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effective_max_age_hours: Option<u64>,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum GcRequestMode {
#[default]
Legacy,
Automatic,
ExplicitAge,
}
#[derive(Debug, Clone, Copy)]
enum GcPolicy {
Automatic { max_age_hours: u64 },
ExplicitAge { hours: u64 },
}
impl GcPolicy {
fn mode(self) -> GcRequestMode {
match self {
Self::Automatic { .. } => GcRequestMode::Automatic,
Self::ExplicitAge { .. } => GcRequestMode::ExplicitAge,
}
}
}
fn gc_entries_pinned_lower_bound(
policy: GcPolicy,
duplicate: usize,
age: usize,
size: usize,
) -> usize {
match policy {
GcPolicy::ExplicitAge { .. } => age,
GcPolicy::Automatic { .. } => duplicate.max(age).max(size),
}
}
impl GcRequest {
fn automatic(effective_max_age_hours: u64) -> Self {
Self {
max_age_hours: None,
mode: GcRequestMode::Automatic,
effective_max_age_hours: Some(effective_max_age_hours),
}
}
fn explicit_age(hours: u64) -> Self {
Self {
max_age_hours: Some(hours),
mode: GcRequestMode::ExplicitAge,
effective_max_age_hours: None,
}
}
#[cfg(test)]
fn legacy(max_age_hours: Option<u64>) -> Self {
Self {
max_age_hours,
mode: GcRequestMode::Legacy,
effective_max_age_hours: None,
}
}
fn resolve(&self, daemon_max_age_hours: u64) -> Result<GcPolicy> {
Ok(match self.mode {
GcRequestMode::Automatic => GcPolicy::Automatic {
max_age_hours: self.effective_max_age_hours.ok_or_else(|| {
anyhow::anyhow!("automatic GC request is missing effective_max_age_hours")
})?,
},
GcRequestMode::ExplicitAge => GcPolicy::ExplicitAge {
hours: self.max_age_hours.ok_or_else(|| {
anyhow::anyhow!("explicit_age GC request is missing max_age_hours")
})?,
},
GcRequestMode::Legacy => match self.max_age_hours {
Some(hours) => GcPolicy::ExplicitAge { hours },
None => GcPolicy::Automatic {
max_age_hours: daemon_max_age_hours,
},
},
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RemoteCheckRequest {
pub key: String,
pub entry_dir: String,
#[serde(default)]
pub crate_name: String,
#[serde(default)]
pub deadline_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct StatsRequest {
pub include_entries: bool,
#[serde(default)]
pub include_summaries: bool,
pub sort_by: Option<String>,
pub event_hours: Option<u64>,
#[serde(default)]
pub client_epoch: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BatchRemoteCheckRequest {
pub checks: Vec<RemoteCheckRequest>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LocalLookupRequest {
pub key: String,
#[serde(default)]
pub client_epoch: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LocalLookupReply {
pub outcome: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub meta: Option<crate::store::EntryMeta>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl LocalLookupReply {
pub(crate) fn hit(meta: crate::store::EntryMeta) -> Self {
Self {
outcome: "hit".to_string(),
meta: Some(meta),
reason: None,
}
}
pub(crate) fn miss() -> Self {
Self {
outcome: "miss".to_string(),
meta: None,
reason: None,
}
}
pub(crate) fn fallback(reason: impl Into<String>) -> Self {
Self {
outcome: "fallback".to_string(),
meta: None,
reason: Some(reason.into()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct HashFilesRequest {
pub files: Vec<HashFileRequest>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct HashFileRequest {
pub path: String,
pub size: i64,
pub mtime_ns: i64,
pub ctime_ns: i64,
#[serde(default)]
pub inode: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct HashFileResult {
pub path: String,
pub size: i64,
pub mtime_ns: i64,
pub ctime_ns: i64,
#[serde(default)]
pub inode: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub hash: Option<String>,
#[serde(default)]
pub cache_hit: bool,
#[serde(default)]
pub bytes_hashed: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PrefetchRequest {
pub keys: Vec<(String, String)>,
#[serde(default)]
pub warm_all: bool,
}
impl PrefetchRequest {
pub fn from_plan(plan: PrefetchPlan) -> Self {
Self {
warm_all: false,
keys: plan
.candidates
.into_iter()
.filter(|c| {
let ok = crate::cache_key::is_valid_cache_key(&c.cache_key)
&& crate::cache_key::is_valid_crate_name(&c.crate_name);
if !ok {
tracing::warn!(
cache_key = key_prefix(&c.cache_key),
cache_key_len = c.cache_key.len(),
"prefetch: dropping planner candidate with invalid cache_key/crate_name"
);
}
ok
})
.map(|candidate| (candidate.cache_key, candidate.crate_name))
.collect(),
}
}
}
#[derive(Debug, Clone)]
struct PackPrefetchContext {
manifest_key: String,
namespace: String,
shard_hashes: Vec<String>,
selector: String,
}
impl PackPrefetchContext {
fn from_deps(manifest_key: String, namespace: &str, deps: &[(String, String)]) -> Result<Self> {
if deps.is_empty() {
anyhow::bail!("packed-prefetch requires Cargo.lock dependencies");
}
let mut shard_hashes = crate::shards::compute_shards(namespace, deps)
.shards
.into_iter()
.map(|(hash, _)| hash)
.collect::<Vec<_>>();
shard_hashes.sort();
let selector = crate::remote_pack::selector_hash(
&manifest_key,
namespace,
&shard_hashes,
crate::cache_key::CACHE_KEY_VERSION,
)?;
Ok(Self {
manifest_key,
namespace: namespace.to_string(),
shard_hashes,
selector,
})
}
fn from_intent(intent: &kache_core::BuildIntent) -> Option<Self> {
let namespace = intent.namespace.as_deref()?;
let manifest_key = std::env::var("KACHE_MANIFEST_KEY")
.unwrap_or_else(|_| crate::cli::default_manifest_key());
Self::from_deps(manifest_key, namespace, &intent.cargo_lock_deps).ok()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BuildStartedRequest {
#[serde(default)]
pub intent: kache_core::BuildIntent,
#[serde(default)]
pub client_epoch: u64,
#[serde(default)]
pub session_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CompileStartedRequest {
pub crate_name: String,
#[serde(default)]
pub root: String,
pub pid: u32,
pub started_at_ms: u64,
#[serde(default)]
pub typical_ms: Option<u64>,
#[serde(default)]
pub client_epoch: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CompileFinishedRequest {
pub pid: u32,
#[serde(default)]
pub started_at_ms: u64,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BatchResponse {
pub ok: bool,
pub results: Vec<Response>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct StatsResponse {
pub total_size: u64,
pub max_size: u64,
pub entry_count: usize,
pub entries: Option<Vec<StatsEntry>>,
pub events: EventStatsResponse,
#[serde(default)]
pub blob_stats: Option<crate::store::BlobStats>,
#[serde(default)]
pub recent_summaries: Vec<crate::events::BuildSummaryEvent>,
#[serde(default)]
pub version: String,
#[serde(default)]
pub build_epoch: u64,
#[serde(default)]
pub gc_policy_version: u32,
#[serde(default)]
pub pending_uploads: usize,
#[serde(default)]
pub active_downloads: usize,
#[serde(default)]
pub s3_concurrency_total: usize,
#[serde(default)]
pub s3_concurrency_used: usize,
#[serde(default)]
pub upload_queue_capacity: usize,
#[serde(default)]
pub uploads_completed: u64,
#[serde(default)]
pub uploads_failed: u64,
#[serde(default)]
pub uploads_skipped: u64,
#[serde(default)]
pub uploads_suppressed: u64,
#[serde(default)]
pub downloads_completed: u64,
#[serde(default)]
pub downloads_failed: u64,
#[serde(default)]
pub downloads_suppressed: u64,
#[serde(default)]
pub remote_check_roundtrips: u64,
#[serde(default)]
pub negative_hits: u64,
#[serde(default)]
pub negative_entries: u64,
#[serde(default)]
pub remote_degraded: bool,
#[serde(default)]
pub bytes_uploaded: u64,
#[serde(default)]
pub bytes_downloaded: u64,
#[serde(default)]
pub recent_transfers: Vec<TransferEvent>,
#[serde(default)]
pub prefetch: PrefetchStatsSnapshot,
#[serde(default)]
pub in_flight: Vec<InFlightEntry>,
#[serde(default)]
pub effective_config: Option<EffectiveConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EffectiveConfig {
pub max_size: u64,
pub cache_dir: String,
#[serde(default)]
pub runtime_dir: String,
pub config_path: String,
#[serde(default)]
pub config_fingerprint: Option<String>,
pub prefetch_enabled: bool,
#[serde(default)]
pub remote_description: Option<String>,
#[serde(default)]
pub local_only: bool,
#[serde(default)]
pub remote_error: Option<String>,
#[serde(default = "default_effective_remote_key_cache_refresh_secs")]
pub remote_key_cache_refresh_secs: u64,
pub socket_path: String,
#[serde(default)]
pub started_at_ms: u64,
}
fn default_effective_remote_key_cache_refresh_secs() -> u64 {
crate::config::DEFAULT_REMOTE_KEY_CACHE_REFRESH_SECS
}
impl EffectiveConfig {
pub(crate) fn capture(
config: &Config,
provenance: &crate::config::ConfigFileProvenance,
) -> Self {
Self {
max_size: config.max_size,
cache_dir: config.cache_dir.display().to_string(),
runtime_dir: config.runtime_dir.display().to_string(),
config_path: provenance.path.display().to_string(),
config_fingerprint: Some(provenance.fingerprint.clone()),
prefetch_enabled: config.prefetch_enabled,
remote_description: config.remote.as_ref().map(|remote| remote.describe()),
local_only: config.local_only,
remote_error: config.remote_error.clone(),
remote_key_cache_refresh_secs: config.remote_key_cache_refresh_secs,
socket_path: config.socket_path().display().to_string(),
started_at_ms: now_millis(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct InFlightEntry {
pub crate_name: String,
#[serde(default)]
pub root: String,
pub pid: u32,
pub elapsed_s: u64,
#[serde(default)]
pub typical_s: Option<u64>,
#[serde(default)]
pub eta_s: Option<u64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct PrefetchStatsSnapshot {
#[serde(default)]
pub downloads_completed: u64,
#[serde(default)]
pub bytes_downloaded: u64,
#[serde(default)]
pub keys_used: u64,
#[serde(default)]
pub keys_cancelled: u64,
#[serde(default)]
pub keys_over_budget: u64,
#[serde(default)]
pub cancelled: bool,
#[serde(default)]
pub plans_advisory: u64,
#[serde(default)]
pub plans_fallback: u64,
#[serde(default)]
pub last_plan_candidates: u64,
#[serde(default)]
pub dedup_join_waits: u64,
#[serde(default)]
pub dedup_join_wait_ms: u64,
#[serde(default)]
pub last_list_duration_ms: u64,
#[serde(default)]
pub last_list_key_count: u64,
#[serde(default)]
pub list_requests_total: u64,
#[serde(default)]
pub list_failures_total: u64,
#[serde(default)]
pub list_duration_ms_total: u64,
#[serde(default)]
pub list_keys_total: u64,
#[serde(default)]
pub pack_requests_total: u64,
#[serde(default)]
pub pack_bytes_downloaded: u64,
#[serde(default)]
pub v3_requests_total: u64,
#[serde(default)]
pub v3_bytes_downloaded: u64,
#[serde(default)]
pub pack_validation_failures: u64,
#[serde(default)]
pub pack_fallback_entries: u64,
#[serde(default)]
pub last_plan_wall_ms: u64,
#[serde(default)]
pub plan_wall_ms_total: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct StatsEntry {
pub cache_key: String,
pub crate_name: String,
pub crate_type: String,
pub profile: String,
pub size: u64,
pub hit_count: u64,
pub created_at: String,
pub last_accessed: String,
pub content_hash: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EventStatsResponse {
pub local_hits: usize,
#[serde(default)]
pub prefetch_hits: usize,
pub remote_hits: usize,
#[serde(default)]
pub dups: usize,
pub misses: usize,
pub errors: usize,
pub total_elapsed_ms: u64,
#[serde(default)]
pub hit_elapsed_ms: u64,
#[serde(default)]
pub miss_elapsed_ms: u64,
#[serde(default)]
pub hit_compile_time_ms: u64,
#[serde(default)]
pub miss_compile_time_ms: u64,
#[serde(default)]
pub store_output_blobs: u32,
#[serde(default)]
pub store_duplicate_blobs: u32,
#[serde(default)]
pub store_new_blobs: u32,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct GcPolicyOutcome {
pub entries_evicted: usize,
pub bytes_freed: u64,
}
impl From<&crate::store::GcStats> for GcPolicyOutcome {
fn from(stats: &crate::store::GcStats) -> Self {
Self {
entries_evicted: stats.entries_evicted,
bytes_freed: stats.bytes_freed,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GcBreakdown {
pub mode: GcRequestMode,
pub duplicate: GcPolicyOutcome,
pub age: GcPolicyOutcome,
pub size: GcPolicyOutcome,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub(crate) struct Response {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub evicted: Option<usize>,
#[serde(default, skip_serializing_if = "is_false")]
pub skipped: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gc: Option<GcBreakdown>,
#[serde(skip_serializing_if = "Option::is_none")]
pub found: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prefetched: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stats: Option<StatsResponse>,
#[serde(skip_serializing_if = "Option::is_none")]
pub batch_results: Option<Vec<Response>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hash_results: Option<Vec<HashFileResult>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local_lookup: Option<LocalLookupReply>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
fn is_false(value: &bool) -> bool {
!*value
}
impl Response {
fn ok() -> Self {
Self {
ok: true,
evicted: None,
skipped: false,
gc: None,
found: None,
prefetched: None,
stats: None,
batch_results: None,
hash_results: None,
local_lookup: None,
error: None,
}
}
#[cfg(test)]
fn ok_evicted(n: usize) -> Self {
Self {
ok: true,
evicted: Some(n),
skipped: false,
gc: None,
found: None,
prefetched: None,
stats: None,
batch_results: None,
hash_results: None,
local_lookup: None,
error: None,
}
}
fn ok_gc(total: usize, breakdown: GcBreakdown) -> Self {
Self {
evicted: Some(total),
gc: Some(breakdown),
..Self::ok()
}
}
fn ok_gc_skipped(breakdown: GcBreakdown) -> Self {
Self {
ok: true,
evicted: Some(0),
skipped: true,
gc: Some(breakdown),
found: None,
prefetched: None,
stats: None,
batch_results: None,
hash_results: None,
local_lookup: None,
error: None,
}
}
fn ok_stats(stats: StatsResponse) -> Self {
Self {
ok: true,
evicted: None,
skipped: false,
gc: None,
found: None,
prefetched: None,
stats: Some(stats),
batch_results: None,
hash_results: None,
local_lookup: None,
error: None,
}
}
fn ok_batch(results: Vec<Response>) -> Self {
Self {
ok: true,
evicted: None,
skipped: false,
gc: None,
found: None,
prefetched: None,
stats: None,
batch_results: Some(results),
hash_results: None,
local_lookup: None,
error: None,
}
}
fn ok_hash_results(results: Vec<HashFileResult>) -> Self {
Self {
ok: true,
evicted: None,
skipped: false,
gc: None,
found: None,
prefetched: None,
stats: None,
batch_results: None,
hash_results: Some(results),
local_lookup: None,
error: None,
}
}
fn found(val: bool) -> Self {
Self {
ok: true,
evicted: None,
skipped: false,
gc: None,
found: Some(val),
prefetched: None,
stats: None,
batch_results: None,
hash_results: None,
local_lookup: None,
error: None,
}
}
fn found_prefetched(val: bool, prefetched: bool) -> Self {
Self {
ok: true,
evicted: None,
skipped: false,
gc: None,
found: Some(val),
prefetched: Some(prefetched),
stats: None,
batch_results: None,
hash_results: None,
local_lookup: None,
error: None,
}
}
fn ok_local_lookup(reply: LocalLookupReply) -> Self {
Self {
local_lookup: Some(reply),
..Self::ok()
}
}
fn err(msg: impl Into<String>) -> Self {
Self {
ok: false,
evicted: None,
skipped: false,
gc: None,
found: None,
prefetched: None,
stats: None,
batch_results: None,
hash_results: None,
local_lookup: None,
error: Some(msg.into()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum TransferDirection {
Upload,
Download,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TransferEvent {
#[serde(default = "default_transfer_schema")]
pub schema: u32,
pub crate_name: String,
pub direction: TransferDirection,
#[serde(default)]
pub format: String,
#[serde(default)]
pub cache_key: String,
#[serde(default)]
pub object_key: String,
pub compressed_bytes: u64,
#[serde(default)]
pub started_at_unix_ms: u64,
#[serde(default)]
pub finished_at_unix_ms: u64,
pub elapsed_ms: u64,
#[serde(default)]
pub network_ms: u64,
#[serde(default)]
pub semaphore_wait_ms: u64,
#[serde(default)]
pub head_ms: u64,
#[serde(default)]
pub request_ms: u64,
#[serde(default)]
pub body_ms: u64,
#[serde(default)]
pub request_count: u32,
#[serde(default)]
pub original_bytes: u64,
#[serde(default)]
pub decompress_ms: u64,
#[serde(default)]
pub extract_ms: u64,
#[serde(default)]
pub disk_io_ms: u64,
#[serde(default)]
pub import_lock_wait_ms: u64,
#[serde(default)]
pub import_ms: u64,
#[serde(default)]
pub compression_ms: u64,
#[serde(default)]
pub head_checks_ms: u64,
#[serde(default)]
pub blobs_skipped: u32,
#[serde(default)]
pub blobs_total: u32,
pub ok: bool,
pub timestamp: u64,
}
const fn default_transfer_schema() -> u32 {
3
}
fn unix_time_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
pub(crate) struct TransferCounters {
pub uploads_completed: std::sync::atomic::AtomicU64,
pub uploads_failed: std::sync::atomic::AtomicU64,
pub uploads_skipped: std::sync::atomic::AtomicU64,
pub uploads_suppressed: std::sync::atomic::AtomicU64,
pub downloads_completed: std::sync::atomic::AtomicU64,
pub downloads_failed: std::sync::atomic::AtomicU64,
pub downloads_suppressed: std::sync::atomic::AtomicU64,
pub remote_check_roundtrips: std::sync::atomic::AtomicU64,
pub bytes_uploaded: std::sync::atomic::AtomicU64,
pub bytes_downloaded: std::sync::atomic::AtomicU64,
}
impl TransferCounters {
fn new() -> Self {
Self {
uploads_completed: 0.into(),
uploads_failed: 0.into(),
uploads_skipped: 0.into(),
uploads_suppressed: 0.into(),
downloads_completed: 0.into(),
downloads_failed: 0.into(),
downloads_suppressed: 0.into(),
remote_check_roundtrips: 0.into(),
bytes_uploaded: 0.into(),
bytes_downloaded: 0.into(),
}
}
}
fn prefetch_concurrency_cap(s3_concurrency: u32) -> usize {
let total = s3_concurrency.max(1) as usize;
let reserve = (total / 4).clamp(1, 4).min(total.saturating_sub(1));
(total - reserve).max(1)
}
pub(crate) struct PrefetchStats {
pub downloads_completed: std::sync::atomic::AtomicU64,
pub bytes_downloaded: std::sync::atomic::AtomicU64,
pub keys_used: std::sync::atomic::AtomicU64,
pub keys_cancelled: std::sync::atomic::AtomicU64,
pub keys_over_budget: std::sync::atomic::AtomicU64,
pub plans_advisory: std::sync::atomic::AtomicU64,
pub plans_fallback: std::sync::atomic::AtomicU64,
pub last_plan_candidates: std::sync::atomic::AtomicU64,
pub dedup_join_waits: std::sync::atomic::AtomicU64,
pub dedup_join_wait_ms: std::sync::atomic::AtomicU64,
pub last_list_duration_ms: std::sync::atomic::AtomicU64,
pub last_list_key_count: std::sync::atomic::AtomicU64,
pub list_requests_total: std::sync::atomic::AtomicU64,
pub list_failures_total: std::sync::atomic::AtomicU64,
pub list_duration_ms_total: std::sync::atomic::AtomicU64,
pub list_keys_total: std::sync::atomic::AtomicU64,
pub pack_requests_total: std::sync::atomic::AtomicU64,
pub pack_bytes_downloaded: std::sync::atomic::AtomicU64,
pub v3_requests_total: std::sync::atomic::AtomicU64,
pub v3_bytes_downloaded: std::sync::atomic::AtomicU64,
pub pack_validation_failures: std::sync::atomic::AtomicU64,
pub pack_fallback_entries: std::sync::atomic::AtomicU64,
pub last_plan_wall_ms: std::sync::atomic::AtomicU64,
pub plan_wall_ms_total: std::sync::atomic::AtomicU64,
}
impl PrefetchStats {
fn new() -> Self {
Self {
downloads_completed: 0.into(),
bytes_downloaded: 0.into(),
keys_used: 0.into(),
keys_cancelled: 0.into(),
keys_over_budget: 0.into(),
plans_advisory: 0.into(),
plans_fallback: 0.into(),
last_plan_candidates: 0.into(),
dedup_join_waits: 0.into(),
dedup_join_wait_ms: 0.into(),
last_list_duration_ms: 0.into(),
last_list_key_count: 0.into(),
list_requests_total: 0.into(),
list_failures_total: 0.into(),
list_duration_ms_total: 0.into(),
list_keys_total: 0.into(),
pack_requests_total: 0.into(),
pack_bytes_downloaded: 0.into(),
v3_requests_total: 0.into(),
v3_bytes_downloaded: 0.into(),
pack_validation_failures: 0.into(),
pack_fallback_entries: 0.into(),
last_plan_wall_ms: 0.into(),
plan_wall_ms_total: 0.into(),
}
}
}
const RECENT_TRANSFERS_CAP: usize = 50;
#[derive(Debug)]
pub(crate) struct ActivePlan {
pub session_id: String,
pub plan_id: String,
pub plan_source: &'static str,
pub candidates: HashSet<String>,
pub demanded: HashSet<String>,
pub demanded_candidates: HashSet<String>,
pub downloaded: HashMap<String, u64>,
pub used: HashSet<String>,
pub cancelled: bool,
pub started_at_ms: u64,
pub last_activity_ms: u64,
pub list_requests_at_install: u64,
pub list_duration_ms_at_install: u64,
}
impl ActivePlan {
fn new(
session_id: String,
plan_id: String,
plan_source: &'static str,
candidates: HashSet<String>,
list_requests_at_install: u64,
list_duration_ms_at_install: u64,
) -> Self {
let now = epoch_ms();
Self {
session_id,
plan_id,
plan_source,
candidates,
demanded: HashSet::new(),
demanded_candidates: HashSet::new(),
downloaded: HashMap::new(),
used: HashSet::new(),
cancelled: false,
started_at_ms: now,
last_activity_ms: now,
list_requests_at_install,
list_duration_ms_at_install,
}
}
fn record_demand(&mut self, key: &str) -> bool {
self.last_activity_ms = epoch_ms();
if self.demanded.insert(key.to_string()) {
if self.candidates.contains(key) {
self.demanded_candidates.insert(key.to_string());
}
if self.downloaded.contains_key(key) {
self.used.insert(key.to_string());
}
}
if self.cancelled {
return false;
}
let downloaded_not_demanded = self
.downloaded
.keys()
.filter(|k| !self.demanded.contains(*k))
.count() as u64;
if should_cancel_prefetch(
self.demanded.len() as u64,
self.demanded_candidates.len() as u64,
downloaded_not_demanded,
) {
self.cancelled = true;
return true;
}
false
}
fn record_download(&mut self, key: &str, compressed_bytes: u64) {
self.last_activity_ms = epoch_ms();
self.downloaded.insert(key.to_string(), compressed_bytes);
if self.demanded.contains(key) {
self.used.insert(key.to_string());
}
}
fn used_bytes(&self) -> u64 {
self.used
.iter()
.filter_map(|k| self.downloaded.get(k))
.sum()
}
}
pub(crate) fn prefetch_key_budget_overflow(offered: usize, max_keys: u64) -> usize {
if max_keys == 0 {
return 0;
}
offered.saturating_sub(max_keys as usize)
}
pub(crate) fn prefetch_byte_budget_exhausted(max_bytes: u64, spent: u64) -> bool {
max_bytes > 0 && spent >= max_bytes
}
pub(crate) fn should_cancel_prefetch(
demanded: u64,
demanded_candidates: u64,
downloaded_not_demanded: u64,
) -> bool {
if demanded < 10 {
return false;
}
let upper_bound_hits = demanded_candidates + downloaded_not_demanded;
(upper_bound_hits as f64 / demanded as f64) < 0.3
}
fn epoch_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
#[derive(Default)]
struct S3Index {
keys: HashSet<String>,
by_crate: HashMap<String, Vec<String>>,
}
pub(crate) struct S3KeyCache {
index: RwLock<Option<S3Index>>,
populated: AtomicBool,
last_populated: RwLock<Option<Instant>>,
revision: AtomicU64,
}
impl S3KeyCache {
fn new() -> Self {
Self {
index: RwLock::new(None),
populated: AtomicBool::new(false),
last_populated: RwLock::new(None),
revision: AtomicU64::new(0),
}
}
pub async fn age(&self) -> Option<Duration> {
let guard = self.last_populated.read().await;
guard.map(|t| t.elapsed())
}
pub async fn check(&self, key: &str) -> Option<bool> {
if !self.populated.load(Ordering::Acquire) {
return None;
}
let guard = self.index.read().await;
guard.as_ref().map(|i| i.keys.contains(key))
}
pub async fn keys_for_crate(&self, crate_name: &str) -> Vec<String> {
if !self.populated.load(Ordering::Acquire) {
return vec![];
}
let guard = self.index.read().await;
guard
.as_ref()
.and_then(|i| i.by_crate.get(crate_name))
.cloned()
.unwrap_or_default()
}
fn refresh_revision(&self) -> u64 {
self.revision.load(Ordering::Acquire)
}
#[cfg(test)]
pub async fn populate(&self, keys: HashMap<String, String>) {
let revision = self.refresh_revision();
let _ = self.populate_if_unchanged(keys, revision).await;
}
pub async fn populate_if_unchanged(
&self,
keys: HashMap<String, String>,
start_revision: u64,
) -> bool {
let mut by_crate: HashMap<String, Vec<String>> = HashMap::new();
for (cache_key, crate_name) in &keys {
by_crate
.entry(crate_name.clone())
.or_default()
.push(cache_key.clone());
}
let new_index = S3Index {
keys: keys.into_keys().collect(),
by_crate,
};
let mut guard = self.index.write().await;
if self.revision.load(Ordering::Acquire) != start_revision {
tracing::debug!(
"discarding stale key-cache LIST snapshot after a concurrent point update"
);
return false;
}
*guard = Some(new_index);
drop(guard);
self.populated.store(true, Ordering::Release);
let mut ts = self.last_populated.write().await;
*ts = Some(Instant::now());
true
}
pub async fn insert(&self, key: String, crate_name: Option<&str>) {
let mut guard = self.index.write().await;
if let Some(index) = guard.as_mut() {
index.keys.insert(key.clone());
if let Some(name) = crate_name {
index
.by_crate
.entry(name.to_string())
.or_default()
.push(key);
}
}
self.revision.fetch_add(1, Ordering::AcqRel);
}
pub async fn remove(&self, key: &str) {
let mut guard = self.index.write().await;
if let Some(index) = guard.as_mut() {
index.keys.remove(key);
for keys in index.by_crate.values_mut() {
keys.retain(|k| k != key);
}
}
self.revision.fetch_add(1, Ordering::AcqRel);
}
}
pub(crate) struct Daemon {
config: Config,
store: OnceLock<Mutex<Store>>,
local_hit: tokio::sync::OnceCell<crate::daemon_local::LocalHitService>,
local_lookup_budget: Option<Duration>,
remote_backend: tokio::sync::OnceCell<Arc<dyn crate::remote_backend::RemoteBackend>>,
key_cache: Arc<S3KeyCache>,
remote_breaker: Arc<RemoteBreaker>,
negative_keys: NegativeKeyCache,
remote_checks: KeyedSingleflight<Response>,
s3_semaphore: Arc<tokio::sync::Semaphore>,
upload_tx: Mutex<Option<tokio::sync::mpsc::UnboundedSender<UploadJob>>>,
upload_queue_closed: AtomicBool,
pending_uploads: Arc<RwLock<HashSet<String>>>,
downloading: Arc<RwLock<HashMap<String, Arc<Notify>>>>,
warming_tx: tokio::sync::watch::Sender<bool>,
prefetched_keys: Arc<RwLock<HashSet<String>>>,
prefetch_cancel: tokio::sync::watch::Sender<bool>,
prefetch_stats: PrefetchStats,
prefetch_gate: Arc<tokio::sync::Semaphore>,
prefetch_used_keys: Arc<RwLock<HashSet<String>>>,
active_plan: Arc<std::sync::Mutex<Option<ActivePlan>>>,
in_flight_compiles: std::sync::Mutex<HashMap<u32, CompileStartedRequest>>,
version: String,
build_epoch: u64,
effective_config: EffectiveConfig,
transfer_counters: TransferCounters,
recent_transfers: std::sync::Mutex<std::collections::VecDeque<TransferEvent>>,
file_hash_cache: Arc<Mutex<HashMap<FileHashCacheKey, String>>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct FileHashCacheKey {
path: String,
size: i64,
mtime_ns: i64,
ctime_ns: i64,
inode: i64,
}
#[derive(Debug, Clone)]
struct GcRunReport {
mode: GcRequestMode,
duplicate: crate::store::GcStats,
age: crate::store::GcStats,
size: crate::store::GcStats,
total: crate::store::GcStats,
}
impl GcRunReport {
fn skipped(mode: GcRequestMode) -> Self {
Self {
mode,
duplicate: crate::store::GcStats::default(),
age: crate::store::GcStats::default(),
size: crate::store::GcStats::default(),
total: crate::store::GcStats {
skipped: true,
..Default::default()
},
}
}
fn breakdown(&self) -> GcBreakdown {
GcBreakdown {
mode: self.mode,
duplicate: GcPolicyOutcome::from(&self.duplicate),
age: GcPolicyOutcome::from(&self.age),
size: GcPolicyOutcome::from(&self.size),
}
}
}
impl Daemon {
#[cfg(test)]
pub fn new(config: Config) -> Self {
let provenance = crate::config::ConfigFileProvenance::current();
Self::new_with_provenance(config, &provenance)
}
#[cfg(test)]
fn new_with_local_lookup_budget(config: Config, budget: Option<Duration>) -> Self {
let provenance = crate::config::ConfigFileProvenance::current();
Self::new_with_provenance_and_local_lookup_budget(config, &provenance, budget)
}
fn new_with_provenance(
config: Config,
provenance: &crate::config::ConfigFileProvenance,
) -> Self {
Self::new_with_provenance_and_local_lookup_budget(
config,
provenance,
Some(crate::daemon_local::LOCAL_LOOKUP_DEADLINE),
)
}
fn new_with_provenance_and_local_lookup_budget(
config: Config,
provenance: &crate::config::ConfigFileProvenance,
local_lookup_budget: Option<Duration>,
) -> Self {
let permits = config.s3_concurrency.max(1) as usize;
let (warming_tx, _) = tokio::sync::watch::channel(false);
let (prefetch_cancel, _) = tokio::sync::watch::channel(false);
Self {
store: OnceLock::new(),
local_hit: tokio::sync::OnceCell::new(),
local_lookup_budget,
s3_semaphore: Arc::new(tokio::sync::Semaphore::new(permits)),
remote_backend: tokio::sync::OnceCell::new(),
key_cache: Arc::new(S3KeyCache::new()),
remote_breaker: Arc::new(RemoteBreaker::new()),
negative_keys: NegativeKeyCache::new(config.remote_negative_ttl_secs),
remote_checks: KeyedSingleflight::new(REMOTE_CHECK_SINGLEFLIGHT_MAX_KEYS),
upload_tx: Mutex::new(None),
upload_queue_closed: AtomicBool::new(false),
pending_uploads: Arc::new(RwLock::new(HashSet::new())),
downloading: Arc::new(RwLock::new(HashMap::new())),
warming_tx,
prefetched_keys: Arc::new(RwLock::new(HashSet::new())),
prefetch_cancel,
prefetch_stats: PrefetchStats::new(),
prefetch_gate: Arc::new(tokio::sync::Semaphore::new(prefetch_concurrency_cap(
config.s3_concurrency,
))),
prefetch_used_keys: Arc::new(RwLock::new(HashSet::new())),
active_plan: Arc::new(std::sync::Mutex::new(None)),
in_flight_compiles: std::sync::Mutex::new(HashMap::new()),
version: VERSION.to_string(),
build_epoch: build_epoch(),
effective_config: EffectiveConfig::capture(&config, provenance),
transfer_counters: TransferCounters::new(),
recent_transfers: std::sync::Mutex::new(std::collections::VecDeque::new()),
file_hash_cache: Arc::new(Mutex::new(HashMap::new())),
config,
}
}
fn store_lock(&self) -> Result<&Mutex<Store>> {
if let Some(store) = self.store.get() {
return Ok(store);
}
let store = Store::open(&self.config)?;
let _ = self.store.set(Mutex::new(store));
self.store
.get()
.ok_or_else(|| anyhow::anyhow!("daemon store failed to initialize"))
}
pub(crate) fn with_store<T>(&self, f: impl FnOnce(&Store) -> Result<T>) -> Result<T> {
let guard = self
.store_lock()?
.lock()
.map_err(|_| anyhow::anyhow!("daemon store mutex poisoned"))?;
f(&guard)
}
fn with_store_timed<T>(&self, f: impl FnOnce(&Store) -> Result<T>) -> (Result<T>, u64, u64) {
let store = match self.store_lock() {
Ok(store) => store,
Err(error) => return (Err(error), 0, 0),
};
let wait_started = Instant::now();
let guard = match store.lock() {
Ok(guard) => guard,
Err(_) => {
return (
Err(anyhow::anyhow!("daemon store mutex poisoned")),
wait_started.elapsed().as_millis() as u64,
0,
);
}
};
let lock_wait_ms = wait_started.elapsed().as_millis() as u64;
let import_started = Instant::now();
let result = f(&guard);
let import_ms = import_started.elapsed().as_millis() as u64;
(result, lock_wait_ms, import_ms)
}
pub(crate) fn entry_dir_for(&self, cache_key: &str) -> PathBuf {
debug_assert!(
crate::cache_key::is_valid_cache_key(cache_key),
"entry_dir_for called with unvalidated cache_key"
);
self.config.store_dir().join(cache_key)
}
pub(crate) fn remote_config(&self) -> Option<&crate::config::RemoteConfig> {
self.config.remote.as_ref()
}
pub(crate) async fn key_cache_keys_for_crate(&self, crate_name: &str) -> Vec<String> {
self.key_cache.keys_for_crate(crate_name).await
}
pub(crate) async fn download_planner_shard(
&self,
namespace: &str,
shard_hash: &str,
) -> Result<Option<crate::remote::Shard>> {
let remote = self
.config
.remote
.as_ref()
.ok_or_else(|| anyhow::anyhow!("no remote configured"))?;
let deadline = RemoteDeadline::from_secs(self.config.remote_restore_timeout_secs);
let breaker = self
.remote_breaker
.try_acquire(RemoteOperation::ShardGet)
.ok_or_else(|| anyhow::anyhow!("remote read breaker open"))?;
let backend = match deadline
.run("planner backend initialization", self.get_remote_backend())
.await
{
Ok(backend) => backend,
Err(error) => {
let class = classify_remote_error(&error);
breaker.failure(class, &format!("{error:#}"));
return Err(error);
}
};
let semaphore = match deadline
.run("planner shard queue", async {
self.s3_semaphore
.acquire()
.await
.map_err(|_| anyhow::anyhow!("remote semaphore closed"))
})
.await
{
Ok(permit) => permit,
Err(error) => {
let class = classify_remote_error(&error);
breaker.failure(class, &format!("{error:#}"));
return Err(error);
}
};
let result = deadline
.run(
"planner shard GET",
crate::remote::download_shard(
backend.as_ref(),
&remote.prefix,
namespace,
shard_hash,
),
)
.await;
drop(semaphore);
match &result {
Ok(_) => breaker.success(),
Err(error) => {
let class = classify_remote_error(error);
breaker.failure(class, &format!("{error:#}"));
}
}
result
}
async fn wait_for_warming(&self, timeout: Duration) -> bool {
let mut rx = self.warming_tx.subscribe();
if *rx.borrow() {
return true;
}
matches!(
tokio::time::timeout(timeout, rx.changed()).await,
Ok(Ok(()))
) || *rx.borrow()
}
fn signal_warming_complete(&self) {
self.warming_tx.send_replace(true);
}
fn push_transfer_event(&self, event: TransferEvent) {
if let Err(e) = events::log_transfer(&self.config.transfer_log_path(), &event) {
tracing::warn!("failed to log transfer event: {e}");
}
if let Ok(mut q) = self.recent_transfers.lock() {
if q.len() >= RECENT_TRANSFERS_CAP {
q.pop_front();
}
q.push_back(event);
}
}
pub fn set_upload_tx(&self, tx: tokio::sync::mpsc::UnboundedSender<UploadJob>) {
*self.upload_tx.lock().expect("upload queue mutex poisoned") = Some(tx);
self.upload_queue_closed.store(false, Ordering::Relaxed);
}
fn upload_tx(&self) -> Option<tokio::sync::mpsc::UnboundedSender<UploadJob>> {
self.upload_tx
.lock()
.expect("upload queue mutex poisoned")
.clone()
}
fn close_upload_queue(&self) {
self.upload_queue_closed.store(true, Ordering::Relaxed);
self.upload_tx
.lock()
.expect("upload queue mutex poisoned")
.take();
}
pub(crate) async fn get_remote_backend(
&self,
) -> Result<&Arc<dyn crate::remote_backend::RemoteBackend>> {
self.remote_backend
.get_or_try_init(|| async {
let remote = self
.config
.remote
.as_ref()
.ok_or_else(|| anyhow::anyhow!("no remote configured"))?;
crate::remote_backend::create_backend(remote, self.config.s3_pool_idle_secs).await
})
.await
}
#[cfg(test)]
pub fn handle_request_sync(&self, req: &Request) -> Response {
match req {
Request::Gc(gc) | Request::GcV2(gc) => self.handle_gc(gc),
Request::Stats(sr) => self.handle_stats(sr),
Request::HashFiles(req) => self.handle_hash_files(req),
Request::CompileStarted(req) => self.handle_compile_started(req.clone()),
Request::CompileFinished(req) => self.handle_compile_finished(req),
Request::Upload(_)
| Request::RemoteCheck(_)
| Request::BatchRemoteCheck(_)
| Request::LocalLookup(_)
| Request::Prefetch(_)
| Request::BuildStarted(_) => {
Response::err(
"upload/remote_check/batch/local_lookup/prefetch/build_started must be handled async",
)
}
Request::Shutdown => Response::ok(),
}
}
pub fn handle_stats(&self, req: &StatsRequest) -> Response {
let (total_size, entry_count, entries, blob_stats) = match self.with_store(|store| {
let total_size = store.total_size().unwrap_or(0);
let entry_count = store.entry_count().unwrap_or(0);
let entries = if req.include_entries {
let sort = req.sort_by.as_deref().unwrap_or("size");
store.list_entries(sort).ok().map(|list| {
list.into_iter()
.map(|e| StatsEntry {
cache_key: e.cache_key,
crate_name: e.crate_name,
crate_type: e.crate_type,
profile: e.profile,
size: e.size,
hit_count: e.hit_count,
created_at: e.created_at,
last_accessed: e.last_accessed,
content_hash: e.content_hash,
})
.collect()
})
} else {
None
};
let blob_stats = store.blob_stats().ok();
Ok((total_size, entry_count, entries, blob_stats))
}) {
Ok(values) => values,
Err(e) => return Response::err(format!("store open failed: {e}")),
};
let hours = req.event_hours.unwrap_or(24);
let since = chrono::Utc::now() - chrono::Duration::hours(hours as i64);
let event_list =
events::read_events_since(&self.config.event_log_path(), since).unwrap_or_default();
let es = events::compute_stats(&event_list);
let recent_summaries = if req.include_summaries {
let mut summaries =
events::read_summaries(&self.config.summary_log_path()).unwrap_or_default();
let keep_from = summaries.len().saturating_sub(5);
summaries.drain(..keep_from);
summaries
} else {
Vec::new()
};
let pending_uploads = self
.pending_uploads
.try_read()
.map(|g| g.len())
.unwrap_or(0);
let active_downloads = self.downloading.try_read().map(|g| g.len()).unwrap_or(0);
let tc = &self.transfer_counters;
let ps = &self.prefetch_stats;
let s3_total = self.config.s3_concurrency.max(1) as usize;
let s3_used = s3_total - self.s3_semaphore.available_permits();
let recent_transfers = self
.recent_transfers
.try_lock()
.map(|q| q.iter().cloned().collect())
.unwrap_or_default();
let in_flight = self.in_flight_snapshot();
Response::ok_stats(StatsResponse {
total_size,
max_size: self.config.max_size,
entry_count,
entries,
events: EventStatsResponse {
local_hits: es.local_hits,
prefetch_hits: es.prefetch_hits,
remote_hits: es.remote_hits,
dups: es.dups,
misses: es.misses,
errors: es.errors,
total_elapsed_ms: es.total_elapsed_ms,
hit_elapsed_ms: es.hit_elapsed_ms,
miss_elapsed_ms: es.miss_elapsed_ms,
hit_compile_time_ms: es.hit_compile_time_ms,
miss_compile_time_ms: es.miss_compile_time_ms,
store_output_blobs: es.store_output_blobs,
store_duplicate_blobs: es.store_duplicate_blobs,
store_new_blobs: es.store_new_blobs,
},
blob_stats,
recent_summaries,
version: self.version.clone(),
build_epoch: self.build_epoch,
gc_policy_version: GC_POLICY_PROTOCOL_VERSION,
pending_uploads,
active_downloads,
s3_concurrency_total: s3_total,
s3_concurrency_used: s3_used,
upload_queue_capacity: 0,
uploads_completed: tc.uploads_completed.load(Ordering::Relaxed),
uploads_failed: tc.uploads_failed.load(Ordering::Relaxed),
uploads_skipped: tc.uploads_skipped.load(Ordering::Relaxed),
uploads_suppressed: tc.uploads_suppressed.load(Ordering::Relaxed),
downloads_completed: tc.downloads_completed.load(Ordering::Relaxed),
downloads_failed: tc.downloads_failed.load(Ordering::Relaxed),
downloads_suppressed: tc.downloads_suppressed.load(Ordering::Relaxed),
remote_check_roundtrips: tc.remote_check_roundtrips.load(Ordering::Relaxed),
negative_hits: self.negative_keys.hits(),
negative_entries: self.negative_keys.len() as u64,
remote_degraded: self.remote_breaker.is_degraded(),
bytes_uploaded: tc.bytes_uploaded.load(Ordering::Relaxed),
bytes_downloaded: tc.bytes_downloaded.load(Ordering::Relaxed),
recent_transfers,
prefetch: PrefetchStatsSnapshot {
downloads_completed: ps.downloads_completed.load(Ordering::Relaxed),
bytes_downloaded: ps.bytes_downloaded.load(Ordering::Relaxed),
keys_used: ps.keys_used.load(Ordering::Relaxed),
keys_cancelled: ps.keys_cancelled.load(Ordering::Relaxed),
keys_over_budget: ps.keys_over_budget.load(Ordering::Relaxed),
cancelled: *self.prefetch_cancel.borrow(),
plans_advisory: ps.plans_advisory.load(Ordering::Relaxed),
plans_fallback: ps.plans_fallback.load(Ordering::Relaxed),
last_plan_candidates: ps.last_plan_candidates.load(Ordering::Relaxed),
dedup_join_waits: ps.dedup_join_waits.load(Ordering::Relaxed),
dedup_join_wait_ms: ps.dedup_join_wait_ms.load(Ordering::Relaxed),
last_list_duration_ms: ps.last_list_duration_ms.load(Ordering::Relaxed),
last_list_key_count: ps.last_list_key_count.load(Ordering::Relaxed),
list_requests_total: ps.list_requests_total.load(Ordering::Relaxed),
list_failures_total: ps.list_failures_total.load(Ordering::Relaxed),
list_duration_ms_total: ps.list_duration_ms_total.load(Ordering::Relaxed),
list_keys_total: ps.list_keys_total.load(Ordering::Relaxed),
pack_requests_total: ps.pack_requests_total.load(Ordering::Relaxed),
pack_bytes_downloaded: ps.pack_bytes_downloaded.load(Ordering::Relaxed),
v3_requests_total: ps.v3_requests_total.load(Ordering::Relaxed),
v3_bytes_downloaded: ps.v3_bytes_downloaded.load(Ordering::Relaxed),
pack_validation_failures: ps.pack_validation_failures.load(Ordering::Relaxed),
pack_fallback_entries: ps.pack_fallback_entries.load(Ordering::Relaxed),
last_plan_wall_ms: ps.last_plan_wall_ms.load(Ordering::Relaxed),
plan_wall_ms_total: ps.plan_wall_ms_total.load(Ordering::Relaxed),
},
in_flight,
effective_config: Some(self.effective_config.clone()),
})
}
pub fn handle_compile_started(&self, req: CompileStartedRequest) -> Response {
if let Ok(mut map) = self.in_flight_compiles.lock() {
prune_in_flight(&mut map);
map.insert(req.pid, req);
}
Response::ok()
}
pub fn handle_compile_finished(&self, req: &CompileFinishedRequest) -> Response {
if let Ok(mut map) = self.in_flight_compiles.lock()
&& let Some(entry) = map.get(&req.pid)
&& (req.started_at_ms == 0 || entry.started_at_ms == req.started_at_ms)
{
map.remove(&req.pid);
}
Response::ok()
}
fn in_flight_snapshot(&self) -> Vec<InFlightEntry> {
let Ok(mut map) = self.in_flight_compiles.lock() else {
return Vec::new();
};
prune_in_flight(&mut map);
let now_ms = unix_ms();
let mut entries: Vec<InFlightEntry> = map
.values()
.map(|c| {
let elapsed_s = now_ms.saturating_sub(c.started_at_ms) / 1000;
let typical_s = c.typical_ms.map(|ms| ms.div_ceil(1000));
InFlightEntry {
crate_name: c.crate_name.clone(),
root: c.root.clone(),
pid: c.pid,
elapsed_s,
typical_s,
eta_s: typical_s.map(|t| t.saturating_sub(elapsed_s)),
}
})
.collect();
entries.sort_by_key(|e| std::cmp::Reverse(e.elapsed_s));
entries
}
pub fn handle_hash_files(&self, req: &HashFilesRequest) -> Response {
let mut results = Vec::with_capacity(req.files.len());
for file in &req.files {
let key = FileHashCacheKey {
path: file.path.clone(),
size: file.size,
mtime_ns: file.mtime_ns,
ctime_ns: file.ctime_ns,
inode: file.inode,
};
if let Ok(cache) = self.file_hash_cache.lock()
&& let Some(hash) = cache.get(&key).cloned()
{
results.push(HashFileResult {
path: file.path.clone(),
size: file.size,
mtime_ns: file.mtime_ns,
ctime_ns: file.ctime_ns,
inode: file.inode,
hash: Some(hash),
cache_hit: true,
bytes_hashed: 0,
error: None,
});
continue;
}
match std::fs::metadata(&file.path) {
Ok(metadata)
if i64::try_from(metadata.len()).unwrap_or(i64::MAX) == file.size
&& crate::cache_key::metadata_mtime_ns(&metadata) == file.mtime_ns
&& crate::cache_key::metadata_ctime_ns(&metadata) == file.ctime_ns
&& crate::cache_key::metadata_inode(&metadata) == file.inode => {}
Ok(_) => {
results.push(HashFileResult {
path: file.path.clone(),
size: file.size,
mtime_ns: file.mtime_ns,
ctime_ns: file.ctime_ns,
inode: file.inode,
hash: None,
cache_hit: false,
bytes_hashed: 0,
error: Some("file metadata changed before hashing".into()),
});
continue;
}
Err(e) => {
results.push(HashFileResult {
path: file.path.clone(),
size: file.size,
mtime_ns: file.mtime_ns,
ctime_ns: file.ctime_ns,
inode: file.inode,
hash: None,
cache_hit: false,
bytes_hashed: 0,
error: Some(e.to_string()),
});
continue;
}
}
let path = Path::new(&file.path);
let computed: anyhow::Result<(String, bool, u64)> =
match self.with_store(|store| Ok(store.file_hash_lookup(path))) {
Ok(crate::cache_key::FileHashLookup::Hit(hash)) => Ok((hash, true, 0)),
Ok(crate::cache_key::FileHashLookup::NeedsHash(fp)) => {
crate::cache_key::hash_file(path).map(|hash| {
let _ = self.with_store(|store| {
store.file_hash_record(&fp, &hash);
Ok(())
});
(hash, false, file.size.max(0) as u64)
})
}
Ok(crate::cache_key::FileHashLookup::Uncacheable) => {
crate::cache_key::hash_file(path)
.map(|hash| (hash, false, file.size.max(0) as u64))
}
Err(e) => Err(e),
};
match computed {
Ok((hash, cache_hit, bytes_hashed)) => {
if let Ok(mut cache) = self.file_hash_cache.lock() {
if cache.len() >= FILE_HASH_MEMORY_CACHE_CAP {
cache.clear();
}
cache.insert(key, hash.clone());
}
results.push(HashFileResult {
path: file.path.clone(),
size: file.size,
mtime_ns: file.mtime_ns,
ctime_ns: file.ctime_ns,
inode: file.inode,
hash: Some(hash),
cache_hit,
bytes_hashed,
error: None,
});
}
Err(e) => results.push(HashFileResult {
path: file.path.clone(),
size: file.size,
mtime_ns: file.mtime_ns,
ctime_ns: file.ctime_ns,
inode: file.inode,
hash: None,
cache_hit: false,
bytes_hashed: 0,
error: Some(e.to_string()),
}),
}
}
Response::ok_hash_results(results)
}
async fn initialize_local_hit_service(&self) -> Result<()> {
self.local_hit
.get_or_try_init(|| async {
let config = self.config.clone();
tokio::task::spawn_blocking(move || {
crate::daemon_local::LocalHitService::new(&config)
})
.await
.context("joining local-hit service initialization")?
})
.await
.map(|_| ())
}
fn start_local_hit_initialization(self: &Arc<Self>) -> tokio::task::JoinHandle<Result<()>> {
let daemon = Arc::clone(self);
tokio::spawn(async move { daemon.initialize_local_hit_service().await })
}
async fn ensure_local_hit_service(
self: &Arc<Self>,
) -> Result<&crate::daemon_local::LocalHitService> {
if let Some(service) = self.local_hit.get() {
return Ok(service);
}
self.start_local_hit_initialization()
.await
.context("joining local-hit initialization task")??;
self.local_hit
.get()
.context("local-hit initialization completed without a service")
}
pub async fn handle_local_lookup(self: &Arc<Self>, req: &LocalLookupRequest) -> Response {
if !crate::cache_key::is_valid_cache_key(&req.key) {
return Response::err("invalid cache key");
}
let started = Instant::now();
let cold_start = self.local_hit.get().is_none();
let deadline = self
.local_lookup_budget
.and_then(|budget| started.checked_add(budget));
let lookup = async {
match self.ensure_local_hit_service().await {
Ok(service) => service.lookup(&req.key, deadline).await,
Err(error) => {
tracing::warn!("local-hit service init failed: {error:#}");
LocalLookupReply::fallback("service initialization failed")
}
}
};
let reply = await_local_lookup(deadline, lookup).await;
if let Some(reason) = reply.reason.as_deref() {
tracing::debug!(
key = key_prefix(&req.key),
reason,
elapsed_ms = started.elapsed().as_millis() as u64,
cold_start,
"daemon local lookup fell back"
);
}
Response::ok_local_lookup(reply)
}
pub fn handle_gc(&self, req: &GcRequest) -> Response {
let policy = match req.resolve(self.config.gc_max_age_hours) {
Ok(policy) => policy,
Err(e) => return Response::err(format!("invalid GC request: {e}")),
};
match self.run_gc(policy) {
Ok(report) if report.total.skipped => Response::ok_gc_skipped(report.breakdown()),
Ok(report) => Response::ok_gc(report.total.entries_evicted, report.breakdown()),
Err(e) => Response::err(format!("gc failed: {e}")),
}
}
pub async fn handle_upload(&self, job: &UploadJob) -> Response {
if !crate::cache_key::is_valid_cache_key(&job.key) {
return Response::err("invalid cache key");
}
if !crate::cache_key::is_valid_crate_name(&job.crate_name) {
return Response::err("invalid crate name");
}
if self.config.remote_readonly {
tracing::debug!(
crate_name = job.crate_name,
key = key_prefix(&job.key),
"remote uploads disabled (read-only mode)"
);
return Response::ok();
}
if self.config.remote.is_none() {
return Response::err("no remote configured");
}
let normalized_job = match persist_upload_job(&self.config, job) {
Ok(job) => job,
Err(error) => {
return Response::err(format!("persisting upload intent failed: {error:#}"));
}
};
if let Some(tx) = self.upload_tx() {
{
let mut pending = self.pending_uploads.write().await;
if !pending.insert(job.key.clone()) {
return Response::ok(); }
}
return match tx.send(normalized_job) {
Ok(()) => Response::ok(),
Err(_) => {
self.pending_uploads.write().await.remove(&job.key);
Response::err("upload queue closed")
}
};
}
if self.upload_queue_closed.load(Ordering::Relaxed) {
return Response::err("upload queue closed");
}
self.do_upload(&normalized_job).await
}
pub async fn do_upload(&self, job: &UploadJob) -> Response {
let key_short = key_prefix(&job.key);
if !crate::cache_key::is_valid_cache_key(&job.key) {
return Response::err("invalid cache key");
}
if !crate::cache_key::is_valid_crate_name(&job.crate_name) {
return Response::err("invalid crate name");
}
if self.config.remote_readonly {
tracing::debug!(
crate_name = job.crate_name,
key = key_short,
"skipping upload (read-only mode)"
);
return Response::ok();
}
let Some(remote) = &self.config.remote else {
return Response::err("no remote configured");
};
let _write_epoch = self
.negative_keys
.begin_write(&job.key)
.expect("validated upload key must admit a knowledge epoch");
let deadline = RemoteDeadline::from_secs(self.config.remote_restore_timeout_secs);
let Some(head_breaker) = self.remote_breaker.try_acquire(RemoteOperation::UploadHead)
else {
self.transfer_counters
.uploads_suppressed
.fetch_add(1, Ordering::Relaxed);
tracing::debug!(
crate_name = job.crate_name,
key = key_short,
"deferring upload — write breaker is degraded"
);
return Response::err("retryable: write breaker open");
};
let backend = match deadline
.run("upload backend initialization", self.get_remote_backend())
.await
{
Ok(b) => b,
Err(e) => {
let class = classify_remote_error(&e);
head_breaker.failure(class, &format!("{e:#}"));
tracing::warn!(
crate_name = job.crate_name,
key = key_short,
"remote backend init failed: {e:#}"
);
return if class.poisons_breaker() {
Response::err(format!("retryable: remote backend init failed: {e:#}"))
} else {
Response::err(format!("remote backend init failed: {e:#}"))
};
}
};
let plan = crate::remote_plan::RemotePlanner::new(&self.config)
.plan(crate::remote_plan::RemoteWorkload::BackgroundUpload);
let layout = plan.layout(backend.as_ref(), remote);
let head_queue_start = Instant::now();
let head_semaphore = match deadline
.run("upload HEAD queue", async {
self.s3_semaphore
.acquire()
.await
.map_err(|_| anyhow::anyhow!("remote semaphore closed"))
})
.await
{
Ok(permit) => permit,
Err(error) => {
let class = classify_remote_error(&error);
head_breaker.failure(class, &format!("{error:#}"));
return Response::err("retryable: upload HEAD queue deadline");
}
};
let already_exists = deadline
.run(
"upload HEAD",
layout.exists_entry(&job.key, &job.crate_name),
)
.await;
drop(head_semaphore);
let _head_queue_ms = head_queue_start.elapsed().as_millis() as u64;
let already_exists = match already_exists {
Ok(exists) => exists,
Err(e) => {
let class = classify_remote_error(&e);
head_breaker.failure(
class,
&format!("upload exists check failed ({class:?}): {e:#}"),
);
return if class.poisons_breaker() {
Response::err(format!("retryable: upload HEAD failed: {e:#}"))
} else {
Response::err(format!("upload HEAD failed: {e:#}"))
};
}
};
head_breaker.success();
if already_exists {
self.note_key_present(&job.key, &job.crate_name).await;
if let Err(error) = remove_upload_job(&self.config, &job.key) {
tracing::warn!("failed to retire completed upload intent: {error:#}");
}
self.transfer_counters
.uploads_skipped
.fetch_add(1, Ordering::Relaxed);
tracing::debug!(
crate_name = job.crate_name,
key = key_short,
"skipping upload — already in remote"
);
return Response::ok();
}
tracing::debug!(
crate_name = job.crate_name,
key = key_short,
remote = %remote.describe(),
"starting remote upload"
);
let entry_dir = PathBuf::from(&job.entry_dir);
let blobs_dir = self.config.store_dir().join("blobs");
let started_at_unix_ms = unix_time_ms();
let start = Instant::now();
let Some(put_breaker) = self.remote_breaker.try_acquire(RemoteOperation::UploadPut) else {
self.transfer_counters
.uploads_suppressed
.fetch_add(1, Ordering::Relaxed);
return Response::err("retryable: write breaker open before PUT");
};
let put_semaphore = match deadline
.run("upload PUT queue", async {
self.s3_semaphore
.acquire()
.await
.map_err(|_| anyhow::anyhow!("remote semaphore closed"))
})
.await
{
Ok(permit) => permit,
Err(error) => {
let class = classify_remote_error(&error);
put_breaker.failure(class, &format!("{error:#}"));
return Response::err("retryable: upload PUT queue deadline");
}
};
let upload_result = deadline
.run(
"upload PUT",
layout.upload_entry_until(
&job.key,
&job.crate_name,
&entry_dir,
&blobs_dir,
self.config.compression_level,
deadline.at(),
),
)
.await;
drop(put_semaphore);
match upload_result {
Ok(ul) => {
put_breaker.success();
let elapsed_ms = start.elapsed().as_millis() as u64;
let finished_at_unix_ms = unix_time_ms();
self.transfer_counters
.uploads_completed
.fetch_add(1, Ordering::Relaxed);
self.transfer_counters
.bytes_uploaded
.fetch_add(ul.transfer.compressed_bytes, Ordering::Relaxed);
self.push_transfer_event(TransferEvent {
schema: default_transfer_schema(),
crate_name: job.crate_name.clone(),
direction: TransferDirection::Upload,
format: ul.format.to_string(),
cache_key: job.key.clone(),
object_key: String::new(),
compressed_bytes: ul.transfer.compressed_bytes,
started_at_unix_ms,
finished_at_unix_ms,
elapsed_ms,
network_ms: ul.transfer.network_ms,
semaphore_wait_ms: 0,
head_ms: 0,
request_ms: 0,
body_ms: 0,
request_count: 0,
original_bytes: 0,
decompress_ms: 0,
extract_ms: 0,
disk_io_ms: 0,
import_lock_wait_ms: 0,
import_ms: 0,
compression_ms: ul.transfer.compression_ms,
head_checks_ms: ul.transfer.head_checks_ms,
blobs_skipped: 0,
blobs_total: 0,
ok: true,
timestamp: finished_at_unix_ms / 1_000,
});
self.note_key_present(&job.key, &job.crate_name).await;
if let Err(error) = remove_upload_job(&self.config, &job.key) {
tracing::warn!("failed to retire completed upload intent: {error:#}");
}
self.maybe_evict_after_upload();
Response::ok()
}
Err(e) => {
let elapsed_ms = start.elapsed().as_millis() as u64;
let finished_at_unix_ms = unix_time_ms();
self.transfer_counters
.uploads_failed
.fetch_add(1, Ordering::Relaxed);
self.push_transfer_event(TransferEvent {
schema: default_transfer_schema(),
crate_name: job.crate_name.clone(),
direction: TransferDirection::Upload,
format: plan.transfer_format().to_string(),
cache_key: job.key.clone(),
object_key: String::new(),
compressed_bytes: 0,
started_at_unix_ms,
finished_at_unix_ms,
elapsed_ms,
network_ms: 0,
semaphore_wait_ms: 0,
head_ms: 0,
request_ms: 0,
body_ms: 0,
request_count: 0,
original_bytes: 0,
decompress_ms: 0,
extract_ms: 0,
disk_io_ms: 0,
import_lock_wait_ms: 0,
import_ms: 0,
compression_ms: 0,
head_checks_ms: 0,
blobs_skipped: 0,
blobs_total: 0,
ok: false,
timestamp: finished_at_unix_ms / 1_000,
});
let class = classify_remote_error(&e);
put_breaker.failure(class, &format!("remote upload failed ({class:?}): {e:#}"));
tracing::warn!(
crate_name = job.crate_name,
key = key_short,
elapsed_ms,
"remote upload failed: {e:#}"
);
if class.poisons_breaker() {
Response::err(format!("retryable: upload failed: {e:#}"))
} else {
Response::err(format!("upload failed: {e:#}"))
}
}
}
}
async fn note_key_present(&self, key: &str, crate_name: &str) {
self.negative_keys.confirm_present(key);
self.key_cache
.insert(key.to_string(), Some(crate_name))
.await;
}
#[cfg(test)]
pub async fn handle_remote_check(&self, req: &RemoteCheckRequest) -> Response {
self.handle_remote_check_started_at(req, Instant::now())
.await
}
async fn handle_remote_check_started_at(
&self,
req: &RemoteCheckRequest,
request_started_at: Instant,
) -> Response {
if !crate::cache_key::is_valid_cache_key(&req.key) {
return Response::err("invalid cache key");
}
if !crate::cache_key::is_valid_crate_name(&req.crate_name) {
return Response::err("invalid crate name");
}
let expected_entry_dir = self.entry_dir_for(&req.key);
if Path::new(&req.entry_dir) != expected_entry_dir {
return Response::err("remote-check entry directory does not match daemon store");
}
let deadline = RemoteDeadline::from_millis_at(
request_started_at,
remote_check_budget_ms(self.config.remote_restore_timeout_secs, req.deadline_ms).get(),
);
match self.remote_checks.claim(&req.key) {
SingleflightClaim::Follower(follower) => follower
.wait(deadline)
.await
.unwrap_or_else(|| Response::found(false)),
SingleflightClaim::AtCapacity => {
tracing::warn!(
key = key_prefix(&req.key),
max = REMOTE_CHECK_SINGLEFLIGHT_MAX_KEYS,
"remote-check singleflight at capacity; treating as miss"
);
Response::found(false)
}
SingleflightClaim::Leader(leader) => {
let response = self.handle_remote_check_leader(req, deadline).await;
leader.complete(response.clone());
response
}
}
}
async fn handle_remote_check_leader(
&self,
req: &RemoteCheckRequest,
deadline: RemoteDeadline,
) -> Response {
let Some(remote) = &self.config.remote else {
return Response::err("no remote configured");
};
let warmed = deadline
.run("warming barrier", async {
Ok(self.wait_for_warming(REMOTE_CHECK_WARMING_GRACE).await)
})
.await
.unwrap_or(false);
tracing::debug!(
warmed,
grace_ms = REMOTE_CHECK_WARMING_GRACE.as_millis(),
"remote check warming barrier completed"
);
{
let is_prefetched = self.prefetched_keys.read().await.contains(&req.key);
if is_prefetched {
if self
.prefetch_used_keys
.write()
.await
.insert(req.key.clone())
{
self.prefetch_stats
.keys_used
.fetch_add(1, Ordering::Relaxed);
}
}
let fire_cancel = {
let mut plan = self.active_plan.lock().unwrap_or_else(|p| p.into_inner());
match plan.as_mut() {
Some(p) => p.record_demand(&req.key),
None => false,
}
};
if fire_cancel {
let _ = self.prefetch_cancel.send(true);
let (demanded, hits) = {
let plan = self.active_plan.lock().unwrap_or_else(|p| p.into_inner());
plan.as_ref()
.map(|p| (p.demanded.len(), p.demanded_candidates.len()))
.unwrap_or((0, 0))
};
tracing::info!(
"adaptive prefetch cancel: {hits}/{demanded} demanded keys were plan candidates, cancelling remaining downloads"
);
}
}
if deadline.check("demand preparation").is_err() {
return Response::found(false);
}
let cn = &req.crate_name;
let mut needs_head_probe = false;
let mut head_ms = 0u64;
let mut semaphore_wait_ms = 0u64;
if self.negative_keys.check(&req.key) {
tracing::debug!(
"negative cache: {} definitively missed recently, skipping remote",
&req.key
);
return Response::found(false);
}
let knowledge = self
.negative_keys
.begin_observation(&req.key)
.expect("validated remote-check key must admit a knowledge epoch");
match self.key_cache.check(&req.key).await {
Some(false) => {
let authoritative = key_cache_miss_is_authoritative(
self.config.remote_key_cache_refresh_secs,
self.key_cache.age().await,
);
if authoritative {
tracing::debug!("key cache: {} not found (skipping remote)", &req.key);
return Response::found(false);
}
tracing::debug!(
"key cache: {} not found but cache is stale, falling through to HEAD",
&req.key
);
needs_head_probe = true;
}
Some(true) => {
tracing::debug!("key cache: {} found, skipping HEAD", &req.key);
}
None => {
needs_head_probe = true;
}
}
let backend = match deadline
.run("demand backend initialization", self.get_remote_backend())
.await
{
Ok(b) => b,
Err(e) => {
let class = classify_remote_error(&e);
return if class.poisons_breaker() {
Response::found(false)
} else {
Response::err(format!("remote backend init failed: {e}"))
};
}
};
let plan = crate::remote_plan::RemotePlanner::new(&self.config)
.plan(crate::remote_plan::RemoteWorkload::RestoreCheck);
let layout = plan.layout(backend.as_ref(), remote);
if needs_head_probe {
let Some(breaker_permit) = self.remote_breaker.try_acquire(RemoteOperation::DemandHead)
else {
self.transfer_counters
.downloads_suppressed
.fetch_add(1, Ordering::Relaxed);
return Response::found(false);
};
let semaphore_start = Instant::now();
let semaphore_permit = match deadline
.run("demand HEAD queue", async {
self.s3_semaphore
.acquire()
.await
.map_err(|_| anyhow::anyhow!("remote semaphore closed"))
})
.await
{
Ok(permit) => permit,
Err(error) => {
let class = classify_remote_error(&error);
breaker_permit.failure(class, &format!("{error:#}"));
return Response::found(false);
}
};
semaphore_wait_ms =
semaphore_wait_ms.saturating_add(semaphore_start.elapsed().as_millis() as u64);
let head_start = Instant::now();
let exists = deadline
.run("demand HEAD", layout.exists_entry(&req.key, cn))
.await;
head_ms += head_start.elapsed().as_millis() as u64;
drop(semaphore_permit);
self.transfer_counters
.remote_check_roundtrips
.fetch_add(1, Ordering::Relaxed);
match exists {
Ok(false) => {
breaker_permit.success();
self.negative_keys.record_miss(&knowledge);
return Response::found(false);
}
Ok(true) => {
breaker_permit.success();
if self.negative_keys.record_present(&knowledge) {
self.key_cache
.insert(req.key.clone(), Some(cn.as_str()))
.await;
}
}
Err(e) => {
let class = classify_remote_error(&e);
let error = format!("remote exists check failed ({class:?}): {e:#}");
breaker_permit.failure(class, &error);
return Response::found(false);
}
}
}
let mut reclaimed = false;
if let Some(notify) = claim_download(&self.downloading, &req.key).await {
tracing::debug!("already downloading {}, waiting for completion", &req.key);
let join_start = Instant::now();
let join_deadline = download_join_deadline(
tokio::time::Instant::now(),
deadline.at().map(tokio::time::Instant::from_std),
);
let entry_dir = self.entry_dir_for(&req.key);
let outcome = join_inflight_download(
&self.downloading,
&req.key,
&entry_dir,
notify,
join_deadline,
)
.await;
self.prefetch_stats
.dedup_join_waits
.fetch_add(1, Ordering::Relaxed);
self.prefetch_stats
.dedup_join_wait_ms
.fetch_add(join_start.elapsed().as_millis() as u64, Ordering::Relaxed);
match outcome {
JoinOutcome::Found => {
let was_prefetched = self.prefetched_keys.read().await.contains(&req.key);
return Response::found_prefetched(true, was_prefetched);
}
JoinOutcome::Reclaimed => reclaimed = true,
JoinOutcome::GaveUp => {
return Response::found(false);
}
}
}
let _dl_guard = DownloadingGuard::new(self.downloading.clone(), req.key.clone());
if reclaimed && self.entry_dir_for(&req.key).join("meta.json").exists() {
let was_prefetched = self.prefetched_keys.read().await.contains(&req.key);
return Response::found_prefetched(true, was_prefetched);
}
let Some(breaker_permit) = self.remote_breaker.try_acquire(RemoteOperation::DemandGet)
else {
self.transfer_counters
.downloads_suppressed
.fetch_add(1, Ordering::Relaxed);
tracing::debug!(
"remote degraded before downloading {}, treating as miss",
&req.key
);
return Response::found(false);
};
let semaphore_start = Instant::now();
let semaphore_permit = match deadline
.run("demand GET queue", async {
self.s3_semaphore
.acquire()
.await
.map_err(|_| anyhow::anyhow!("remote semaphore closed"))
})
.await
{
Ok(permit) => permit,
Err(error) => {
let class = classify_remote_error(&error);
breaker_permit.failure(class, &format!("{error:#}"));
return Response::found(false);
}
};
semaphore_wait_ms =
semaphore_wait_ms.saturating_add(semaphore_start.elapsed().as_millis() as u64);
let entry_dir = self.entry_dir_for(&req.key);
let blobs_dir = self.config.store_dir().join("blobs");
let started_at_unix_ms = unix_time_ms();
let start = Instant::now();
self.transfer_counters
.remote_check_roundtrips
.fetch_add(1, Ordering::Relaxed);
let download_result = deadline
.run(
"demand GET and extraction",
layout.download_entry_until(&req.key, cn, &entry_dir, &blobs_dir, deadline.at()),
)
.await;
drop(semaphore_permit);
match download_result {
Ok(dl) => {
breaker_permit.success();
if self.negative_keys.record_present(&knowledge) {
self.key_cache
.insert(req.key.clone(), Some(cn.as_str()))
.await;
}
let (import_result, import_lock_wait_ms, import_ms) =
self.with_store_timed(|store| store.import_restored_entry(&req.key));
if let Err(e) = import_result {
tracing::warn!("failed to import downloaded entry {}: {e}", &req.key);
}
let elapsed_ms = start.elapsed().as_millis() as u64;
let finished_at_unix_ms = unix_time_ms();
self.transfer_counters
.downloads_completed
.fetch_add(1, Ordering::Relaxed);
self.transfer_counters
.bytes_downloaded
.fetch_add(dl.compressed_bytes, Ordering::Relaxed);
self.push_transfer_event(TransferEvent {
schema: default_transfer_schema(),
crate_name: cn.to_string(),
direction: TransferDirection::Download,
format: dl.format.to_string(),
cache_key: req.key.clone(),
object_key: dl.object_key,
compressed_bytes: dl.compressed_bytes,
started_at_unix_ms,
finished_at_unix_ms,
elapsed_ms,
network_ms: dl.network_ms,
semaphore_wait_ms,
head_ms,
request_ms: dl.request_ms,
body_ms: dl.body_ms,
request_count: dl.request_count,
original_bytes: dl.original_bytes,
decompress_ms: dl.decompress_ms,
extract_ms: dl.extract_ms,
disk_io_ms: dl.disk_io_ms,
import_lock_wait_ms,
import_ms,
compression_ms: 0,
head_checks_ms: 0,
blobs_skipped: dl.blobs_skipped,
blobs_total: dl.blobs_total,
ok: true,
timestamp: finished_at_unix_ms / 1_000,
});
Response::found(true)
}
Err(e) if classify_remote_error(&e) == RemoteErrorClass::Miss => {
tracing::debug!("remote GET 404 for {} — treating as miss", &req.key);
breaker_permit.success();
if self.negative_keys.record_miss(&knowledge) {
self.key_cache.remove(&req.key).await;
}
Response::found(false)
}
Err(e) => {
let elapsed_ms = start.elapsed().as_millis() as u64;
let finished_at_unix_ms = unix_time_ms();
self.transfer_counters
.downloads_failed
.fetch_add(1, Ordering::Relaxed);
self.push_transfer_event(TransferEvent {
schema: default_transfer_schema(),
crate_name: cn.to_string(),
direction: TransferDirection::Download,
format: plan.transfer_format().to_string(),
cache_key: req.key.clone(),
object_key: String::new(),
compressed_bytes: 0,
started_at_unix_ms,
finished_at_unix_ms,
elapsed_ms,
network_ms: 0,
semaphore_wait_ms,
head_ms,
request_ms: 0,
body_ms: 0,
request_count: 0,
original_bytes: 0,
decompress_ms: 0,
extract_ms: 0,
disk_io_ms: 0,
import_lock_wait_ms: 0,
import_ms: 0,
compression_ms: 0,
head_checks_ms: 0,
blobs_skipped: 0,
blobs_total: 0,
ok: false,
timestamp: finished_at_unix_ms / 1_000,
});
let class = classify_remote_error(&e);
breaker_permit
.failure(class, &format!("remote download failed ({class:?}): {e:#}"));
if matches!(
class,
RemoteErrorClass::Timeout | RemoteErrorClass::Transient
) {
tracing::warn!(
"remote download of {} failed after {elapsed_ms}ms — treating as miss",
&req.key
);
return Response::found(false);
}
Response::err(format!("remote download failed: {e}"))
}
}
}
#[cfg(test)]
pub async fn handle_batch_remote_check(
self: &Arc<Self>,
req: &BatchRemoteCheckRequest,
) -> Response {
self.handle_batch_remote_check_started_at(req, Instant::now())
.await
}
async fn handle_batch_remote_check_started_at(
self: &Arc<Self>,
req: &BatchRemoteCheckRequest,
request_started_at: Instant,
) -> Response {
let futures: Vec<_> = req
.checks
.iter()
.map(|check| self.handle_remote_check_started_at(check, request_started_at))
.collect();
let results = futures::future::join_all(futures).await;
Response::ok_batch(results)
}
async fn packed_prefetch_list(
&self,
backend: &dyn crate::remote_backend::RemoteBackend,
prefix: &str,
) -> Result<Vec<String>> {
let breaker = self
.remote_breaker
.try_acquire(RemoteOperation::PrefetchGet)
.ok_or_else(|| anyhow::anyhow!("remote read breaker open"))?;
let deadline = RemoteDeadline::from_secs(self.config.remote_restore_timeout_secs);
let gate = deadline
.run("pack catalog gate", async {
self.prefetch_gate
.clone()
.acquire_owned()
.await
.map_err(|_| anyhow::anyhow!("prefetch gate closed"))
})
.await?;
let semaphore = deadline
.run("pack catalog LIST queue", async {
self.s3_semaphore
.acquire()
.await
.map_err(|_| anyhow::anyhow!("remote semaphore closed"))
})
.await?;
self.prefetch_stats
.pack_requests_total
.fetch_add(1, Ordering::Relaxed);
let result = deadline
.run("pack catalog LIST", backend.list(prefix))
.await;
drop(semaphore);
drop(gate);
match result {
Ok(objects) => {
breaker.success();
Ok(objects)
}
Err(error) => {
let class = classify_remote_error(&error);
breaker.failure(class, &format!("packed-prefetch LIST failed: {error:#}"));
Err(error)
}
}
}
async fn packed_prefetch_get(
&self,
backend: &dyn crate::remote_backend::RemoteBackend,
key: &str,
max_bytes: u64,
stage: &'static str,
) -> Result<Option<crate::remote_backend::GetObject>> {
let breaker = self
.remote_breaker
.try_acquire(RemoteOperation::PrefetchGet)
.ok_or_else(|| anyhow::anyhow!("remote read breaker open"))?;
let deadline = RemoteDeadline::from_secs(self.config.remote_restore_timeout_secs);
let gate = deadline
.run("packed-prefetch gate", async {
self.prefetch_gate
.clone()
.acquire_owned()
.await
.map_err(|_| anyhow::anyhow!("prefetch gate closed"))
})
.await?;
let semaphore = deadline
.run("packed-prefetch GET queue", async {
self.s3_semaphore
.acquire()
.await
.map_err(|_| anyhow::anyhow!("remote semaphore closed"))
})
.await?;
self.prefetch_stats
.pack_requests_total
.fetch_add(1, Ordering::Relaxed);
let result = deadline.run(stage, backend.get(key, Some(max_bytes))).await;
drop(semaphore);
drop(gate);
match result {
Ok(object) => {
breaker.success();
if let Some(object) = &object {
self.prefetch_stats
.pack_bytes_downloaded
.fetch_add(object.body.len() as u64, Ordering::Relaxed);
}
Ok(object)
}
Err(error) => {
let class = classify_remote_error(&error);
breaker.failure(class, &format!("{stage} failed: {error:#}"));
Err(error)
}
}
}
async fn try_packed_prefetch(
self: &Arc<Self>,
context: &PackPrefetchContext,
backend: &Arc<dyn crate::remote_backend::RemoteBackend>,
remote: &crate::config::RemoteConfig,
candidates: &[(String, String, PathBuf)],
bytes_at_plan_start: u64,
) -> HashSet<String> {
let wanted = candidates
.iter()
.map(|(key, _, _)| key.clone())
.collect::<HashSet<_>>();
let mut imported = HashSet::new();
let catalog_prefix =
match crate::remote_pack::catalog_prefix(&remote.prefix, &context.selector) {
Ok(prefix) => prefix,
Err(error) => {
tracing::warn!("packed-prefetch selector rejected: {error:#}");
return imported;
}
};
let objects = match self
.packed_prefetch_list(backend.as_ref(), &catalog_prefix)
.await
{
Ok(objects) => objects,
Err(error) => {
tracing::debug!("packed-prefetch catalog discovery failed: {error:#}");
self.prefetch_stats
.pack_fallback_entries
.fetch_add(wanted.len() as u64, Ordering::Relaxed);
return imported;
}
};
let catalog_ref = match crate::remote_pack::latest_catalog_object(
&remote.prefix,
&context.selector,
&objects,
) {
Ok(Some(catalog)) => catalog,
Ok(None) => {
self.prefetch_stats
.pack_fallback_entries
.fetch_add(wanted.len() as u64, Ordering::Relaxed);
return imported;
}
Err(error) => {
tracing::warn!("packed-prefetch catalog key rejected: {error:#}");
self.prefetch_stats
.pack_validation_failures
.fetch_add(1, Ordering::Relaxed);
self.prefetch_stats
.pack_fallback_entries
.fetch_add(wanted.len() as u64, Ordering::Relaxed);
return imported;
}
};
let Some(catalog_object) = self
.packed_prefetch_get(
backend.as_ref(),
&catalog_ref.object_key,
crate::remote_pack::MAX_CATALOG_BYTES as u64,
"packed-prefetch catalog GET",
)
.await
.ok()
.flatten()
else {
self.prefetch_stats
.pack_fallback_entries
.fetch_add(wanted.len() as u64, Ordering::Relaxed);
return imported;
};
let now_ms = epoch_ms();
let catalog = match crate::remote_pack::decode_catalog_for_selector(
&catalog_object.body,
&catalog_ref.digest,
&context.selector,
now_ms,
) {
Ok(catalog)
if catalog.created_at_ms == catalog_ref.created_at_ms
&& catalog.manifest_key == context.manifest_key
&& catalog.namespace == context.namespace
&& catalog.shard_hashes == context.shard_hashes =>
{
catalog
}
Ok(_) => {
tracing::warn!("packed-prefetch catalog context binding mismatch");
self.prefetch_stats
.pack_validation_failures
.fetch_add(1, Ordering::Relaxed);
self.prefetch_stats
.pack_fallback_entries
.fetch_add(wanted.len() as u64, Ordering::Relaxed);
return imported;
}
Err(error) => {
tracing::warn!("packed-prefetch catalog validation failed: {error:#}");
self.prefetch_stats
.pack_validation_failures
.fetch_add(1, Ordering::Relaxed);
self.prefetch_stats
.pack_fallback_entries
.fetch_add(wanted.len() as u64, Ordering::Relaxed);
return imported;
}
};
let selected = catalog
.packs
.iter()
.filter(|pack| {
pack.entries
.iter()
.any(|entry| wanted.contains(&entry.cache_key))
})
.collect::<Vec<_>>();
let already_spent = self
.prefetch_stats
.bytes_downloaded
.load(Ordering::Relaxed)
.saturating_sub(bytes_at_plan_start);
let mut reserved = 0u64;
let admitted = selected
.into_iter()
.filter(|pack| {
if self.config.prefetch_max_bytes == 0 {
return true;
}
let fits = pack.pack_bytes
<= self
.config
.prefetch_max_bytes
.saturating_sub(already_spent.saturating_add(reserved));
if fits {
reserved = reserved.saturating_add(pack.pack_bytes);
}
fits
})
.cloned()
.collect::<Vec<_>>();
use futures::StreamExt as _;
let mut fetched = futures::stream::iter(admitted)
.map(|pack_ref| async move {
let pack_key =
crate::remote_pack::pack_object_key(&remote.prefix, &pack_ref.digest);
let object = match pack_key {
Ok(key) => self
.packed_prefetch_get(
backend.as_ref(),
&key,
crate::remote_pack::DEFAULT_MAX_PACK_BYTES,
"packed-prefetch pack GET",
)
.await
.ok()
.flatten(),
Err(error) => {
tracing::warn!("packed-prefetch pack key rejected: {error:#}");
self.prefetch_stats
.pack_validation_failures
.fetch_add(1, Ordering::Relaxed);
None
}
};
(pack_ref, object)
})
.buffer_unordered(prefetch_concurrency_cap(self.config.s3_concurrency))
.collect::<Vec<_>>()
.await;
fetched.sort_by(|(left, _), (right, _)| left.digest.cmp(&right.digest));
let mut verified = Vec::new();
for (pack_ref, pack_object) in fetched {
let Some(pack_object) = pack_object else {
continue;
};
let decoded = match crate::remote_pack::decode_catalog_pack(
&pack_object.body,
&pack_ref,
crate::remote_pack::DEFAULT_MAX_PACK_BYTES,
) {
Ok(decoded) => decoded,
Err(error) => {
tracing::warn!("packed-prefetch pack validation failed: {error:#}");
self.prefetch_stats
.pack_validation_failures
.fetch_add(1, Ordering::Relaxed);
continue;
}
};
self.transfer_counters
.downloads_completed
.fetch_add(1, Ordering::Relaxed);
self.transfer_counters
.bytes_downloaded
.fetch_add(pack_object.body.len() as u64, Ordering::Relaxed);
self.prefetch_stats
.bytes_downloaded
.fetch_add(pack_object.body.len() as u64, Ordering::Relaxed);
for entry in decoded.entries {
let key = &entry.descriptor.cache_key;
let entry_dir = self.entry_dir_for(key);
if !try_claim_packed_download(&self.downloading, key, &entry_dir).await {
continue;
}
let guard = DownloadingGuard::new(self.downloading.clone(), key.clone());
match crate::remote_layout::extract_verified_prefetch_entry(
key,
&entry.descriptor.crate_name,
&entry.descriptor.meta_digest,
entry.payload,
&entry_dir,
None,
) {
Ok(extracted) => verified.push((extracted, guard, entry.payload.len() as u64)),
Err(error) => {
tracing::warn!(
key = key_prefix(key),
"packed-prefetch entry validation failed: {error:#}"
);
self.prefetch_stats
.pack_validation_failures
.fetch_add(1, Ordering::Relaxed);
}
}
}
}
let batch = verified
.iter()
.map(|(entry, _, _)| entry.restored.clone())
.collect::<Vec<_>>();
if !batch.is_empty() {
let import_start = Instant::now();
match self.with_store(|store| store.import_verified_restored_entries(&batch)) {
Ok(_) => {
let original_bytes = verified
.iter()
.map(|(entry, _, _)| entry.original_bytes)
.sum::<u64>();
let extract_ms = verified
.iter()
.map(|(entry, _, _)| entry.extract_ms)
.sum::<u64>();
for (entry, _, payload_bytes) in &verified {
let key = &entry.restored.cache_key;
imported.insert(key.clone());
self.note_key_present(key, &entry.restored.meta.crate_name)
.await;
{
let mut plan =
self.active_plan.lock().unwrap_or_else(|p| p.into_inner());
if let Some(plan) = plan.as_mut() {
plan.record_download(key, *payload_bytes);
}
}
}
self.prefetch_stats
.downloads_completed
.fetch_add(batch.len() as u64, Ordering::Relaxed);
tracing::info!(
entries = batch.len(),
original_bytes,
extract_ms,
import_ms = import_start.elapsed().as_millis() as u64,
"packed-prefetch batch imported"
);
}
Err(error) => {
tracing::warn!("packed-prefetch batch import failed: {error:#}");
self.prefetch_stats
.pack_validation_failures
.fetch_add(1, Ordering::Relaxed);
for (entry, _, _) in &verified {
let _ =
std::fs::remove_dir_all(self.entry_dir_for(&entry.restored.cache_key));
}
}
}
}
drop(verified);
if !imported.is_empty() {
const MAX_PREFETCHED_KEYS: usize = 50_000;
let mut prefetched = self.prefetched_keys.write().await;
if prefetched.len().saturating_add(imported.len()) >= MAX_PREFETCHED_KEYS {
prefetched.clear();
self.prefetch_used_keys.write().await.clear();
}
prefetched.extend(imported.iter().cloned());
}
self.prefetch_stats.pack_fallback_entries.fetch_add(
wanted.difference(&imported).count() as u64,
Ordering::Relaxed,
);
imported
}
pub async fn handle_prefetch(self: &Arc<Self>, req: &PrefetchRequest) -> Response {
self.handle_prefetch_with_context(req, None, Instant::now())
.await
}
async fn handle_prefetch_with_context(
self: &Arc<Self>,
req: &PrefetchRequest,
pack_context: Option<PackPrefetchContext>,
plan_started_at: Instant,
) -> Response {
if !self.config.prefetch_enabled {
tracing::debug!("prefetch request ignored: speculative prefetch disabled");
return Response::ok();
}
let Some(remote) = &self.config.remote else {
return Response::err("no remote configured");
};
let init_deadline = RemoteDeadline::from_secs(self.config.remote_restore_timeout_secs);
let backend = match init_deadline
.run("prefetch backend initialization", self.get_remote_backend())
.await
{
Ok(backend) => backend,
Err(error) => return Response::err(format!("remote backend init failed: {error:#}")),
};
let backend = Arc::clone(backend);
let bytes_at_plan_start = self.prefetch_stats.bytes_downloaded.load(Ordering::Relaxed);
let mut keys_to_fetch: Vec<(String, String, PathBuf)> = Vec::new();
let downloading_guard = self.downloading.read().await;
for (key, crate_name) in &req.keys {
if !crate::cache_key::is_valid_cache_key(key)
|| !crate::cache_key::is_valid_crate_name(crate_name)
{
tracing::warn!(
key = key_prefix(key),
"prefetch: skipping request key with invalid cache_key/crate_name"
);
continue;
}
let entry_dir = self.entry_dir_for(key);
if entry_dir.exists() {
continue;
}
if downloading_guard.contains_key(key) {
continue;
}
keys_to_fetch.push((key.clone(), crate_name.clone(), entry_dir));
}
drop(downloading_guard);
if req.warm_all {
let deadline = RemoteDeadline::from_secs(self.config.remote_restore_timeout_secs);
let s3_keys = if let Some(breaker) = self
.remote_breaker
.try_acquire(RemoteOperation::WarmAllList)
{
let result = match deadline
.run("warm-all LIST queue", async {
self.s3_semaphore
.acquire()
.await
.map_err(|_| anyhow::anyhow!("remote semaphore closed"))
})
.await
{
Ok(semaphore) => {
let result = deadline
.run(
"warm-all LIST",
crate::remote_plan::RemotePlanner::new(&self.config)
.plan(crate::remote_plan::RemoteWorkload::KeyDiscovery)
.layout(backend.as_ref(), remote)
.list_keys(),
)
.await;
drop(semaphore);
result
}
Err(error) => Err(error),
};
match result {
Ok(keys) => {
breaker.success();
Some(keys)
}
Err(error) => {
let class = classify_remote_error(&error);
breaker.failure(class, &format!("{error:#}"));
None
}
}
} else {
None
};
for (key, crate_name) in s3_keys.unwrap_or_default() {
if !crate::cache_key::is_valid_cache_key(&key)
|| !crate::cache_key::is_valid_crate_name(&crate_name)
{
tracing::warn!(
key = key_prefix(&key),
"prefetch: skipping listing key with invalid cache_key/crate_name"
);
continue;
}
let entry_dir = self.entry_dir_for(&key);
if !entry_dir.exists() {
keys_to_fetch.push((key, crate_name, entry_dir));
}
}
}
let offered = keys_to_fetch.len();
let dropped_over_key_budget =
prefetch_key_budget_overflow(offered, self.config.prefetch_max_keys);
if dropped_over_key_budget > 0 {
keys_to_fetch.truncate(offered - dropped_over_key_budget);
}
let count = keys_to_fetch.len();
if count == 0 {
tracing::info!("prefetch: nothing to fetch");
return Response::ok();
}
if dropped_over_key_budget > 0 {
self.prefetch_stats
.keys_over_budget
.fetch_add(dropped_over_key_budget as u64, Ordering::Relaxed);
tracing::warn!(
offered,
admitted = count,
dropped = dropped_over_key_budget,
max_keys = self.config.prefetch_max_keys,
"prefetch: plan truncated by the key budget"
);
}
let daemon = Arc::clone(self);
let remote_config = (*remote).clone();
let cancel_rx = self.prefetch_cancel.subscribe();
tokio::spawn(async move {
if let Some(context) = pack_context.as_ref() {
let packed = daemon
.try_packed_prefetch(
context,
&backend,
&remote_config,
&keys_to_fetch,
bytes_at_plan_start,
)
.await;
keys_to_fetch.retain(|(key, _, _)| !packed.contains(key));
}
let mut in_flight = futures::stream::FuturesUnordered::new();
let max_concurrent = prefetch_concurrency_cap(daemon.config.s3_concurrency);
let byte_budget = daemon.config.prefetch_max_bytes;
let bytes_at_start = bytes_at_plan_start;
let deadline = match daemon.config.prefetch_deadline_secs {
0 => None,
secs => Some(Instant::now() + Duration::from_secs(secs)),
};
let mut keys_iter = keys_to_fetch.into_iter().peekable();
while let Some((key, crate_name, entry_dir)) = keys_iter.next() {
if let Some(deadline) = deadline
&& Instant::now() >= deadline
{
let dropped = 1 + keys_iter.count() as u64;
daemon
.prefetch_stats
.keys_over_budget
.fetch_add(dropped, Ordering::Relaxed);
tracing::warn!(
dropped,
deadline_secs = daemon.config.prefetch_deadline_secs,
"prefetch: plan truncated by the time budget"
);
break;
}
{
let spent = daemon
.prefetch_stats
.bytes_downloaded
.load(Ordering::Relaxed)
.saturating_sub(bytes_at_start);
if prefetch_byte_budget_exhausted(byte_budget, spent) {
let dropped = 1 + keys_iter.count() as u64;
daemon
.prefetch_stats
.keys_over_budget
.fetch_add(dropped, Ordering::Relaxed);
tracing::warn!(
dropped,
spent_bytes = spent,
max_bytes = byte_budget,
in_flight = in_flight.len(),
"prefetch: plan truncated by the byte budget (soft: in-flight \
downloads still finish)"
);
break;
}
}
if *cancel_rx.borrow() {
tracing::info!("prefetch: cancelled by adaptive hit-rate check");
let cancelled = 1 + keys_iter.count() as u64;
daemon
.prefetch_stats
.keys_cancelled
.fetch_add(cancelled, Ordering::Relaxed);
break;
}
while in_flight.len() >= max_concurrent {
use futures::StreamExt;
in_flight.next().await;
}
let sem = daemon.s3_semaphore.clone();
let d = daemon.clone();
let remote_cfg = remote_config.clone();
let remote_backend = backend.clone();
let download_plan = crate::remote_plan::RemotePlanner::new(&d.config)
.plan(crate::remote_plan::RemoteWorkload::Prefetch);
let plan_deadline = deadline;
in_flight.push(tokio::spawn(async move {
let item_deadline =
RemoteDeadline::from_secs(d.config.remote_restore_timeout_secs)
.min(RemoteDeadline::from_instant(plan_deadline));
if entry_dir.exists() {
return;
}
let knowledge = d
.negative_keys
.begin_observation(&key)
.expect("validated prefetch key must admit a knowledge epoch");
let Some(breaker_permit) =
d.remote_breaker.try_acquire(RemoteOperation::PrefetchGet)
else {
d.transfer_counters
.downloads_suppressed
.fetch_add(1, Ordering::Relaxed);
return;
};
let gate = match item_deadline
.run("prefetch gate queue", async {
d.prefetch_gate
.clone()
.acquire_owned()
.await
.map_err(|_| anyhow::anyhow!("prefetch gate closed"))
})
.await
{
Ok(permit) => permit,
Err(error) => {
let class = classify_remote_error(&error);
breaker_permit.failure(class, &format!("{error:#}"));
return;
}
};
let semaphore_start = Instant::now();
let semaphore = match item_deadline
.run("prefetch remote queue", async {
sem.acquire()
.await
.map_err(|_| anyhow::anyhow!("remote semaphore closed"))
})
.await
{
Ok(permit) => permit,
Err(error) => {
drop(gate);
let class = classify_remote_error(&error);
breaker_permit.failure(class, &format!("{error:#}"));
return;
}
};
let semaphore_wait_ms = semaphore_start.elapsed().as_millis() as u64;
if claim_download(&d.downloading, &key).await.is_some() {
tracing::debug!("prefetch: {} already claimed, skipping", key_prefix(&key));
return;
}
let _dl_guard = DownloadingGuard::new(d.downloading.clone(), key.clone());
if entry_dir.exists() {
return;
}
let blobs_dir = d.config.store_dir().join("blobs");
let started_at_unix_ms = unix_time_ms();
let start = Instant::now();
d.prefetch_stats
.v3_requests_total
.fetch_add(1, Ordering::Relaxed);
let download_result = item_deadline
.run(
"prefetch GET and extraction",
download_plan
.layout(remote_backend.as_ref(), &remote_cfg)
.download_entry_until(
&key,
&crate_name,
&entry_dir,
&blobs_dir,
item_deadline.at(),
),
)
.await;
drop(semaphore);
drop(gate);
match download_result {
Ok(dl) => {
breaker_permit.success();
if d.negative_keys.record_present(&knowledge) {
d.key_cache
.insert(key.clone(), Some(crate_name.as_str()))
.await;
}
let (import_result, import_lock_wait_ms, import_ms) =
d.with_store_timed(|store| store.import_restored_entry(&key));
if let Err(e) = import_result {
tracing::warn!("prefetch import failed for {}: {e}", key);
}
let elapsed_ms = start.elapsed().as_millis() as u64;
let finished_at_unix_ms = unix_time_ms();
d.transfer_counters
.downloads_completed
.fetch_add(1, Ordering::Relaxed);
d.transfer_counters
.bytes_downloaded
.fetch_add(dl.compressed_bytes, Ordering::Relaxed);
d.prefetch_stats
.downloads_completed
.fetch_add(1, Ordering::Relaxed);
d.prefetch_stats
.bytes_downloaded
.fetch_add(dl.compressed_bytes, Ordering::Relaxed);
d.prefetch_stats
.v3_bytes_downloaded
.fetch_add(dl.compressed_bytes, Ordering::Relaxed);
{
let mut plan =
d.active_plan.lock().unwrap_or_else(|p| p.into_inner());
if let Some(p) = plan.as_mut() {
p.record_download(&key, dl.compressed_bytes);
}
}
d.push_transfer_event(TransferEvent {
schema: default_transfer_schema(),
crate_name: crate_name.clone(),
direction: TransferDirection::Download,
format: dl.format.to_string(),
cache_key: key.clone(),
object_key: dl.object_key,
compressed_bytes: dl.compressed_bytes,
started_at_unix_ms,
finished_at_unix_ms,
elapsed_ms,
network_ms: dl.network_ms,
semaphore_wait_ms,
head_ms: 0,
request_ms: dl.request_ms,
body_ms: dl.body_ms,
request_count: dl.request_count,
original_bytes: dl.original_bytes,
decompress_ms: dl.decompress_ms,
extract_ms: dl.extract_ms,
disk_io_ms: dl.disk_io_ms,
import_lock_wait_ms,
import_ms,
compression_ms: 0,
head_checks_ms: 0,
blobs_skipped: dl.blobs_skipped,
blobs_total: dl.blobs_total,
ok: true,
timestamp: finished_at_unix_ms / 1_000,
});
{
const MAX_PREFETCHED_KEYS: usize = 50_000;
let mut pf = d.prefetched_keys.write().await;
if pf.len() >= MAX_PREFETCHED_KEYS {
pf.clear();
d.prefetch_used_keys.write().await.clear();
}
pf.insert(key.clone());
}
}
Err(e) => {
let class = classify_remote_error(&e);
if class == RemoteErrorClass::Miss {
breaker_permit.success();
if d.negative_keys.record_miss(&knowledge) {
d.key_cache.remove(&key).await;
}
return;
}
breaker_permit.failure(
class,
&format!("prefetch download failed ({class:?}): {e:#}"),
);
let elapsed_ms = start.elapsed().as_millis() as u64;
let finished_at_unix_ms = unix_time_ms();
d.transfer_counters
.downloads_failed
.fetch_add(1, Ordering::Relaxed);
d.push_transfer_event(TransferEvent {
schema: default_transfer_schema(),
crate_name: crate_name.clone(),
direction: TransferDirection::Download,
format: download_plan.transfer_format().to_string(),
cache_key: key.clone(),
object_key: String::new(),
compressed_bytes: 0,
started_at_unix_ms,
finished_at_unix_ms,
elapsed_ms,
network_ms: 0,
semaphore_wait_ms,
head_ms: 0,
request_ms: 0,
body_ms: 0,
request_count: 0,
original_bytes: 0,
decompress_ms: 0,
extract_ms: 0,
disk_io_ms: 0,
import_lock_wait_ms: 0,
import_ms: 0,
compression_ms: 0,
head_checks_ms: 0,
blobs_skipped: 0,
blobs_total: 0,
ok: false,
timestamp: finished_at_unix_ms / 1_000,
});
tracing::warn!("prefetch download failed for {}: {e}", key);
}
}
}));
}
use futures::StreamExt;
while in_flight.next().await.is_some() {}
let wall_ms = plan_started_at.elapsed().as_millis() as u64;
daemon
.prefetch_stats
.last_plan_wall_ms
.store(wall_ms, Ordering::Relaxed);
daemon
.prefetch_stats
.plan_wall_ms_total
.fetch_add(wall_ms, Ordering::Relaxed);
tracing::info!(wall_ms, "prefetch: completed {} downloads", count);
});
tracing::info!("prefetch: queued {} downloads", count);
Response::ok()
}
fn install_plan(
&self,
session_id: &str,
plan_id: &str,
plan_source: &'static str,
candidates: impl Iterator<Item = String>,
) {
let _ = self.prefetch_cancel.send(false);
let plan = ActivePlan::new(
session_id.to_string(),
plan_id.to_string(),
plan_source,
candidates.collect(),
self.prefetch_stats
.list_requests_total
.load(Ordering::Relaxed),
self.prefetch_stats
.list_duration_ms_total
.load(Ordering::Relaxed),
);
let prev = {
let mut slot = self.active_plan.lock().unwrap_or_else(|p| p.into_inner());
slot.replace(plan)
};
if let Some(prev) = prev {
self.emit_plan_summary(prev, "superseded");
}
}
pub(crate) fn finalize_inactive_plan(&self, inactivity_ms: u64) {
let prev = {
let mut slot = self.active_plan.lock().unwrap_or_else(|p| p.into_inner());
match slot.as_ref() {
Some(p) if epoch_ms().saturating_sub(p.last_activity_ms) >= inactivity_ms => {
slot.take()
}
_ => None,
}
};
if let Some(prev) = prev {
self.emit_plan_summary(prev, "inactivity");
}
}
fn emit_plan_summary(&self, plan: ActivePlan, closure_reason: &str) {
let used_bytes = plan.used_bytes();
let downloaded_bytes: u64 = plan.downloaded.values().sum();
let event = crate::events::BuildSummaryEvent {
ts: chrono::Utc::now(),
schema: 1,
session_id: plan.session_id,
root: String::new(),
plan_source: plan.plan_source.to_string(),
plan_id: plan.plan_id,
closure_reason: closure_reason.to_string(),
started_at_ms: plan.started_at_ms,
last_activity_ms: plan.last_activity_ms,
candidate_keys: plan.candidates.len() as u64,
downloaded_keys: plan.downloaded.len() as u64,
downloaded_bytes,
used_keys: plan.used.len() as u64,
used_bytes,
demanded_keys: plan.demanded.len() as u64,
demanded_candidate_keys: plan.demanded_candidates.len() as u64,
cancelled: plan.cancelled,
list_requests: self
.prefetch_stats
.list_requests_total
.load(Ordering::Relaxed)
.saturating_sub(plan.list_requests_at_install),
list_duration_ms: self
.prefetch_stats
.list_duration_ms_total
.load(Ordering::Relaxed)
.saturating_sub(plan.list_duration_ms_at_install),
};
let path = self.config.summary_log_path();
if let Err(e) = crate::events::log_summary(&path, &event) {
tracing::debug!("failed to write build summary: {e}");
}
}
pub async fn handle_build_started(self: &Arc<Self>, req: &BuildStartedRequest) -> Response {
let plan_started_at = Instant::now();
let pack_context = PackPrefetchContext::from_intent(&req.intent);
let Some(_remote) = &self.config.remote else {
return Response::err("no remote configured");
};
if speculative_prefetch_disabled(self.config.prefetch_enabled) {
tracing::debug!("build-started: speculative prefetch disabled");
return Response::ok();
}
{
let prev = {
let mut slot = self.active_plan.lock().unwrap_or_else(|p| p.into_inner());
match slot.as_ref() {
Some(p) if p.session_id != req.session_id => slot.take(),
_ => None,
}
};
if let Some(prev) = prev {
self.emit_plan_summary(prev, "superseded");
}
}
match crate::planner_client::resolve_prefetch_plan(&req.intent).await {
Ok(Some(plan)) => {
let plan_id = plan.plan_id.clone();
let planner = plan.planner.clone();
match plan.disposition {
PrefetchDisposition::Execute if plan.candidates.is_empty() => {
tracing::warn!(
plan_id = ?plan_id,
planner = ?planner,
"build-started: planner returned execute with no candidates, falling back to local planning"
);
}
PrefetchDisposition::Execute => {
let prefetch_req = PrefetchRequest::from_plan(plan);
self.install_plan(
&req.session_id,
plan_id.as_deref().unwrap_or(""),
"advisory",
prefetch_req.keys.iter().map(|(k, _)| k.clone()),
);
let resp = self
.handle_prefetch_with_context(
&prefetch_req,
pack_context.clone(),
plan_started_at,
)
.await;
if resp.ok {
self.prefetch_stats
.plans_advisory
.fetch_add(1, Ordering::Relaxed);
self.prefetch_stats
.last_plan_candidates
.store(prefetch_req.keys.len() as u64, Ordering::Relaxed);
tracing::info!(
plan_id = ?plan_id,
planner = ?planner,
candidate_count = prefetch_req.keys.len(),
"build-started: using advisory planner plan"
);
return resp;
}
tracing::warn!(
plan_id = ?plan_id,
planner = ?planner,
"build-started: planner plan execution failed, falling back to local planning"
);
}
PrefetchDisposition::UseFallback => {
tracing::debug!(
plan_id = ?plan_id,
planner = ?planner,
"build-started: planner requested fallback to local planning"
);
}
PrefetchDisposition::DoNothing => {
tracing::info!(
plan_id = ?plan_id,
planner = ?planner,
"build-started: planner explicitly requested no prefetch"
);
return Response::ok();
}
}
}
Ok(None) => {}
Err(e) => {
tracing::warn!(
"build-started: planner lookup failed, falling back to local planning: {e}"
);
}
}
let fallback_plan =
match crate::fallback_planner::build_prefetch_plan(self, &req.intent).await {
Ok(plan) => plan,
Err(e) => return Response::err(format!("fallback planning failed: {e}")),
};
if fallback_plan.candidates.is_empty() {
tracing::debug!(
"build-started: nothing to prefetch ({} crate names checked)",
req.intent.crate_names.len()
);
return Response::ok();
}
tracing::info!(
"build-started: using fallback planner with {} candidates for {} crates",
fallback_plan.candidates.len(),
req.intent.crate_names.len()
);
self.prefetch_stats
.plans_fallback
.fetch_add(1, Ordering::Relaxed);
self.prefetch_stats
.last_plan_candidates
.store(fallback_plan.candidates.len() as u64, Ordering::Relaxed);
let prefetch_req = PrefetchRequest::from_plan(fallback_plan);
self.install_plan(
&req.session_id,
"",
"fallback",
prefetch_req.keys.iter().map(|(k, _)| k.clone()),
);
self.handle_prefetch_with_context(&prefetch_req, pack_context, plan_started_at)
.await
}
fn maybe_evict_after_upload(&self) {
let _ = self.with_store(|store| {
let _gc_lock = match store.try_gc_lock()? {
Some(lock) => lock,
None => {
tracing::debug!(
"gc.lock held by another GC; skipping upload-triggered eviction"
);
return Ok(());
}
};
let size = store.physical_size()?;
if size > self.config.max_size {
tracing::info!(
"store size {} > max {}, running LRU eviction",
size,
self.config.max_size
);
let _ = store.evict();
}
Ok(())
});
}
fn run_gc(&self, policy: GcPolicy) -> Result<GcRunReport> {
let start = Instant::now();
let mode = policy.mode();
let _gc_lock = match self.with_store(|store| store.try_gc_lock())? {
Some(lock) => lock,
None => {
tracing::info!("gc.lock held by another GC; skipping this run");
return Ok(GcRunReport::skipped(mode));
}
};
let (dedup_stats, evict_stats, age_evict_stats, incremental_cleaned, orphan_stats) =
self.with_store(|store| {
let backfilled = store.backfill_content_hashes().unwrap_or(0);
if backfilled > 0 {
tracing::info!("backfilled {backfilled} content hashes");
}
let costs = store.backfill_compile_times().unwrap_or(0);
if costs > 0 {
tracing::info!("backfilled {costs} compile times");
}
let mapped = store.backfill_entry_blobs().unwrap_or(0);
if mapped > 0 {
tracing::info!("backfilled {mapped} entry blob maps");
}
let pruned = store
.prune_tombstones(crate::store::TOMBSTONE_RETENTION_DAYS)
.unwrap_or(0);
if let Ok((tracked, demanded)) = store.tombstone_stats()
&& tracked > 0
{
tracing::info!(
tracked,
demanded,
pruned,
demand_rate_pct = demanded * 100 / tracked.max(1),
"gc: post-eviction demand"
);
}
if let Ok(split) = store.shadow_demand_split()
&& split.agreed + split.shadow_kept > 0
{
tracing::info!(
shadow_agreed = split.agreed,
shadow_agreed_demanded = split.agreed_demanded,
shadow_kept = split.shadow_kept,
shadow_kept_demanded = split.shadow_kept_demanded,
"gc: post-eviction demand by shadow verdict (value-density, #594)"
);
}
let (dedup_stats, age_evict_stats, evict_stats) = match policy {
GcPolicy::ExplicitAge { hours } => (
crate::store::GcStats::default(),
store.evict_older_than(hours)?,
crate::store::GcStats::default(),
),
GcPolicy::Automatic { max_age_hours } => {
let age_stats = if max_age_hours > 0 {
store.evict_older_than(max_age_hours)?
} else {
crate::store::GcStats::default()
};
let duplicate_stats = store.evict_duplicate_entries().unwrap_or_default();
let size_stats = store.evict()?;
(duplicate_stats, age_stats, size_stats)
}
};
if dedup_stats.entries_evicted > 0 {
tracing::info!("evicted {} duplicate entries", dedup_stats.entries_evicted);
}
if age_evict_stats.entries_evicted > 0 {
tracing::info!(
"evicted {} entries by age policy",
age_evict_stats.entries_evicted
);
}
let incremental_cleaned = if self.config.clean_incremental {
store.clean_registered_incremental_dirs().unwrap_or(0)
} else {
0
};
let orphan_stats = store
.sweep_orphan_blobs(std::time::Duration::from_secs(3600))
.unwrap_or_default();
let staging_stats = store.sweep_stale_staging(crate::store::STAGING_SWEEP_GRACE);
if staging_stats.removed > 0 {
tracing::info!(
"swept {} stale staging files ({})",
staging_stats.removed,
crate::report::format_bytes(staging_stats.bytes_reclaimed)
);
}
if orphan_stats.removed > 0 {
tracing::info!(
"swept {} of {} blobs as orphans ({} reclaimed)",
orphan_stats.removed,
orphan_stats.scanned,
crate::report::format_bytes(orphan_stats.bytes_reclaimed)
);
}
Ok((
dedup_stats,
evict_stats,
age_evict_stats,
incremental_cleaned,
orphan_stats,
))
})?;
Self::clean_tool_version_caches(&self.config.cache_dir);
if incremental_cleaned > 0 {
tracing::info!("cleaned {incremental_cleaned} registered incremental dirs");
}
let stats = crate::store::GcStats {
entries_evicted: dedup_stats.entries_evicted
+ evict_stats.entries_evicted
+ age_evict_stats.entries_evicted,
bytes_freed: dedup_stats.bytes_freed
+ evict_stats.bytes_freed
+ age_evict_stats.bytes_freed
+ orphan_stats.bytes_reclaimed,
blobs_removed: dedup_stats.blobs_removed
+ evict_stats.blobs_removed
+ age_evict_stats.blobs_removed
+ orphan_stats.removed,
duration_ms: start.elapsed().as_millis() as u64,
skipped: false,
entries_pinned: gc_entries_pinned_lower_bound(
policy,
dedup_stats.entries_pinned,
age_evict_stats.entries_pinned,
evict_stats.entries_pinned,
),
};
tracing::info!(
"gc complete: {} entries evicted, {} freed, {} blobs removed in {}ms",
stats.entries_evicted,
crate::report::format_bytes(stats.bytes_freed),
stats.blobs_removed,
stats.duration_ms,
);
let gc_stats_path = self.config.cache_dir.join("gc_stats.json");
let persisted = crate::report::GcStatsPersisted {
last_run: chrono::Utc::now().to_rfc3339(),
entries_evicted: stats.entries_evicted,
bytes_freed: stats.bytes_freed,
blobs_removed: stats.blobs_removed,
duration_ms: stats.duration_ms,
};
if let Ok(json) = serde_json::to_string_pretty(&persisted) {
let _ = std::fs::write(&gc_stats_path, json);
}
Ok(GcRunReport {
mode,
duplicate: dedup_stats,
age: age_evict_stats,
size: evict_stats,
total: stats,
})
}
fn clean_tool_version_caches(cache_dir: &Path) {
let cutoff = std::time::SystemTime::now() - std::time::Duration::from_secs(7 * 24 * 3600);
let Ok(entries) = std::fs::read_dir(cache_dir) else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if (name.starts_with("rustc-ver-") || name.starts_with("linker-ver-"))
&& name.ends_with(".txt")
&& let Ok(meta) = entry.metadata()
&& let Ok(modified) = meta.modified()
&& modified < cutoff
{
let _ = std::fs::remove_file(entry.path());
}
}
}
}
pub fn run_server(config: &Config, provenance: &crate::config::ConfigFileProvenance) -> Result<()> {
let socket_path = config.socket_path();
let lock_path = socket_path.with_extension("run.lock");
std::fs::create_dir_all(socket_path.parent().unwrap())?;
let lock_file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lock_path)
.context("opening daemon run lock file")?;
if lock_file.try_lock().is_err() {
tracing::info!("another daemon holds the run lock, exiting");
return Ok(());
}
let _lock = lock_file;
let coord = DaemonCoordFile::for_socket(&socket_path);
coord
.write_phase(DaemonPhase::Starting)
.context("writing daemon coordinator state")?;
let _coord_guard = DaemonCoordGuard::new(coord.path.clone());
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
rt.block_on(server_main(config, provenance, coord))
}
fn start_manifest_warming(daemon: &Arc<Daemon>) -> Option<tokio::task::JoinHandle<()>> {
if should_start_speculative_prefetch(
daemon.config.remote.is_some(),
daemon.config.prefetch_enabled,
) {
let manifest_daemon = daemon.clone();
let namespace = std::env::var("KACHE_NAMESPACE").ok();
let lock_path = PathBuf::from("Cargo.lock");
Some(tokio::spawn(async move {
manifest_prefetch(&manifest_daemon, namespace.as_deref(), &lock_path).await;
manifest_daemon.signal_warming_complete();
}))
} else {
daemon.signal_warming_complete();
None
}
}
fn upload_result_is_terminal(error: Option<&str>) -> bool {
!error.is_some_and(|error| error.starts_with("retryable:"))
}
fn daemon_idle_timeout(seconds: u64) -> Option<Duration> {
std::num::NonZeroU64::new(seconds).map(|seconds| Duration::from_secs(seconds.get()))
}
const LOCAL_HIT_PREWARM_BUDGET: Duration = Duration::from_secs(1);
async fn await_local_lookup(
deadline: Option<Instant>,
lookup: impl std::future::Future<Output = LocalLookupReply>,
) -> LocalLookupReply {
match deadline {
Some(deadline) => tokio::time::timeout_at(tokio::time::Instant::from_std(deadline), lookup)
.await
.unwrap_or_else(|_| LocalLookupReply::fallback("deadline exceeded")),
None => lookup.await,
}
}
async fn prewarm_local_hit_service(daemon: &Arc<Daemon>, budget: Duration) {
let mut task = daemon.start_local_hit_initialization();
match tokio::time::timeout(budget, &mut task).await {
Ok(Ok(Ok(()))) => tracing::debug!("local-hit service prewarm complete"),
Ok(Ok(Err(error))) => tracing::warn!("local-hit service prewarm failed: {error:#}"),
Ok(Err(error)) => tracing::warn!("local-hit service prewarm task failed: {error}"),
Err(_) => tracing::debug!(
budget_ms = budget.as_millis() as u64,
"local-hit service prewarm continues in the background"
),
}
}
async fn server_main(
config: &Config,
provenance: &crate::config::ConfigFileProvenance,
coord: DaemonCoordFile,
) -> Result<()> {
let socket_path = config.socket_path();
std::fs::create_dir_all(socket_path.parent().unwrap())?;
let probe_name = socket_name(&socket_path)?;
match TokioStream::connect(probe_name).await {
Ok(_) => {
tracing::info!("another daemon is already running (socket is active), exiting cleanly",);
return Ok(());
}
Err(_) => {
let _ = std::fs::remove_file(&socket_path);
}
}
let daemon = Arc::new(Daemon::new_with_provenance(config.clone(), provenance));
if config.local_hit_daemon {
prewarm_local_hit_service(&daemon, LOCAL_HIT_PREWARM_BUDGET).await;
}
let bind_name = socket_name(&socket_path)?;
let listener = ListenerOptions::new()
.name(bind_name)
.create_tokio()
.context("binding local IPC socket")?;
#[cfg(unix)]
crate::transport::restrict_socket_permissions(&socket_path)
.context("hardening local IPC socket permissions")?;
let _socket_guard = SocketCleanupGuard {
path: socket_path.clone(),
};
coord
.write_phase(DaemonPhase::Ready)
.context("publishing daemon ready state")?;
tracing::info!("daemon listening on {}", socket_path.display());
#[cfg(target_os = "macos")]
let _ = crate::store::exclude_from_indexing(&config.cache_dir);
match crate::cache_fs::classify(&crate::cache_fs::probe(&config.cache_dir)) {
crate::cache_fs::CacheFsVerdict::NotLocal { name } => tracing::warn!(
cache_dir = %config.cache_dir.display(),
filesystem = %name,
"cache directory is not on host-local storage: the WAL index needs working \
file locking and a single writing machine, and can be corrupted on a shared \
or network mount. Set KACHE_CACHE_DIR to a local path; to share artifacts \
between machines use a remote cache instead."
),
verdict => tracing::debug!(
cache_dir = %config.cache_dir.display(),
?verdict,
"cache filesystem locality check"
),
}
let (buffer_tx, mut buffer_rx) = tokio::sync::mpsc::unbounded_channel::<UploadJob>();
let num_workers = (config.s3_concurrency as usize).max(1);
let (worker_tx, worker_rx) = tokio::sync::mpsc::channel::<UploadJob>(num_workers * 2);
let worker_rx = Arc::new(tokio::sync::Mutex::new(worker_rx));
daemon.set_upload_tx(buffer_tx.clone());
match load_upload_jobs(config) {
Ok(jobs) => {
let replay_count = jobs.len();
for job in jobs {
if daemon.pending_uploads.write().await.insert(job.key.clone())
&& buffer_tx.send(job).is_err()
{
tracing::warn!("upload replay buffer closed during startup");
break;
}
}
tracing::info!(replay_count, "durable upload replay scan complete");
}
Err(error) => tracing::warn!("failed to replay durable upload intents: {error:#}"),
}
drop(buffer_tx);
let enqueue_handle = tokio::spawn(async move {
while let Some(job) = buffer_rx.recv().await {
if worker_tx.send(job).await.is_err() {
break;
}
}
});
let mut upload_handles: Vec<tokio::task::JoinHandle<()>> = Vec::new();
for _ in 0..num_workers {
let rx = worker_rx.clone();
let d = daemon.clone();
upload_handles.push(tokio::spawn(async move {
while let Some(job) = rx.lock().await.recv().await {
let resp = loop {
let response = d.do_upload(&job).await;
if upload_result_is_terminal(response.error.as_deref()) {
break response;
}
tracing::debug!(
key = key_prefix(&job.key),
retry_after_secs = UPLOAD_RETRY_DELAY.as_secs(),
"durable upload deferred"
);
tokio::time::sleep(UPLOAD_RETRY_DELAY).await;
};
d.pending_uploads.write().await.remove(&job.key);
if !resp.ok {
tracing::warn!(
"upload worker: {} failed: {}",
job.key,
resp.error.as_deref().unwrap_or("unknown")
);
}
}
}));
}
tracing::info!("started {} upload workers", num_workers);
let gc_daemon = daemon.clone();
let sweep_daemon = daemon.clone();
tokio::spawn(async move {
const SESSION_INACTIVITY_MS: u64 = 300_000;
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
interval.tick().await;
sweep_daemon.finalize_inactive_plan(SESSION_INACTIVITY_MS);
}
});
let gc_handle = tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(6 * 3600));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
interval.tick().await;
tracing::info!("periodic GC sweep starting");
let gc = gc_daemon.clone();
match tokio::task::spawn_blocking(move || {
gc.run_gc(GcPolicy::Automatic {
max_age_hours: gc.config.gc_max_age_hours,
})
})
.await
{
Ok(Ok(_)) => {}
Ok(Err(e)) => tracing::warn!("periodic GC failed: {e}"),
Err(e) => tracing::warn!("periodic GC task panicked: {e}"),
}
}
});
let cache_handle = if should_start_speculative_prefetch(
config.remote.is_some(),
config.prefetch_enabled,
) {
let cache_daemon = daemon.clone();
let refresh_secs = config.remote_key_cache_refresh_secs;
Some(tokio::spawn(async move {
let mut delay = std::time::Duration::from_secs(1);
for attempt in 1..=5 {
match populate_key_cache(&cache_daemon).await {
Ok(count) => {
tracing::info!("remote key cache populated: {count} keys");
break;
}
Err(e) => {
tracing::warn!(
"remote key cache population attempt {attempt}/5 failed: {e}"
);
if attempt < 5 {
tokio::time::sleep(delay).await;
delay *= 2;
}
}
}
}
if key_cache_periodic_refresh_disabled(refresh_secs) {
tracing::info!("remote key cache periodic refresh disabled");
return;
}
let mut interval = tokio::time::interval(std::time::Duration::from_secs(refresh_secs));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
interval.tick().await; let mut consecutive_refresh_failures = 0u32;
loop {
interval.tick().await;
match populate_key_cache(&cache_daemon).await {
Ok(count) => {
if consecutive_refresh_failures > 0 {
tracing::info!(
"remote key cache refresh recovered after {consecutive_refresh_failures} failed attempt(s)"
);
consecutive_refresh_failures = 0;
}
tracing::debug!("remote key cache refreshed: {count} keys");
}
Err(e) => {
consecutive_refresh_failures += 1;
if should_warn_key_cache_refresh_failure(consecutive_refresh_failures) {
tracing::warn!(
"remote key cache refresh failed (attempt {consecutive_refresh_failures}): {e}"
);
} else {
tracing::debug!(
"remote key cache refresh failed (attempt {consecutive_refresh_failures}): {e}"
);
}
}
}
}
}))
} else {
None
};
let manifest_handle = start_manifest_warming(&daemon);
let migration_config = config.clone();
tokio::spawn(async move {
let result = tokio::task::spawn_blocking(move || {
if let Ok(store) = Store::open(&migration_config) {
store.migrate_to_blobs(|_, _| {})
} else {
Err(anyhow::anyhow!("failed to open store for migration"))
}
})
.await;
if let Ok(Ok(stats)) = result
&& stats.entries_migrated > 0
{
tracing::info!(
"background migration: migrated {} entries",
stats.entries_migrated,
);
}
});
let shutdown_flag = Arc::new(AtomicBool::new(false));
let heartbeat_coord = coord.clone();
let heartbeat_handle = tokio::spawn(async move {
let mut interval = tokio::time::interval(DAEMON_COORD_HEARTBEAT_INTERVAL);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
interval.tick().await;
loop {
interval.tick().await;
if let Err(e) = heartbeat_coord.write_phase(DaemonPhase::Ready) {
tracing::debug!("daemon coordinator heartbeat failed: {e}");
}
}
});
let shutdown_notify = Arc::new(Notify::new());
let config_provenance = provenance.clone();
let config_watch_flag = Arc::clone(&shutdown_flag);
let config_watch_notify = Arc::clone(&shutdown_notify);
let config_watch_handle = tokio::spawn(async move {
let mut interval = tokio::time::interval(DAEMON_CONFIG_WATCH_INTERVAL);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
interval.tick().await;
loop {
interval.tick().await;
if config_watch_flag.load(Ordering::Relaxed) {
break;
}
if crate::config::config_file_has_changed(&config_provenance) {
tracing::info!("config file changed on disk, scheduling restart to reload it");
config_watch_flag.store(true, Ordering::Relaxed);
config_watch_notify.notify_one();
break;
}
}
});
let idle_timeout = daemon_idle_timeout(config.daemon_idle_timeout_secs);
accept_loop(
&listener,
&daemon,
&shutdown_flag,
&shutdown_notify,
idle_timeout,
shutdown_signal(),
)
.await;
gc_handle.abort();
if let Some(h) = cache_handle {
h.abort();
}
if let Some(h) = manifest_handle {
h.abort();
}
heartbeat_handle.abort();
config_watch_handle.abort();
daemon.close_upload_queue();
drop(daemon);
if drain_upload_pipeline(enqueue_handle, upload_handles, Duration::from_secs(30)).await {
tracing::warn!("upload drain timeout, aborting remaining upload tasks");
}
tracing::info!("daemon stopped");
Ok(())
}
async fn drain_upload_pipeline(
mut enqueue_handle: tokio::task::JoinHandle<()>,
mut upload_handles: Vec<tokio::task::JoinHandle<()>>,
timeout: Duration,
) -> bool {
let drain_deadline = tokio::time::sleep(timeout);
tokio::pin!(drain_deadline);
let mut timed_out = false;
tokio::select! {
_ = &mut enqueue_handle => {}
_ = &mut drain_deadline => {
timed_out = true;
}
}
if !timed_out {
for handle in &mut upload_handles {
tokio::select! {
_ = handle => {}
_ = &mut drain_deadline => {
timed_out = true;
break;
}
}
}
}
enqueue_handle.abort();
for handle in upload_handles {
handle.abort();
}
timed_out
}
const ACCEPT_LOOP_IDLE_TICK: Duration = Duration::from_secs(60);
const DOWNLOAD_JOIN_BUDGET: Duration = Duration::from_secs(30);
fn download_join_deadline(
now: tokio::time::Instant,
overall: Option<tokio::time::Instant>,
) -> tokio::time::Instant {
let join_budget = now
.checked_add(DOWNLOAD_JOIN_BUDGET)
.expect("download join budget fits monotonic time");
overall.map_or(join_budget, |overall| overall.min(join_budget))
}
#[derive(Debug, PartialEq, Eq)]
enum JoinOutcome {
Found,
Reclaimed,
GaveUp,
}
async fn join_inflight_download(
downloading: &RwLock<HashMap<String, Arc<Notify>>>,
key: &str,
entry_dir: &Path,
mut notify: Arc<Notify>,
deadline: tokio::time::Instant,
) -> JoinOutcome {
loop {
let mut timed_out = false;
let mut adopt: Option<Arc<Notify>> = None;
{
let notified = notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
let current = {
let guard = downloading.read().await;
guard.get(key).cloned()
};
if let Some(cur) = current {
if Arc::ptr_eq(&cur, ¬ify) {
timed_out = tokio::time::timeout_at(deadline, notified).await.is_err();
} else if tokio::time::Instant::now() < deadline {
adopt = Some(cur);
} else {
timed_out = true;
}
}
}
if let Some(cur) = adopt {
notify = cur;
continue;
}
if entry_dir.join("meta.json").exists() {
return JoinOutcome::Found;
}
match claim_download(downloading, key).await {
None => return JoinOutcome::Reclaimed,
Some(next) => {
if timed_out {
tracing::warn!(
key = key_prefix(key),
"download dedup wait exceeded {DOWNLOAD_JOIN_BUDGET:?} with the \
leader still holding the claim; treating as remote miss"
);
return JoinOutcome::GaveUp;
}
notify = next;
}
}
}
}
async fn claim_download(
downloading: &RwLock<HashMap<String, Arc<Notify>>>,
key: &str,
) -> Option<Arc<Notify>> {
use std::collections::hash_map::Entry;
match downloading.write().await.entry(key.to_string()) {
Entry::Occupied(e) => Some(e.get().clone()),
Entry::Vacant(v) => {
v.insert(Arc::new(Notify::new()));
None
}
}
}
async fn try_claim_packed_download(
downloading: &RwLock<HashMap<String, Arc<Notify>>>,
key: &str,
entry_dir: &Path,
) -> bool {
if entry_dir.exists() {
return false;
}
claim_download(downloading, key).await.is_none()
}
struct DownloadingGuard {
map: Arc<RwLock<HashMap<String, Arc<Notify>>>>,
key: String,
}
impl DownloadingGuard {
fn new(map: Arc<RwLock<HashMap<String, Arc<Notify>>>>, key: String) -> Self {
Self { map, key }
}
}
impl Drop for DownloadingGuard {
fn drop(&mut self) {
let key = std::mem::take(&mut self.key);
if let Ok(mut g) = self.map.try_write() {
let notify = g.remove(&key);
drop(g);
if let Some(notify) = notify {
notify.notify_waiters();
}
return;
}
let map = self.map.clone();
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
let notify = map.write().await.remove(&key);
if let Some(notify) = notify {
notify.notify_waiters();
}
});
}
}
}
async fn accept_loop(
listener: &TokioListener,
daemon: &Arc<Daemon>,
shutdown_flag: &Arc<AtomicBool>,
shutdown_notify: &Arc<Notify>,
idle_timeout: Option<Duration>,
shutdown_signal: impl std::future::Future<Output = ()>,
) {
tokio::pin!(shutdown_signal);
let mut last_activity = Instant::now();
const MAX_CONCURRENT_CONNECTIONS: usize = 128;
let conn_limiter = Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_CONNECTIONS));
loop {
if shutdown_flag.load(Ordering::Relaxed) {
tracing::info!("shutdown requested via protocol, draining...");
break;
}
if let Some(timeout) = idle_timeout
&& last_activity.elapsed() > timeout
{
tracing::info!("daemon idle for {:?}, shutting down", timeout);
break;
}
tokio::select! {
accept = listener.accept() => {
match accept {
Ok(stream) => {
let request_started_at = Instant::now();
last_activity = request_started_at;
let d = daemon.clone();
let flag = shutdown_flag.clone();
let notify = shutdown_notify.clone();
let limiter = conn_limiter.clone();
tokio::spawn(async move {
if let Err(e) = handle_connection_after_queue(
stream,
&d,
&flag,
¬ify,
limiter,
request_started_at,
)
.await
{
if e.downcast_ref::<std::io::Error>()
.is_some_and(is_client_disconnect)
{
tracing::debug!("connection handler: client disconnected: {e}");
} else {
tracing::warn!("connection handler error: {e}");
}
}
});
}
Err(e) => {
tracing::warn!("accept error: {e}");
}
}
}
_ = shutdown_notify.notified() => {}
_ = tokio::time::sleep(ACCEPT_LOOP_IDLE_TICK) => {}
_ = &mut shutdown_signal => {
tracing::info!("shutdown signal received, draining...");
break;
}
}
}
}
async fn populate_key_cache(daemon: &Daemon) -> Result<usize> {
let remote = daemon
.config
.remote
.as_ref()
.ok_or_else(|| anyhow::anyhow!("no remote configured"))?;
let Some(breaker_permit) = daemon
.remote_breaker
.try_acquire(RemoteOperation::ListIndex)
else {
anyhow::bail!("remote degraded — key cache refresh suppressed");
};
let deadline = RemoteDeadline::from_secs(daemon.config.remote_restore_timeout_secs);
let backend = match deadline
.run("index backend initialization", daemon.get_remote_backend())
.await
{
Ok(backend) => backend,
Err(error) => {
let class = classify_remote_error(&error);
breaker_permit.failure(class, &format!("{error:#}"));
return Err(error);
}
};
let listing_epoch = daemon.negative_keys.listing_epoch();
let key_cache_revision = daemon.key_cache.refresh_revision();
let list_start = Instant::now();
daemon
.prefetch_stats
.list_requests_total
.fetch_add(1, Ordering::Relaxed);
let semaphore = match deadline
.run("index LIST queue", async {
daemon
.s3_semaphore
.acquire()
.await
.map_err(|_| anyhow::anyhow!("remote semaphore closed"))
})
.await
{
Ok(permit) => permit,
Err(error) => {
let class = classify_remote_error(&error);
breaker_permit.failure(class, &format!("{error:#}"));
return Err(error);
}
};
let list_result = deadline
.run(
"index LIST",
crate::remote_plan::RemotePlanner::new(&daemon.config)
.plan(crate::remote_plan::RemoteWorkload::KeyDiscovery)
.layout(backend.as_ref(), remote)
.list_keys(),
)
.await;
drop(semaphore);
let keys = match list_result {
Ok(keys) => keys,
Err(e) => {
daemon
.prefetch_stats
.list_failures_total
.fetch_add(1, Ordering::Relaxed);
daemon
.prefetch_stats
.list_duration_ms_total
.fetch_add(list_start.elapsed().as_millis() as u64, Ordering::Relaxed);
let class = classify_remote_error(&e);
breaker_permit.failure(
class,
&format!("key cache refresh failed ({class:?}): {e:#}"),
);
return Err(e);
}
};
let list_elapsed_ms = list_start.elapsed().as_millis() as u64;
daemon
.prefetch_stats
.last_list_duration_ms
.store(list_elapsed_ms, Ordering::Relaxed);
daemon
.prefetch_stats
.last_list_key_count
.store(keys.len() as u64, Ordering::Relaxed);
daemon
.prefetch_stats
.list_duration_ms_total
.fetch_add(list_elapsed_ms, Ordering::Relaxed);
daemon
.prefetch_stats
.list_keys_total
.fetch_add(keys.len() as u64, Ordering::Relaxed);
breaker_permit.success();
let count = keys.len();
daemon.negative_keys.remove_present_in(&keys, listing_epoch);
let _ = daemon
.key_cache
.populate_if_unchanged(keys, key_cache_revision)
.await;
Ok(count)
}
async fn manifest_prefetch(
daemon: &Arc<Daemon>,
namespace: Option<&str>,
lock_path: &Path,
) -> usize {
let Some(remote) = &daemon.config.remote else {
return 0;
};
let initialization_deadline =
RemoteDeadline::from_secs(daemon.config.remote_restore_timeout_secs);
let backend = match initialization_deadline
.run(
"startup prefetch backend initialization",
daemon.get_remote_backend(),
)
.await
{
Ok(b) => b,
Err(e) => {
tracing::warn!("manifest prefetch: remote backend init failed: {e}");
return 0;
}
};
if let Some(namespace) = namespace {
if lock_path.exists() {
match shard_prefetch(daemon, backend, &remote.prefix, namespace, lock_path).await {
Ok(n) => {
tracing::info!("shard prefetch: queued {n} keys from shards");
return n;
}
Err(e) => {
tracing::warn!(
"shard prefetch failed, falling back to monolithic build manifest: {e}"
);
}
}
} else {
tracing::info!(
"KACHE_NAMESPACE set but no Cargo.lock found, falling back to monolithic build manifest"
);
}
}
monolithic_manifest_prefetch(daemon, backend.as_ref(), remote).await
}
async fn shard_prefetch(
daemon: &Arc<Daemon>,
backend: &Arc<dyn crate::remote_backend::RemoteBackend>,
prefix: &str,
namespace: &str,
lock_path: &std::path::Path,
) -> anyhow::Result<usize> {
let deps = crate::shards::parse_cargo_lock(lock_path)?;
shard_prefetch_for_deps(daemon, backend, prefix, namespace, &deps).await
}
async fn shard_prefetch_for_deps(
daemon: &Arc<Daemon>,
backend: &Arc<dyn crate::remote_backend::RemoteBackend>,
prefix: &str,
namespace: &str,
deps: &[(String, String)],
) -> anyhow::Result<usize> {
let plan_started_at = Instant::now();
let shard_set = crate::shards::compute_shards(namespace, deps);
tracing::info!(
"shard prefetch: {} deps -> {} shards for namespace '{namespace}'",
deps.len(),
shard_set.shards.len()
);
let mut handles = Vec::new();
for (hash, _entries) in &shard_set.shards {
let b = Arc::clone(backend);
let d = Arc::clone(daemon);
let p = prefix.to_string();
let ns = namespace.to_string();
let h = hash.clone();
handles.push(tokio::spawn(async move {
let Some(breaker) = d.remote_breaker.try_acquire(RemoteOperation::ShardGet) else {
return Ok(None);
};
let deadline = RemoteDeadline::from_secs(d.config.remote_restore_timeout_secs);
let semaphore = match deadline
.run("shard GET queue", async {
d.s3_semaphore
.acquire()
.await
.map_err(|_| anyhow::anyhow!("remote semaphore closed"))
})
.await
{
Ok(permit) => permit,
Err(error) => {
let class = classify_remote_error(&error);
breaker.failure(class, &format!("{error:#}"));
return Err(error);
}
};
let result = deadline
.run(
"shard GET",
crate::remote::download_shard(b.as_ref(), &p, &ns, &h),
)
.await;
drop(semaphore);
match &result {
Ok(_) => breaker.success(),
Err(error) => {
let class = classify_remote_error(error);
breaker.failure(class, &format!("{error:#}"));
}
}
result
}));
}
let mut prefetch_keys: Vec<(String, String)> = Vec::new();
let mut shards_matched = 0usize;
for handle in handles {
match handle.await {
Ok(Ok(Some(shard))) => {
shards_matched += 1;
for entry in shard.entries {
prefetch_keys.push((entry.cache_key, entry.crate_name));
}
}
Ok(Ok(None)) => {} Ok(Err(e)) => tracing::warn!("shard download error: {e}"),
Err(e) => tracing::warn!("shard download task panicked: {e}"),
}
}
tracing::info!(
"shard prefetch: {shards_matched}/{} shards matched, {} keys to prefetch",
shard_set.shards.len(),
prefetch_keys.len()
);
if prefetch_keys.is_empty() {
return Ok(0);
}
let count = prefetch_keys.len();
let req = PrefetchRequest {
keys: prefetch_keys,
warm_all: false,
};
let manifest_key =
std::env::var("KACHE_MANIFEST_KEY").unwrap_or_else(|_| crate::cli::default_manifest_key());
let pack_context = PackPrefetchContext::from_deps(manifest_key, namespace, deps).ok();
let resp = daemon
.handle_prefetch_with_context(&req, pack_context, plan_started_at)
.await;
if !resp.ok {
anyhow::bail!(
"prefetch failed: {}",
resp.error.as_deref().unwrap_or("unknown")
);
}
Ok(count)
}
async fn monolithic_manifest_prefetch(
daemon: &Arc<Daemon>,
backend: &dyn crate::remote_backend::RemoteBackend,
remote: &crate::config::RemoteConfig,
) -> usize {
let manifest_key =
std::env::var("KACHE_MANIFEST_KEY").unwrap_or_else(|_| crate::cli::default_manifest_key());
let min_compile_ms: u64 = std::env::var("KACHE_MIN_COMPILE_MS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(1000);
let Some(breaker) = daemon
.remote_breaker
.try_acquire(RemoteOperation::ManifestGet)
else {
tracing::debug!("manifest prefetch suppressed by read breaker");
return 0;
};
let deadline = RemoteDeadline::from_secs(daemon.config.remote_restore_timeout_secs);
let semaphore = match deadline
.run("manifest GET queue", async {
daemon
.s3_semaphore
.acquire()
.await
.map_err(|_| anyhow::anyhow!("remote semaphore closed"))
})
.await
{
Ok(permit) => permit,
Err(error) => {
let class = classify_remote_error(&error);
breaker.failure(class, &format!("{error:#}"));
tracing::warn!("manifest prefetch queue failed: {error:#}");
return 0;
}
};
let manifest_result = deadline
.run(
"manifest GET",
crate::remote::download_manifest(backend, &remote.prefix, &manifest_key),
)
.await;
drop(semaphore);
let manifest = match manifest_result {
Ok(manifest) => {
breaker.success();
manifest
}
Err(e) => {
let class = classify_remote_error(&e);
breaker.failure(class, &format!("{e:#}"));
tracing::info!("manifest prefetch: no manifest for '{manifest_key}' ({e}), skipping");
return 0;
}
};
let mut worth_prefetching: Vec<_> = manifest
.entries
.iter()
.filter(|e| e.compile_time_ms >= min_compile_ms)
.collect();
worth_prefetching.sort_by_key(|entry| std::cmp::Reverse(entry.compile_time_ms));
let skipped = manifest.entries.len() - worth_prefetching.len();
tracing::info!(
"manifest prefetch: {} entries, prefetching {} (skipped {} cheap crates < {}ms)",
manifest.entries.len(),
worth_prefetching.len(),
skipped,
min_compile_ms
);
if worth_prefetching.is_empty() {
return 0;
}
let prefetch_keys: Vec<(String, String)> = worth_prefetching
.iter()
.map(|e| (e.cache_key.clone(), e.crate_name.clone()))
.collect();
let req = PrefetchRequest {
keys: prefetch_keys,
warm_all: false,
};
let resp = daemon.handle_prefetch(&req).await;
if !resp.ok {
tracing::warn!(
"manifest prefetch failed: {}",
resp.error.as_deref().unwrap_or("unknown")
);
return 0;
}
worth_prefetching.len()
}
const MAX_REQUEST_FRAME_BYTES: usize = 8 * 1024 * 1024;
async fn read_bounded_line<R>(reader: &mut R, buf: &mut Vec<u8>) -> std::io::Result<Option<String>>
where
R: AsyncBufRead + Unpin,
{
buf.clear();
loop {
let available = reader.fill_buf().await?;
if available.is_empty() {
return Ok((!buf.is_empty()).then(|| decode_request_frame(buf)));
}
if let Some(pos) = available.iter().position(|&b| b == b'\n') {
buf.extend_from_slice(&available[..pos]);
std::pin::Pin::new(&mut *reader).consume(pos + 1);
return Ok(Some(decode_request_frame(buf)));
}
buf.extend_from_slice(available);
let consumed = available.len();
std::pin::Pin::new(&mut *reader).consume(consumed);
if buf.len() > MAX_REQUEST_FRAME_BYTES {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"request frame exceeds maximum size",
));
}
}
}
fn decode_request_frame(buf: &[u8]) -> String {
let mut s = String::from_utf8_lossy(buf).into_owned();
if s.ends_with('\r') {
s.pop();
}
s
}
async fn offload<F>(f: F) -> Response
where
F: FnOnce() -> Response + Send + 'static,
{
match tokio::task::spawn_blocking(f).await {
Ok(resp) => resp,
Err(e) => Response::err(format!("daemon handler task failed: {e}")),
}
}
async fn handle_connection_after_queue(
stream: TokioStream,
daemon: &Arc<Daemon>,
shutdown_flag: &AtomicBool,
shutdown_notify: &Notify,
limiter: Arc<tokio::sync::Semaphore>,
request_started_at: Instant,
) -> Result<()> {
#[cfg(unix)]
if let Err(error) = crate::transport::require_self_peer(crate::transport::peer_euid(&stream)) {
tracing::warn!(%error, "rejected IPC connection from another local user");
return Ok(());
}
let _permit = limiter.acquire_owned().await.ok();
handle_connection_started_at(
stream,
daemon,
shutdown_flag,
shutdown_notify,
request_started_at,
)
.await
}
#[cfg(test)]
async fn handle_connection(
stream: TokioStream,
daemon: &Arc<Daemon>,
shutdown_flag: &AtomicBool,
shutdown_notify: &Notify,
) -> Result<()> {
handle_connection_started_at(
stream,
daemon,
shutdown_flag,
shutdown_notify,
Instant::now(),
)
.await
}
async fn handle_connection_started_at(
stream: TokioStream,
daemon: &Arc<Daemon>,
shutdown_flag: &AtomicBool,
shutdown_notify: &Notify,
request_started_at: Instant,
) -> Result<()> {
let mut reader = BufReader::new(&stream);
let mut frame = Vec::new();
loop {
let line = match read_bounded_line(&mut reader, &mut frame).await {
Ok(Some(l)) => l,
Ok(None) => break,
Err(e) if is_client_disconnect(&e) => {
tracing::debug!("client disconnected mid-read: {e}");
break;
}
Err(e) => return Err(e.into()),
};
let start = Instant::now();
let parsed = serde_json::from_str::<Request>(&line);
let client_epoch = match &parsed {
Ok(Request::Upload(job)) => job.client_epoch,
Ok(Request::Stats(req)) => req.client_epoch,
Ok(Request::BuildStarted(req)) => req.client_epoch,
Ok(Request::LocalLookup(req)) => req.client_epoch,
_ => 0,
};
let resp = match parsed {
Ok(Request::Upload(ref job)) => {
tracing::debug!(
crate_name = job.crate_name,
key = key_prefix(&job.key),
"handling upload request"
);
daemon.handle_upload(job).await
}
Ok(Request::Gc(req) | Request::GcV2(req)) => {
let d = Arc::clone(daemon);
offload(move || d.handle_gc(&req)).await
}
Ok(Request::RemoteCheck(req)) => {
daemon
.handle_remote_check_started_at(&req, request_started_at)
.await
}
Ok(Request::LocalLookup(req)) => daemon.handle_local_lookup(&req).await,
Ok(Request::Stats(req)) => {
let d = Arc::clone(daemon);
offload(move || d.handle_stats(&req)).await
}
Ok(Request::BatchRemoteCheck(req)) => {
daemon
.handle_batch_remote_check_started_at(&req, request_started_at)
.await
}
Ok(Request::HashFiles(req)) => {
let d = Arc::clone(daemon);
offload(move || d.handle_hash_files(&req)).await
}
Ok(Request::Prefetch(req)) => daemon.handle_prefetch(&req).await,
Ok(Request::BuildStarted(req)) => daemon.handle_build_started(&req).await,
Ok(Request::CompileStarted(req)) => daemon.handle_compile_started(req),
Ok(Request::CompileFinished(req)) => daemon.handle_compile_finished(&req),
Ok(Request::Shutdown) => {
shutdown_flag.store(true, Ordering::Relaxed);
shutdown_notify.notify_one();
Response::ok()
}
Err(e) => {
tracing::warn!("invalid request from client: {e}");
Response::err(format!("invalid request: {e}"))
}
};
let elapsed = start.elapsed();
if client_epoch_is_newer(client_epoch, daemon.build_epoch)
&& !shutdown_flag.load(Ordering::Relaxed)
{
tracing::info!(
daemon_epoch = daemon.build_epoch,
client_epoch,
"client binary is newer than daemon, scheduling restart"
);
shutdown_flag.store(true, Ordering::Relaxed);
shutdown_notify.notify_one();
}
if !resp.ok {
tracing::warn!(
elapsed_ms = elapsed.as_millis() as u64,
error = resp.error.as_deref().unwrap_or("unknown"),
"request failed"
);
}
let mut resp_line = serde_json::to_string(&resp)?;
resp_line.push('\n');
if let Err(e) = (&stream).write_all(resp_line.as_bytes()).await {
tracing::debug!("response write failed (client likely closed): {e}");
break;
}
}
Ok(())
}
fn is_client_disconnect(e: &std::io::Error) -> bool {
matches!(
e.kind(),
std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::ConnectionReset
) || e.raw_os_error() == Some(32) }
fn key_prefix(key: &str) -> &str {
let mut end = key.len().min(16);
while end > 0 && !key.is_char_boundary(end) {
end -= 1;
}
&key[..end]
}
fn send_retry_delay(attempt: u32, pid: u32) -> Duration {
let jitter = (u64::from(pid) * 7) % 50;
Duration::from_millis(100 * u64::from(attempt) + jitter)
}
fn should_warn_key_cache_refresh_failure(consecutive_refresh_failures: u32) -> bool {
consecutive_refresh_failures == 1 || consecutive_refresh_failures.is_multiple_of(10)
}
fn rotate_daemon_log_if_large(log_path: &Path) {
if std::fs::metadata(log_path).is_ok_and(|m| m.len() > 2 * 1024 * 1024) {
let _ = std::fs::write(log_path, b"--- log rotated ---\n");
}
}
use crate::platform::wait_for_shutdown as shutdown_signal;
pub fn send_upload_job(
config: &Config,
key: &str,
entry_dir: &Path,
crate_name: &str,
) -> Result<()> {
if config.remote_readonly {
return Ok(());
}
let socket_path = config.socket_path();
let job = UploadJob {
key: key.to_string(),
entry_dir: entry_dir.to_string_lossy().into_owned(),
crate_name: crate_name.to_string(),
client_epoch: build_epoch(),
};
let durable_job = persist_upload_job(config, &job)?;
let req = Request::Upload(durable_job);
let key_short = key_prefix(key);
let try_send = |path: &Path| -> Result<()> { send_request_fire_and_forget(path, &req) };
match try_send(&socket_path) {
Ok(()) => return Ok(()),
Err(first_err) => {
tracing::debug!(
crate_name,
key = key_short,
"initial upload send failed, starting daemon: {first_err:#}",
);
match start_daemon_background() {
Ok(true) => {}
Ok(false) | Err(_) => {
tracing::warn!(
crate_name,
key = key_short,
"could not reach or start daemon; upload remains queued durably"
);
return Ok(());
}
}
}
}
for attempt in 1..=3u32 {
match try_send(&socket_path) {
Ok(()) => return Ok(()),
Err(e) => {
if attempt < 3 {
let delay = send_retry_delay(attempt, std::process::id());
tracing::debug!(
crate_name,
key = key_short,
attempt,
"upload send retry {attempt}/3 failed, backoff {delay:?}: {e:#}",
);
std::thread::sleep(delay);
} else {
tracing::warn!(
crate_name,
key = key_short,
socket = %socket_path.display(),
"upload send failed after {attempt} retries: {e:#}",
);
}
}
}
}
Ok(()) }
pub struct GcRequestOutcome {
pub evicted: Option<usize>,
pub skipped: bool,
pub breakdown: Option<GcBreakdown>,
}
const GC_POLICY_PROTOCOL_VERSION: u32 = 2;
fn require_gc_policy_support(stats: &StatsResponse) -> Result<()> {
if stats.gc_policy_version < GC_POLICY_PROTOCOL_VERSION {
anyhow::bail!(
"connected daemon predates GC policy version {GC_POLICY_PROTOCOL_VERSION}; refusing \
to send a mutating GC request"
);
}
Ok(())
}
fn require_daemon_started(started: bool) -> Result<()> {
anyhow::ensure!(started, "could not reach or start daemon");
Ok(())
}
fn gc_outcome_from_response(resp: Response) -> Result<GcRequestOutcome> {
if !resp.ok {
anyhow::bail!("daemon GC error: {}", resp.error.unwrap_or_default());
}
if resp.gc.is_none() {
anyhow::bail!(
"connected daemon omitted GC policy reporting; refusing to accept ambiguous semantics"
);
}
Ok(GcRequestOutcome {
evicted: resp.evicted,
skipped: resp.skipped,
breakdown: resp.gc,
})
}
pub fn send_gc_request(config: &Config, max_age_hours: Option<u64>) -> Result<GcRequestOutcome> {
let socket_path = config.socket_path();
match send_stats_request(config, false, None, None) {
Ok(stats) => require_gc_policy_support(&stats)?,
Err(_) => {
require_daemon_started(start_daemon_background()?)?;
let stats = send_stats_request(config, false, None, None)
.context("probing GC policy support after daemon start")?;
require_gc_policy_support(&stats)?;
}
}
let req = Request::GcV2(match max_age_hours {
Some(hours) => GcRequest::explicit_age(hours),
None => GcRequest::automatic(config.gc_max_age_hours),
});
let try_send = |path: &Path| -> Result<Response> {
let resp_str = send_request(path, &req)?;
let resp: Response = serde_json::from_str(&resp_str)?;
Ok(resp)
};
match try_send(&socket_path) {
Ok(resp) => gc_outcome_from_response(resp),
Err(_) => {
require_daemon_started(start_daemon_background()?)?;
let stats = send_stats_request(config, false, None, None)
.context("probing GC policy support before retry")?;
require_gc_policy_support(&stats)?;
let resp = try_send(&socket_path)?;
gc_outcome_from_response(resp)
}
}
}
pub struct RemoteCheckResult {
pub found: bool,
pub prefetched: bool,
}
fn remote_check_result_from_response_line(resp_str: &str) -> Option<RemoteCheckResult> {
match serde_json::from_str::<Response>(resp_str) {
Ok(resp) if resp.ok => resp.found.map(|found| RemoteCheckResult {
found,
prefetched: resp.prefetched.unwrap_or(false),
}),
Ok(resp) => {
tracing::warn!(
"remote check error: {}",
resp.error.as_deref().unwrap_or("unknown")
);
None
}
Err(e) => {
tracing::warn!("remote check response parse error: {e}");
None
}
}
}
pub fn send_remote_check(
config: &Config,
key: &str,
entry_dir: &Path,
crate_name: &str,
) -> Option<RemoteCheckResult> {
let socket_path = config.socket_path();
if !crate::transport::is_reachable(&socket_path) {
return None;
}
let client_budget_ms = remote_check_budget_ms(config.remote_restore_timeout_secs, None);
let req = Request::RemoteCheck(RemoteCheckRequest {
key: key.to_string(),
entry_dir: entry_dir.to_string_lossy().into_owned(),
crate_name: crate_name.to_string(),
deadline_ms: Some(client_budget_ms.get()),
});
let client_timeout = Duration::from_millis(client_budget_ms.get());
match send_request_with_timeout(&socket_path, &req, client_timeout) {
Ok(resp_str) => remote_check_result_from_response_line(&resp_str),
Err(e) => {
tracing::debug!("remote check: daemon unreachable ({e})");
None
}
}
}
pub fn send_local_lookup(config: &Config, key: &str) -> Option<LocalLookupReply> {
let socket_path = config.socket_path();
if !crate::transport::is_reachable(&socket_path) {
return None;
}
let req = Request::LocalLookup(LocalLookupRequest {
key: key.to_string(),
client_epoch: build_epoch(),
});
let timeout = std::env::var("KACHE_LOCAL_HIT_TIMEOUT_MS")
.ok()
.and_then(|v| v.parse().ok())
.map(std::time::Duration::from_millis)
.unwrap_or(std::time::Duration::from_millis(250));
match send_request_with_timeout(&socket_path, &req, timeout) {
Ok(resp_str) => match serde_json::from_str::<Response>(&resp_str) {
Ok(resp) if resp.ok => resp.local_lookup,
_ => None,
},
Err(e) => {
tracing::debug!("local lookup: daemon unreachable ({e})");
None
}
}
}
pub fn send_hash_files_request(
socket_path: &Path,
files: Vec<HashFileRequest>,
) -> Result<Vec<HashFileResult>> {
if files.is_empty() {
return Ok(Vec::new());
}
if !socket_path.exists() {
anyhow::bail!("daemon socket does not exist: {}", socket_path.display());
}
let req = Request::HashFiles(HashFilesRequest { files });
let resp_str = send_request_with_timeout(socket_path, &req, std::time::Duration::from_secs(3))?;
hash_files_results_from_response_line(&resp_str)
}
fn hash_files_results_from_response_line(resp_str: &str) -> Result<Vec<HashFileResult>> {
let resp: Response = serde_json::from_str(resp_str)?;
if !resp.ok {
anyhow::bail!(
"daemon hash_files error: {}",
resp.error.unwrap_or_default()
);
}
Ok(resp.hash_results.unwrap_or_default())
}
#[allow(dead_code)]
pub fn send_prefetch(config: &Config, keys: &[(String, String)]) -> Result<()> {
let socket_path = config.socket_path();
let req = Request::Prefetch(PrefetchRequest {
keys: keys.to_vec(),
warm_all: false,
});
let try_send = |path: &Path| -> Result<()> { send_request_fire_and_forget(path, &req) };
match try_send(&socket_path) {
Ok(()) => return Ok(()),
Err(_) => match start_daemon_background() {
Ok(true) => {}
Ok(false) | Err(_) => {
tracing::warn!("could not reach or start daemon, skipping prefetch");
return Ok(());
}
},
}
for attempt in 1..=3u32 {
match try_send(&socket_path) {
Ok(()) => return Ok(()),
Err(e) => {
if attempt < 3 {
std::thread::sleep(send_retry_delay(attempt, std::process::id()));
} else {
tracing::warn!("prefetch send failed after {attempt} retries: {e}");
}
}
}
}
Ok(()) }
pub fn send_build_started(config: &Config, req: BuildStartedRequest) {
let socket_path = config.socket_path();
let crate_count = req.intent.crate_names.len();
let req = Request::BuildStarted(req);
match send_request_fire_and_forget(&socket_path, &req) {
Ok(()) => {
tracing::debug!("build-started hint sent for {} crates", crate_count);
}
Err(e) => {
tracing::debug!("build-started hint: daemon unreachable ({e}), skipping");
}
}
}
const IN_FLIGHT_MAX_AGE_MS: u64 = 6 * 60 * 60 * 1000;
fn unix_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
fn prune_in_flight(map: &mut HashMap<u32, CompileStartedRequest>) {
let now = unix_ms();
map.retain(|&pid, c| {
now.saturating_sub(c.started_at_ms) <= IN_FLIGHT_MAX_AGE_MS && pid_alive(pid)
});
}
#[cfg(unix)]
fn pid_alive(pid: u32) -> bool {
if pid <= 1 || i32::try_from(pid).is_err() {
return false;
}
let rc = unsafe { libc::kill(pid as libc::pid_t, 0) };
rc == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
#[cfg(not(unix))]
fn pid_alive(_pid: u32) -> bool {
true
}
pub fn send_compile_started(socket_path: &std::path::Path, req: CompileStartedRequest) {
if !crate::transport::is_reachable(socket_path) {
return;
}
let req = Request::CompileStarted(req);
if let Err(e) = send_request_fire_and_forget(socket_path, &req) {
tracing::debug!("compile-started: daemon unreachable ({e}), skipping");
}
}
pub fn send_compile_finished(socket_path: &std::path::Path, pid: u32, started_at_ms: u64) {
if !crate::transport::is_reachable(socket_path) {
return;
}
let req = Request::CompileFinished(CompileFinishedRequest { pid, started_at_ms });
if let Err(e) = send_request_fire_and_forget(socket_path, &req) {
tracing::debug!("compile-finished: daemon unreachable ({e}), skipping");
}
}
pub fn send_stats_request(
config: &Config,
include_entries: bool,
sort_by: Option<&str>,
event_hours: Option<u64>,
) -> Result<StatsResponse> {
send_stats_request_options(config, include_entries, false, sort_by, event_hours)
}
pub fn send_stats_request_without_restart(
config: &Config,
include_entries: bool,
) -> Result<StatsResponse> {
fetch_stats(
config,
include_entries,
false,
None,
None,
STATS_READ_TIMEOUT,
)
}
pub(crate) fn send_stats_request_options(
config: &Config,
include_entries: bool,
include_summaries: bool,
sort_by: Option<&str>,
event_hours: Option<u64>,
) -> Result<StatsResponse> {
let client_epoch = build_epoch();
let stats = fetch_stats(
config,
include_entries,
include_summaries,
sort_by,
event_hours,
STATS_READ_TIMEOUT,
)?;
if client_epoch_is_newer(client_epoch, stats.build_epoch) {
tracing::info!(
daemon_epoch = stats.build_epoch,
client_epoch,
"stale daemon detected via stats request, restarting"
);
if restart_daemon_for_stale_client(config)?
&& let Ok(fresh_stats) = fetch_stats(
config,
include_entries,
include_summaries,
sort_by,
event_hours,
STATS_REFETCH_TIMEOUT,
)
{
return Ok(fresh_stats);
}
}
Ok(stats)
}
fn fetch_stats(
config: &Config,
include_entries: bool,
include_summaries: bool,
sort_by: Option<&str>,
event_hours: Option<u64>,
read_timeout: Duration,
) -> Result<StatsResponse> {
let req = Request::Stats(StatsRequest {
include_entries,
include_summaries,
sort_by: sort_by.map(String::from),
event_hours,
client_epoch: build_epoch(),
});
let resp_str = send_request_with_timeout(&config.socket_path(), &req, read_timeout)?;
let resp: Response = serde_json::from_str(&resp_str)?;
if resp.ok {
resp.stats
.ok_or_else(|| anyhow::anyhow!("stats response missing payload"))
} else {
anyhow::bail!("daemon stats error: {}", resp.error.unwrap_or_default())
}
}
pub fn send_shutdown_request(config: &Config) -> Result<()> {
let socket_path = config.socket_path();
match send_request_with_timeout(&socket_path, &Request::Shutdown, Duration::from_secs(5)) {
Ok(_) => {
eprintln!("daemon stopped");
Ok(())
}
Err(e) => {
if let Some(state) = read_daemon_state(&socket_path)
&& process_is_alive(state.pid)
{
tracing::info!(
pid = state.pid,
"socket unreachable, terminating daemon process"
);
crate::platform::terminate_process(state.pid);
if wait_for_run_lock_release(&socket_path, Duration::from_secs(3))? {
let _ = std::fs::remove_file(&socket_path);
eprintln!("daemon stopped (terminated stale process)");
return Ok(());
}
tracing::warn!(pid = state.pid, "daemon did not stop, force-killing");
crate::platform::kill_process(state.pid);
if wait_for_run_lock_release(&socket_path, Duration::from_secs(2))? {
let _ = std::fs::remove_file(&socket_path);
eprintln!("daemon stopped (killed stale process)");
return Ok(());
}
}
Err(e).context("connecting to daemon socket")
}
}
}
#[cfg(unix)]
const DAEMON_EXE_NAME: &str = "kache";
#[cfg(unix)]
fn comm_is_daemon_exe(comm: &str) -> bool {
let comm = comm.trim();
!comm.is_empty()
&& Path::new(comm)
.file_name()
.is_some_and(|name| name == DAEMON_EXE_NAME)
}
#[cfg(unix)]
fn process_comm(pid: u32) -> Option<String> {
let output = std::process::Command::new("ps")
.args(["-o", "comm=", "-p", &pid.to_string()])
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn find_daemon_pids() -> Vec<u32> {
let own_pid = std::process::id();
#[cfg(unix)]
{
let output = match std::process::Command::new("pgrep")
.args(["-f", "kache daemon run"])
.output()
{
Ok(o) if o.status.success() => o,
_ => return Vec::new(),
};
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|l| l.trim().parse::<u32>().ok())
.filter(|&pid| pid != own_pid && process_is_alive(pid))
.filter(|&pid| process_comm(pid).is_some_and(|comm| comm_is_daemon_exe(&comm)))
.collect()
}
#[cfg(windows)]
{
if let Some(processes) = windows_kache_processes() {
return processes
.into_iter()
.filter(|(pid, cmdline)| *pid != own_pid && cmdline_is_daemon_run(cmdline))
.map(|(pid, _)| pid)
.filter(|&pid| process_is_alive(pid))
.collect();
}
let output = match std::process::Command::new("tasklist")
.args(["/FI", "IMAGENAME eq kache.exe", "/FO", "CSV", "/NH"])
.output()
{
Ok(o) if o.status.success() => o,
_ => return Vec::new(),
};
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| {
let fields: Vec<&str> = line.split(',').collect();
fields.get(1)?.trim_matches('"').parse::<u32>().ok()
})
.filter(|&pid| pid != own_pid && process_is_alive(pid))
.collect()
}
}
#[cfg(any(windows, test))]
fn cmdline_is_daemon_run(cmdline: &str) -> bool {
let mut rest = cmdline
.split_whitespace()
.skip_while(|token| *token != "daemon");
rest.next().is_some() && rest.next() == Some("run")
}
#[cfg(windows)]
fn windows_kache_processes() -> Option<Vec<(u32, String)>> {
let output = std::process::Command::new("powershell")
.args([
"-NoProfile",
"-NonInteractive",
"-Command",
"Get-CimInstance Win32_Process -Filter \"Name='kache.exe'\" | \
ForEach-Object { \"$($_.ProcessId)|$($_.CommandLine)\" }",
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut rows = Vec::new();
let mut saw_command_line = false;
for line in stdout.lines() {
let Some((pid, cmdline)) = line.trim().split_once('|') else {
continue;
};
let Ok(pid) = pid.trim().parse::<u32>() else {
continue;
};
let cmdline = cmdline.trim();
saw_command_line |= !cmdline.is_empty();
rows.push((pid, cmdline.to_string()));
}
if rows.is_empty() {
return Some(rows);
}
saw_command_line.then_some(rows)
}
pub fn force_recover(config: &Config) -> Result<()> {
let socket_path = config.socket_path();
let pids = find_daemon_pids();
if !pids.is_empty() {
tracing::info!(?pids, "killing lingering kache daemon processes");
for &pid in &pids {
crate::platform::terminate_process(pid);
}
std::thread::sleep(Duration::from_millis(500));
for &pid in &pids {
if process_is_alive(pid) {
tracing::warn!(pid, "graceful terminate did not land, force-killing");
crate::platform::kill_process(pid);
}
}
std::thread::sleep(Duration::from_millis(200));
}
let _ = std::fs::remove_file(&socket_path);
let _ = std::fs::remove_file(daemon_state_path(&socket_path));
let _ = std::fs::remove_file(socket_path.with_extension("lock"));
let _ = std::fs::remove_file(socket_path.with_extension("run.lock"));
Ok(())
}
pub fn restart(config: &Config) -> Result<bool> {
let socket_path = config.socket_path();
match crate::service::kickstart() {
Ok(true) => {
eprintln!("restarting daemon via service manager...");
if wait_for_socket_until(&socket_path, None, Duration::from_secs(10))? {
let responsive = send_stats_request(config, false, None, None).is_ok();
let pids = find_daemon_pids();
if responsive && pids.len() <= 1 {
eprintln!("daemon restarted");
return Ok(true);
}
tracing::warn!(
responsive,
daemon_pids = ?pids,
"service kickstart reported success but daemon isn't healthy; attempting nuclear recovery"
);
} else {
tracing::warn!(
"service kickstart completed but socket not ready; attempting nuclear recovery"
);
}
}
Ok(false) => {
}
Err(e) => {
tracing::warn!("service kickstart failed: {e:#}; attempting nuclear recovery");
}
}
let _ = send_shutdown_request(config);
force_recover(config)?;
match start_daemon_background()? {
true => {
eprintln!("daemon restarted");
Ok(true)
}
false => {
eprintln!("daemon did not start within timeout");
Ok(false)
}
}
}
pub(crate) fn restart_daemon_for_stale_client(config: &Config) -> Result<bool> {
let socket_path = config.socket_path();
let _ = send_request_with_timeout(&socket_path, &Request::Shutdown, Duration::from_secs(2));
for _ in 0..4 {
if !crate::transport::is_reachable(&socket_path) {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
start_daemon_background()
}
fn send_request(socket_path: &Path, req: &Request) -> Result<String> {
send_request_with_timeout(socket_path, req, std::time::Duration::from_secs(30))
}
fn send_request_with_timeout(
socket_path: &Path,
req: &Request,
read_timeout: std::time::Duration,
) -> Result<String> {
#[cfg(windows)]
{
send_request_with_async_timeout(socket_path, req, read_timeout)
}
#[cfg(not(windows))]
{
send_request_with_socket_timeout(socket_path, req, read_timeout)
}
}
#[cfg(not(windows))]
fn send_request_with_socket_timeout(
socket_path: &Path,
req: &Request,
read_timeout: std::time::Duration,
) -> Result<String> {
use crate::transport::SyncStream;
use interprocess::local_socket::traits::Stream as _;
use std::io::{BufRead, Write};
let name = socket_name(socket_path)?;
let mut stream = SyncStream::connect(name)
.with_context(|| format!("connecting to daemon socket {}", socket_path.display()))?;
let _ = stream.set_recv_timeout(Some(read_timeout));
let _ = stream.set_send_timeout(Some(std::time::Duration::from_secs(5)));
let mut line = serde_json::to_string(req)?;
line.push('\n');
stream
.write_all(line.as_bytes())
.context("writing request to daemon")?;
stream.flush().context("flushing request to daemon")?;
let mut reader = std::io::BufReader::new(&stream);
let mut resp = String::new();
reader.read_line(&mut resp).with_context(|| {
format!(
"reading response from daemon (timeout {:?}, socket {})",
read_timeout,
socket_path.display()
)
})?;
Ok(resp)
}
#[cfg(windows)]
fn send_request_with_async_timeout(
socket_path: &Path,
req: &Request,
read_timeout: std::time::Duration,
) -> Result<String> {
let mut line = serde_json::to_string(req)?;
line.push('\n');
if tokio::runtime::Handle::try_current().is_ok() {
let socket_path = socket_path.to_path_buf();
std::thread::spawn(move || {
send_request_with_async_timeout_blocking(&socket_path, line, read_timeout)
})
.join()
.map_err(|_| anyhow::anyhow!("daemon client timeout thread panicked"))?
} else {
send_request_with_async_timeout_blocking(socket_path, line, read_timeout)
}
}
#[cfg(windows)]
fn send_request_with_async_timeout_blocking(
socket_path: &Path,
line: String,
read_timeout: std::time::Duration,
) -> Result<String> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()
.context("creating daemon client runtime")?;
runtime.block_on(async {
tokio::time::timeout(
read_timeout,
send_request_with_async_transport(socket_path, line, read_timeout),
)
.await
.with_context(|| {
format!(
"daemon request timed out after {:?} (socket {})",
read_timeout,
socket_path.display()
)
})?
})
}
#[cfg(windows)]
async fn send_request_with_async_transport(
socket_path: &Path,
line: String,
read_timeout: std::time::Duration,
) -> Result<String> {
let name = socket_name(socket_path)?;
let mut stream = TokioStream::connect(name)
.await
.with_context(|| format!("connecting to daemon socket {}", socket_path.display()))?;
stream
.write_all(line.as_bytes())
.await
.context("writing request to daemon")?;
stream.flush().await.context("flushing request to daemon")?;
let mut reader = BufReader::new(stream);
let mut resp = String::new();
reader.read_line(&mut resp).await.with_context(|| {
format!(
"reading response from daemon (timeout {:?}, socket {})",
read_timeout,
socket_path.display()
)
})?;
Ok(resp)
}
fn send_request_fire_and_forget(socket_path: &Path, req: &Request) -> Result<()> {
use crate::transport::SyncStream;
use interprocess::local_socket::traits::Stream as _;
use std::io::Write;
let name = socket_name(socket_path)?;
let mut stream = SyncStream::connect(name)
.with_context(|| format!("connecting to daemon socket {}", socket_path.display()))?;
let _ = stream.set_send_timeout(Some(std::time::Duration::from_secs(5)));
let mut line = serde_json::to_string(req)?;
line.push('\n');
stream
.write_all(line.as_bytes())
.context("writing request to daemon")?;
stream.flush().context("flushing request to daemon")?;
Ok(())
}
pub fn start_daemon_background() -> Result<bool> {
let config = Config::load()?;
let socket_path = config.socket_path();
let lock_path = socket_path.with_extension("lock");
let mut recovered_once = false;
for attempt in 0..2 {
std::fs::create_dir_all(socket_path.parent().unwrap())?;
let lock_file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lock_path)
.context("opening daemon lock file")?;
let got_lock = lock_file.try_lock().is_ok();
if !got_lock {
tracing::debug!("daemon start already in progress, waiting for socket");
if wait_for_socket(&socket_path, None)? {
if recovered_once {
tracing::info!(
socket = %socket_path.display(),
"daemon startup recovered after retry"
);
}
return Ok(true);
}
if attempt == 0 {
tracing::info!(
socket = %socket_path.display(),
"daemon starter timed out without publishing a ready socket, retrying coordination"
);
std::thread::sleep(DAEMON_START_POLL_INTERVAL);
continue;
}
return Ok(false);
}
if crate::transport::is_reachable(&socket_path) {
let my_epoch = build_epoch();
let is_stale = send_request_with_timeout(
&socket_path,
&Request::Stats(StatsRequest {
include_entries: false,
include_summaries: false,
sort_by: None,
event_hours: None,
client_epoch: my_epoch,
}),
Duration::from_secs(2),
)
.ok()
.and_then(|s| serde_json::from_str::<Response>(&s).ok())
.and_then(|r| r.stats)
.map(|s| client_epoch_is_newer(my_epoch, s.build_epoch))
.unwrap_or(false);
if !is_stale {
tracing::debug!("daemon already running");
return Ok(true);
}
tracing::info!("stale daemon detected, requesting shutdown before restart");
let _ =
send_request_with_timeout(&socket_path, &Request::Shutdown, Duration::from_secs(2));
if !wait_for_run_lock_release(&socket_path, Duration::from_secs(5))? {
tracing::info!(
socket = %socket_path.display(),
"stale daemon did not exit within timeout, attempting bounded recovery"
);
if attempt == 0
&& recover_unhealthy_daemon(
&socket_path,
"stale daemon did not exit after shutdown request",
)?
{
recovered_once = true;
continue;
}
return Ok(false);
}
}
if daemon_run_lock_is_held(&socket_path)? {
tracing::debug!(
socket = %socket_path.display(),
"daemon run lock already held, waiting for socket"
);
if wait_for_socket(&socket_path, None)? {
return Ok(true);
}
if attempt == 0
&& recover_unhealthy_daemon(
&socket_path,
"daemon run lock held but no ready socket became reachable",
)?
{
recovered_once = true;
continue;
}
return Ok(false);
}
let exe = std::env::current_exe().context("getting current executable path")?;
tracing::info!("auto-starting daemon");
let log_path = socket_path.with_extension("log");
rotate_daemon_log_if_large(&log_path);
let stderr_target = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.map(std::process::Stdio::from)
.unwrap_or_else(|_| std::process::Stdio::null());
let _warned = warn_if_remote_is_env_only(&config);
let mut child = spawn_detached_daemon(&exe, stderr_target)?;
let ready = wait_for_socket(&socket_path, Some(&mut child))?;
if ready {
if recovered_once {
tracing::info!(
socket = %socket_path.display(),
"daemon started successfully after recovery"
);
} else {
tracing::info!("daemon started successfully");
}
return Ok(true);
}
if attempt == 0
&& recover_unhealthy_daemon(
&socket_path,
"daemon starter failed to publish a ready socket before timeout",
)?
{
recovered_once = true;
continue;
}
return Ok(false);
}
Ok(false)
}
fn daemon_run_lock_path(socket_path: &Path) -> PathBuf {
socket_path.with_extension("run.lock")
}
fn daemon_run_lock_is_held(socket_path: &Path) -> Result<bool> {
let run_lock_file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(daemon_run_lock_path(socket_path))
.context("opening daemon run lock probe file")?;
Ok(run_lock_file_is_held(&run_lock_file))
}
fn existing_daemon_run_lock_is_held(socket_path: &Path) -> Result<bool> {
match std::fs::OpenOptions::new()
.write(true)
.open(daemon_run_lock_path(socket_path))
{
Ok(file) => Ok(run_lock_file_is_held(&file)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error).context("opening daemon run lock probe file"),
}
}
fn run_lock_file_is_held(file: &std::fs::File) -> bool {
if file.try_lock().is_ok() {
let _ = file.unlock();
false
} else {
true
}
}
const AMBIENT_REMOTE_ENV_VARS: &[&str] = &[
"KACHE_S3_BUCKET",
"KACHE_S3_ENDPOINT",
"KACHE_S3_REGION",
"KACHE_S3_PREFIX",
"KACHE_S3_PROFILE",
"KACHE_S3_USER_AGENT",
"KACHE_LOCAL_ONLY",
"KACHE_REMOTE_READONLY",
];
fn warn_if_remote_is_env_only(config: &Config) -> bool {
let set: Vec<&str> = AMBIENT_REMOTE_ENV_VARS
.iter()
.copied()
.filter(|name| std::env::var_os(name).is_some())
.collect();
if set.is_empty() {
return false;
}
let (file_config, _) = Config::load_raw_file_config();
if file_config
.cache
.as_ref()
.is_some_and(|cache| cache.remote.is_some())
{
return false;
}
let message = format!(
"kache: a remote is configured only in this build's environment ({vars}), and the \n \
background daemon does not inherit it — the daemon will run local-only.\n \
A daemon outlives the build that starts it and cannot watch an environment for \n \
changes, so an inherited remote would silently depend on which build happened to \n \
start it (kunobi-ninja/kache#706).\n \
Fix: move the remote into `[cache.remote]` in {path}, or start the daemon \n \
yourself with `kache daemon run` from this environment.",
vars = set.join(", "),
path = crate::config::resolve_config_path().display(),
);
let marker = crate::wrapper::warn_marker_path("daemon-remote-env", &config.cache_dir);
crate::wrapper::warn_once_per_session(&marker, crate::wrapper::WARN_SESSION_SECS, &message);
true
}
fn strip_ambient_remote_env(command: &mut std::process::Command) {
for name in AMBIENT_REMOTE_ENV_VARS {
command.env_remove(name);
}
}
fn spawn_detached_daemon(
exe: &Path,
stderr_target: std::process::Stdio,
) -> Result<std::process::Child> {
let mut command = std::process::Command::new(exe);
command
.args(["daemon", "run"])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(stderr_target);
strip_ambient_remote_env(&mut command);
#[cfg(windows)]
let spawned = {
let _guard = NonInheritableStdio::acquire();
command.spawn()
};
#[cfg(not(windows))]
let spawned = command.spawn();
spawned.context("spawning daemon process")
}
#[cfg(windows)]
struct NonInheritableStdio {
restore: Vec<windows_sys::Win32::Foundation::HANDLE>,
}
#[cfg(windows)]
impl NonInheritableStdio {
fn acquire() -> Self {
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Foundation::{HANDLE, HANDLE_FLAG_INHERIT, SetHandleInformation};
let handles: [HANDLE; 3] = [
std::io::stdin().as_raw_handle(),
std::io::stdout().as_raw_handle(),
std::io::stderr().as_raw_handle(),
];
let mut restore = Vec::new();
for handle in handles {
if handle.is_null() || handle == windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE {
continue;
}
let cleared = unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, 0) };
if cleared != 0 {
restore.push(handle);
}
}
Self { restore }
}
}
#[cfg(windows)]
impl Drop for NonInheritableStdio {
fn drop(&mut self) {
use windows_sys::Win32::Foundation::{HANDLE_FLAG_INHERIT, SetHandleInformation};
for handle in &self.restore {
unsafe {
SetHandleInformation(*handle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT);
}
}
}
}
fn wait_for_socket(socket_path: &Path, child: Option<&mut std::process::Child>) -> Result<bool> {
wait_for_socket_until(socket_path, child, DAEMON_START_TIMEOUT)
}
fn wait_for_socket_until(
socket_path: &Path,
mut child: Option<&mut std::process::Child>,
timeout: Duration,
) -> Result<bool> {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if crate::transport::is_reachable(socket_path) {
return Ok(true);
}
if let Some(child_proc) = child.as_mut()
&& let Some(status) = child_proc
.try_wait()
.context("checking daemon process status")?
{
if status.success() {
tracing::debug!(
socket = %socket_path.display(),
?status,
"daemon starter exited cleanly before socket became ready, continuing to wait"
);
child = None;
continue;
}
tracing::warn!(
socket = %socket_path.display(),
?status,
"daemon exited before socket became ready"
);
return Ok(false);
}
std::thread::sleep(DAEMON_START_POLL_INTERVAL);
}
if crate::transport::is_reachable(socket_path) {
return Ok(true);
}
if let Some(child) = child.as_mut()
&& child
.try_wait()
.context("checking daemon process status after timeout")?
.is_none()
{
tracing::debug!(
socket = %socket_path.display(),
timeout_ms = timeout.as_millis(),
"daemon did not start within timeout, terminating starter process"
);
let _ = child.kill();
let _ = child.wait();
}
tracing::warn!(
socket = %socket_path.display(),
timeout_ms = timeout.as_millis(),
"daemon did not start within timeout"
);
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::mpsc;
#[test]
fn auto_spawned_daemon_does_not_inherit_ambient_remote_env() {
let mut command = std::process::Command::new("kache");
strip_ambient_remote_env(&mut command);
let removed: Vec<&str> = command
.get_envs()
.filter(|(_, value)| value.is_none())
.filter_map(|(name, _)| name.to_str())
.collect();
for name in AMBIENT_REMOTE_ENV_VARS {
assert!(
removed.contains(name),
"{name} must be cleared from the daemon spawn"
);
}
assert_eq!(
removed.len(),
AMBIENT_REMOTE_ENV_VARS.len(),
"unexpected extra removals: {removed:?}"
);
assert!(
command.get_envs().all(|(_, value)| value.is_none()),
"the spawn should only remove variables, never set them"
);
}
struct EnvVarForTest {
name: String,
previous: Option<std::ffi::OsString>,
}
impl EnvVarForTest {
fn set(name: &str, value: &std::ffi::OsStr) -> Self {
let previous = std::env::var_os(name);
unsafe { std::env::set_var(name, value) };
Self {
name: name.to_string(),
previous,
}
}
fn remove(name: &str) -> Self {
let previous = std::env::var_os(name);
unsafe { std::env::remove_var(name) };
Self {
name: name.to_string(),
previous,
}
}
}
impl Drop for EnvVarForTest {
fn drop(&mut self) {
match self.previous.take() {
Some(value) => unsafe { std::env::set_var(&self.name, value) },
None => unsafe { std::env::remove_var(&self.name) },
}
}
}
#[test]
fn env_only_remote_warns_but_a_file_configured_remote_does_not() {
let _lock = crate::config::tests::config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config = test_config(&dir.path().join("cache"));
let config_path = dir.path().join("config.toml");
let restore_config = EnvVarForTest::set("KACHE_CONFIG", config_path.as_os_str());
std::fs::write(&config_path, "[cache]\n").unwrap();
let clear: Vec<_> = AMBIENT_REMOTE_ENV_VARS
.iter()
.map(|name| EnvVarForTest::remove(name))
.collect();
assert!(!warn_if_remote_is_env_only(&config));
let _bucket = EnvVarForTest::set("KACHE_S3_BUCKET", std::ffi::OsStr::new("some-bucket"));
assert!(
warn_if_remote_is_env_only(&config),
"an env-only remote must warn"
);
std::fs::write(
&config_path,
"[cache.remote]\ntype = \"s3\"\nbucket = \"from-file\"\n",
)
.unwrap();
assert!(
!warn_if_remote_is_env_only(&config),
"a file-configured remote must stay quiet"
);
drop(clear);
drop(restore_config);
}
#[test]
fn ambient_remote_env_list_covers_every_remote_deciding_var() {
let documented = [
"KACHE_S3_BUCKET",
"KACHE_S3_ENDPOINT",
"KACHE_S3_REGION",
"KACHE_S3_PREFIX",
"KACHE_S3_PROFILE",
"KACHE_S3_USER_AGENT",
"KACHE_LOCAL_ONLY",
"KACHE_REMOTE_READONLY",
];
assert_eq!(
AMBIENT_REMOTE_ENV_VARS, &documented,
"remote-deciding env vars changed: update the strip list too (#706)"
);
}
#[test]
fn remote_check_demand_budget_keeps_legacy_cap_and_only_allows_tightening() {
for (case, configured_secs, wire_ms, expected_ms) in [
("legacy client / default daemon", 300, None, 3_000),
("legacy client / disabled daemon deadline", 0, None, 3_000),
("overflowing daemon config", u64::MAX, None, 3_000),
("daemon tightens", 2, None, 2_000),
("daemon tighter than client", 1, Some(3_000), 1_000),
("client tightens", 300, Some(1_500), 1_500),
("zero wire value", 300, Some(0), 3_000),
("oversized wire value", 300, Some(u64::MAX), 3_000),
] {
assert_eq!(
remote_check_budget_ms(configured_secs, wire_ms).get(),
expected_ms,
"{case}"
);
}
let legacy_json = format!(
r#"{{"remote_check":{{"key":"{}","entry_dir":"/tmp/entry","crate_name":"serde"}}}}"#,
"a".repeat(64)
);
let Request::RemoteCheck(legacy_request) =
serde_json::from_str::<Request>(&legacy_json).unwrap()
else {
panic!("expected remote-check request");
};
assert_eq!(legacy_request.deadline_ms, None);
assert_eq!(
remote_check_budget_ms(300, legacy_request.deadline_ms).get(),
3_000
);
let accepted_at = Instant::now();
let legacy_client_deadline =
RemoteDeadline::from_millis_at(accepted_at, remote_check_budget_ms(300, None).get());
assert_eq!(
legacy_client_deadline.at(),
Some(accepted_at + Duration::from_secs(3))
);
}
#[test]
fn should_cancel_prefetch_fires_on_low_candidate_share() {
assert!(should_cancel_prefetch(12, 1, 0));
}
#[test]
fn should_cancel_prefetch_holds_below_min_demands() {
assert!(!should_cancel_prefetch(9, 0, 0));
}
#[test]
fn should_cancel_prefetch_holds_when_plan_is_good() {
assert!(!should_cancel_prefetch(20, 15, 0));
}
#[test]
fn should_cancel_prefetch_counts_undmanded_downloads_as_potential_hits() {
assert!(should_cancel_prefetch(20, 2, 0));
assert!(!should_cancel_prefetch(20, 2, 8));
}
#[test]
fn active_plan_tracks_demand_download_and_use() {
let mut plan = ActivePlan::new(
"sess-1".into(),
"plan-1".into(),
"fallback",
["a", "b"].into_iter().map(String::from).collect(),
0,
0,
);
assert!(!plan.record_demand("a"));
assert_eq!(plan.demanded.len(), 1);
assert_eq!(plan.demanded_candidates.len(), 1);
assert!(plan.used.is_empty());
plan.record_download("a", 100);
assert!(plan.used.contains("a"));
plan.record_download("b", 50);
assert!(!plan.record_demand("b"));
assert!(plan.used.contains("b"));
assert_eq!(plan.used_bytes(), 150);
assert!(!plan.record_demand("a"));
assert_eq!(plan.demanded.len(), 2);
}
#[test]
fn active_plan_cancel_latch_fires_once() {
let mut plan = ActivePlan::new(
"sess-2".into(),
String::new(),
"advisory",
["only-candidate".to_string()].into_iter().collect(),
0,
0,
);
for i in 0..9 {
assert!(!plan.record_demand(&format!("k{i}")));
}
assert!(plan.record_demand("k9"));
assert!(plan.cancelled);
assert!(!plan.record_demand("k10"));
}
use crate::transport::{ListenerOptions, TokioListener, TokioStream, socket_name};
fn bind_listener(path: &Path) -> TokioListener {
let name = socket_name(path).expect("socket name");
ListenerOptions::new()
.name(name)
.create_tokio()
.expect("create_tokio listener")
}
async fn connect_stream(path: &Path) -> TokioStream {
let name = socket_name(path).expect("socket name");
TokioStream::connect(name).await.expect("connect")
}
fn bind_sync_listener(path: &Path) -> interprocess::local_socket::Listener {
let name = socket_name(path).expect("socket name");
ListenerOptions::new()
.name(name)
.create_sync()
.expect("create_sync listener")
}
fn spawn_quick_exit_child() -> std::process::Child {
#[cfg(unix)]
{
std::process::Command::new("sh")
.args(["-c", "exit 0"])
.spawn()
.unwrap()
}
#[cfg(windows)]
{
std::process::Command::new("cmd")
.args(["/c", "exit", "0"])
.spawn()
.unwrap()
}
}
fn spawn_blocking_child() -> std::process::Child {
#[cfg(unix)]
let mut child = std::process::Command::new("sh")
.args(["-c", "sleep 30"])
.spawn()
.unwrap();
#[cfg(windows)]
let mut child = std::process::Command::new("powershell.exe")
.args([
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-Command",
"Start-Sleep -Seconds 30",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.unwrap();
assert!(
child.try_wait().unwrap().is_none(),
"blocking test child exited during setup"
);
child
}
async fn client_roundtrip(socket_path: &Path, req: &Request) -> Response {
let mut stream = connect_stream(socket_path).await;
let mut line = serde_json::to_string(req).expect("serialize request");
line.push('\n');
stream
.write_all(line.as_bytes())
.await
.expect("write request");
let mut resp_line = String::new();
{
let mut reader = BufReader::new(&stream);
reader
.read_line(&mut resp_line)
.await
.expect("read response");
}
drop(stream);
serde_json::from_str(&resp_line).expect("parse response")
}
async fn one_shot_request(daemon: &Arc<Daemon>, socket_path: &Path, req: &Request) -> Response {
let listener = bind_listener(socket_path);
let server_daemon = daemon.clone();
let server = tokio::spawn(async move {
let stream = listener.accept().await.expect("accept");
handle_connection(
stream,
&server_daemon,
&AtomicBool::new(false),
&Notify::new(),
)
.await
.expect("handle_connection");
});
let resp = client_roundtrip(socket_path, req).await;
server.await.expect("join server task");
resp
}
#[test]
fn in_flight_registry_upserts_prunes_and_snapshots() {
let dir = tempfile::tempdir().unwrap();
let daemon = Daemon::new(test_config(dir.path()));
let now = unix_ms();
let pid = std::process::id();
daemon.handle_compile_started(CompileStartedRequest {
crate_name: "gkrust".into(),
root: "/w".into(),
pid,
started_at_ms: now.saturating_sub(10_000),
typical_ms: None,
client_epoch: 0,
});
daemon.handle_compile_started(CompileStartedRequest {
crate_name: "gkrust".into(),
root: "/w".into(),
pid,
started_at_ms: now.saturating_sub(10_000),
typical_ms: Some(471_000),
client_epoch: 0,
});
daemon.handle_compile_started(CompileStartedRequest {
crate_name: "ghost".into(),
root: "/w".into(),
pid: pid.wrapping_add(1),
started_at_ms: now.saturating_sub(IN_FLIGHT_MAX_AGE_MS + 60_000),
typical_ms: None,
client_epoch: 0,
});
let snapshot = daemon.in_flight_snapshot();
assert_eq!(
snapshot.len(),
1,
"ghost pruned, upsert deduped: {snapshot:?}"
);
let entry = &snapshot[0];
assert_eq!(entry.crate_name, "gkrust");
assert_eq!(entry.pid, pid);
assert!(entry.elapsed_s >= 10);
assert_eq!(entry.typical_s, Some(471));
assert_eq!(entry.eta_s, Some(471u64.saturating_sub(entry.elapsed_s)));
daemon.handle_compile_finished(&CompileFinishedRequest {
pid,
started_at_ms: 12345,
});
assert_eq!(daemon.in_flight_snapshot().len(), 1);
daemon.handle_compile_finished(&CompileFinishedRequest {
pid,
started_at_ms: now.saturating_sub(10_000),
});
assert!(daemon.in_flight_snapshot().is_empty());
}
#[test]
fn compile_started_wire_tags_and_stats_default() {
let req = Request::CompileStarted(CompileStartedRequest {
crate_name: "c".into(),
root: String::new(),
pid: 1,
started_at_ms: 2,
typical_ms: None,
client_epoch: 0,
});
let wire = serde_json::to_string(&req).unwrap();
assert!(wire.contains("\"compile_started\""), "{wire}");
let round: Request = serde_json::from_str(&wire).unwrap();
assert_eq!(round, req);
let mut old = serde_json::to_value(StatsResponse {
total_size: 0,
max_size: 0,
entry_count: 0,
entries: None,
events: EventStatsResponse {
local_hits: 0,
prefetch_hits: 0,
remote_hits: 0,
dups: 0,
misses: 0,
errors: 0,
total_elapsed_ms: 0,
hit_elapsed_ms: 0,
miss_elapsed_ms: 0,
hit_compile_time_ms: 0,
miss_compile_time_ms: 0,
store_output_blobs: 0,
store_duplicate_blobs: 0,
store_new_blobs: 0,
},
blob_stats: None,
recent_summaries: Vec::new(),
version: String::new(),
build_epoch: 0,
gc_policy_version: GC_POLICY_PROTOCOL_VERSION,
pending_uploads: 0,
active_downloads: 0,
s3_concurrency_total: 0,
s3_concurrency_used: 0,
upload_queue_capacity: 0,
uploads_completed: 0,
uploads_failed: 0,
uploads_skipped: 0,
uploads_suppressed: 0,
downloads_completed: 0,
downloads_failed: 0,
downloads_suppressed: 0,
remote_check_roundtrips: 0,
negative_hits: 0,
negative_entries: 0,
remote_degraded: false,
bytes_uploaded: 0,
bytes_downloaded: 0,
recent_transfers: Vec::new(),
prefetch: PrefetchStatsSnapshot::default(),
in_flight: vec![InFlightEntry {
crate_name: "x".into(),
root: String::new(),
pid: 1,
elapsed_s: 1,
typical_s: None,
eta_s: None,
}],
effective_config: Some(EffectiveConfig {
max_size: 1,
cache_dir: "/c".into(),
runtime_dir: "/c".into(),
config_path: "/c/config.toml".into(),
config_fingerprint: Some("fingerprint".into()),
prefetch_enabled: true,
remote_description: None,
local_only: false,
remote_error: None,
remote_key_cache_refresh_secs: 60,
socket_path: "/c/daemon.sock".into(),
started_at_ms: 1,
}),
})
.unwrap();
{
let old_obj = old.as_object_mut().unwrap();
old_obj.remove("in_flight");
old_obj.remove("blob_stats");
old_obj.remove("recent_summaries");
old_obj.remove("gc_policy_version");
}
let mut old_effective = old.get("effective_config").unwrap().clone();
let old_effective_obj = old_effective.as_object_mut().unwrap();
old_effective_obj.remove("remote_key_cache_refresh_secs");
old_effective_obj.remove("runtime_dir");
let parsed_effective: EffectiveConfig = serde_json::from_value(old_effective).unwrap();
assert_eq!(
parsed_effective.remote_key_cache_refresh_secs,
crate::config::DEFAULT_REMOTE_KEY_CACHE_REFRESH_SECS,
"an older daemon report must deserialize with the historical cadence"
);
assert!(parsed_effective.runtime_dir.is_empty());
old.as_object_mut().unwrap().remove("effective_config");
let parsed: StatsResponse = serde_json::from_value(old).unwrap();
assert!(parsed.in_flight.is_empty());
assert!(parsed.blob_stats.is_none());
assert!(parsed.recent_summaries.is_empty());
assert!(parsed.effective_config.is_none());
assert_eq!(parsed.gc_policy_version, 0);
}
#[tokio::test]
async fn test_shutdown_request_sets_flag_and_stores_notify_permit() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let socket_path = config.socket_path();
std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap();
let listener = bind_listener(&socket_path);
let daemon = Arc::new(Daemon::new(config));
let shutdown_flag = Arc::new(AtomicBool::new(false));
let shutdown_notify = Arc::new(Notify::new());
let server_daemon = daemon.clone();
let server_flag = shutdown_flag.clone();
let server_notify = shutdown_notify.clone();
let server = tokio::spawn(async move {
let stream = listener.accept().await.expect("accept");
handle_connection(stream, &server_daemon, &server_flag, &server_notify)
.await
.expect("handle_connection");
});
let resp = client_roundtrip(&socket_path, &Request::Shutdown).await;
server.await.expect("join server task");
assert!(resp.ok, "stop request should return ok");
assert!(
shutdown_flag.load(Ordering::Relaxed),
"stop request must set the shutdown flag"
);
tokio::time::timeout(Duration::from_secs(1), shutdown_notify.notified())
.await
.expect("stop request must leave a notify permit (issue #288)");
}
#[tokio::test]
async fn test_accept_loop_breaks_promptly_on_stop_request() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let socket_path = config.socket_path();
std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap();
let listener = bind_listener(&socket_path);
let daemon = Arc::new(Daemon::new(config));
let shutdown_flag = Arc::new(AtomicBool::new(false));
let shutdown_notify = Arc::new(Notify::new());
let client_socket = socket_path.clone();
let client =
tokio::spawn(async move { client_roundtrip(&client_socket, &Request::Shutdown).await });
let outcome = tokio::time::timeout(
Duration::from_secs(5),
accept_loop(
&listener,
&daemon,
&shutdown_flag,
&shutdown_notify,
None,
std::future::pending::<()>(),
),
)
.await;
assert!(
outcome.is_ok(),
"accept_loop did not break within 5s of a stop request (issue #288 regression)"
);
assert!(
shutdown_flag.load(Ordering::Relaxed),
"shutdown flag should be set after the stop request"
);
let resp = client.await.expect("join client task");
assert!(resp.ok, "stop request should return ok");
}
#[tokio::test]
async fn test_send_request_with_timeout_bounds_unresponsive_daemon() {
let dir = tempfile::tempdir().unwrap();
let socket_path = dir.path().join("daemon.sock");
let listener = bind_listener(&socket_path);
let server = tokio::spawn(async move {
let stream = listener.accept().await.expect("accept");
let mut request_line = String::new();
{
let mut reader = BufReader::new(&stream);
reader
.read_line(&mut request_line)
.await
.expect("read request");
}
assert!(request_line.contains("\"stats\""));
tokio::time::sleep(Duration::from_secs(1)).await;
drop(stream);
});
let req = Request::Stats(StatsRequest {
include_entries: false,
include_summaries: false,
sort_by: None,
event_hours: None,
client_epoch: 0,
});
let client_socket_path = socket_path.clone();
let started = Instant::now();
let result = tokio::task::spawn_blocking(move || {
send_request_with_timeout(&client_socket_path, &req, Duration::from_millis(75))
})
.await
.expect("join client task");
assert!(result.is_err());
assert!(started.elapsed() < Duration::from_millis(750));
server.abort();
}
fn spawn_blocking_child_holding_run_lock(
socket_path: &Path,
) -> (u32, std::thread::JoinHandle<std::process::ExitStatus>) {
let run_lock_path = socket_path.with_extension("run.lock");
let (tx, rx) = mpsc::channel();
let handle = std::thread::spawn(move || {
let file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&run_lock_path)
.unwrap();
file.lock().unwrap();
let mut child = spawn_blocking_child();
tx.send(child.id()).unwrap();
let status = child.wait().unwrap();
let _ = file.unlock();
status
});
(rx.recv().unwrap(), handle)
}
fn hold_run_lock_until_released(
socket_path: &Path,
) -> (mpsc::Sender<()>, std::thread::JoinHandle<()>) {
let run_lock_path = socket_path.with_extension("run.lock");
let (ready_tx, ready_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel();
let handle = std::thread::spawn(move || {
let file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&run_lock_path)
.unwrap();
file.lock().unwrap();
ready_tx.send(()).unwrap();
release_rx
.recv_timeout(Duration::from_secs(30))
.expect("test did not explicitly release the daemon run lock");
let _ = file.unlock();
});
ready_rx.recv().unwrap();
(release_tx, handle)
}
fn test_config(dir: &Path) -> Config {
Config {
fallback: None,
key_salt: None,
cc_extra_allowlist_flags: Vec::new(),
local_only: false,
remote_readonly: false,
modified_input_guard: false,
local_hit_daemon: false,
windows_hardlink: false,
auto_gc: true,
storage_layout_advice: true,
heartbeat_secs: 30,
explain_miss: false,
path_only_env_vars: Vec::new(),
incremental_crates: Vec::new(),
key_env_vars: Vec::new(),
base_dirs: Vec::new(),
cache_dir: dir.to_path_buf(),
runtime_dir: dir.to_path_buf(),
socket_path_override: None,
max_size: 50 * 1024 * 1024, remote: None,
remote_error: None,
disabled: false,
cache_executables: false,
clean_incremental: false,
preserve_incremental: false,
adaptive_incremental: true,
event_log_max_size: 10 * 1024 * 1024,
event_log_keep_lines: 1000,
compression_level: 3,
s3_concurrency: 16,
prefetch_enabled: crate::config::DEFAULT_PREFETCH_ENABLED,
remote_key_cache_refresh_secs: crate::config::DEFAULT_REMOTE_KEY_CACHE_REFRESH_SECS,
prefetch_max_keys: crate::config::DEFAULT_PREFETCH_MAX_KEYS,
prefetch_max_bytes: crate::config::DEFAULT_PREFETCH_MAX_BYTES,
prefetch_deadline_secs: crate::config::DEFAULT_PREFETCH_DEADLINE_SECS,
min_store_compile_ms: crate::config::DEFAULT_MIN_STORE_COMPILE_MS,
gc_max_age_hours: crate::config::DEFAULT_GC_MAX_AGE_HOURS,
daemon_idle_timeout_secs: crate::config::DEFAULT_DAEMON_IDLE_TIMEOUT_SECS,
s3_pool_idle_secs: crate::config::DEFAULT_S3_POOL_IDLE_SECS,
remote_restore_timeout_secs: crate::config::DEFAULT_REMOTE_RESTORE_TIMEOUT_SECS,
remote_negative_ttl_secs: crate::config::DEFAULT_REMOTE_NEGATIVE_TTL_SECS,
}
}
fn test_cache_key(label: &str) -> String {
blake3::hash(label.as_bytes()).to_hex().to_string()
}
fn latest_transfer(daemon: &Daemon) -> TransferEvent {
daemon
.recent_transfers
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.back()
.cloned()
.expect("transfer event")
}
fn assert_v3_transfer_timestamps(transfer: &TransferEvent) {
assert_eq!(transfer.schema, 3);
assert!(
transfer.started_at_unix_ms > 1_000_000_000_000,
"transfer start must be Unix epoch milliseconds: {transfer:?}"
);
assert!(
transfer.finished_at_unix_ms >= transfer.started_at_unix_ms,
"transfer finish must not precede its start: {transfer:?}"
);
assert_eq!(
transfer.timestamp,
transfer.finished_at_unix_ms / 1_000,
"legacy seconds timestamp must match the exact millisecond finish"
);
}
#[test]
fn key_cache_authoritative_truth_table() {
assert!(key_cache_miss_is_authoritative(1, Some(Duration::ZERO)));
assert!(key_cache_miss_is_authoritative(
1,
Some(Duration::from_secs(5))
));
assert!(!key_cache_miss_is_authoritative(
1,
Some(Duration::from_secs(6))
));
assert!(key_cache_miss_is_authoritative(
60,
Some(Duration::from_secs(300))
));
assert!(!key_cache_miss_is_authoritative(
60,
Some(Duration::from_secs(301))
));
assert!(key_cache_miss_is_authoritative(
900,
Some(Duration::from_secs(300))
));
assert!(!key_cache_miss_is_authoritative(
900,
Some(Duration::from_secs(301))
));
assert!(!key_cache_miss_is_authoritative(0, Some(Duration::ZERO)));
assert!(!key_cache_miss_is_authoritative(60, None));
}
#[test]
fn speculative_prefetch_decision_truth_table() {
assert!(speculative_prefetch_disabled(false));
assert!(!speculative_prefetch_disabled(true));
assert!(should_start_speculative_prefetch(true, true));
assert!(!should_start_speculative_prefetch(false, true));
assert!(!should_start_speculative_prefetch(true, false));
assert!(!should_start_speculative_prefetch(false, false));
}
#[test]
fn key_cache_periodic_refresh_disabled_truth_table() {
assert!(key_cache_periodic_refresh_disabled(0));
assert!(!key_cache_periodic_refresh_disabled(1));
assert!(!key_cache_periodic_refresh_disabled(60));
}
#[test]
fn upload_retry_and_idle_timeout_truth_tables() {
assert!(upload_result_is_terminal(None));
assert!(upload_result_is_terminal(Some("local: missing payload")));
assert!(!upload_result_is_terminal(Some("retryable: remote outage")));
assert_eq!(daemon_idle_timeout(0), None);
assert_eq!(daemon_idle_timeout(1), Some(Duration::from_secs(1)));
assert_eq!(daemon_idle_timeout(60), Some(Duration::from_secs(60)));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn server_main_binds_socket_and_handles_shutdown() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.daemon_idle_timeout_secs = 0;
let socket_path = config.socket_path();
let coord = DaemonCoordFile::for_socket(&socket_path);
let server_config = config.clone();
let provenance = crate::config::config_file_provenance_at(dir.path().join("config.toml"));
let server =
tokio::spawn(async move { server_main(&server_config, &provenance, coord).await });
let ready_socket = socket_path.clone();
let ready = tokio::task::spawn_blocking(move || {
wait_for_socket_until(&ready_socket, None, Duration::from_secs(5))
})
.await
.unwrap()
.unwrap();
assert!(ready, "server_main must bind its configured socket");
let shutdown_config = config.clone();
tokio::task::spawn_blocking(move || send_shutdown_request(&shutdown_config))
.await
.unwrap()
.unwrap();
let result = tokio::time::timeout(Duration::from_secs(10), server)
.await
.expect("server_main should stop after a shutdown request")
.expect("server_main task should not panic");
assert!(
result.is_ok(),
"server_main should exit cleanly: {result:?}"
);
assert!(
!socket_path.exists(),
"server_main should remove its socket during shutdown"
);
}
#[test]
fn test_request_upload_serde() {
let req = Request::Upload(UploadJob {
key: "abc123".into(),
entry_dir: "/tmp/store/abc123".into(),
crate_name: String::new(),
client_epoch: 0,
});
let json = serde_json::to_string(&req).unwrap();
let parsed: Request = serde_json::from_str(&json).unwrap();
assert_eq!(req, parsed);
assert!(json.contains("\"upload\""));
assert!(json.contains("\"key\":\"abc123\""));
}
#[test]
fn test_wait_for_socket_until_observes_late_socket() {
let dir = tempfile::tempdir().unwrap();
let socket_path = dir.path().join("daemon.sock");
let socket_path_bg = socket_path.clone();
let (done_tx, done_rx) = mpsc::channel::<()>();
let handle = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(150));
let listener = bind_sync_listener(&socket_path_bg);
done_rx
.recv_timeout(Duration::from_secs(60))
.expect("test did not report a wait result");
drop(listener);
});
let ready = wait_for_socket_until(&socket_path, None, Duration::from_secs(30)).unwrap();
done_tx.send(()).unwrap();
handle.join().unwrap();
assert!(ready);
}
#[test]
fn test_wait_for_socket_until_times_out_cleanly() {
let dir = tempfile::tempdir().unwrap();
let socket_path = dir.path().join("missing.sock");
let ready = wait_for_socket_until(&socket_path, None, Duration::from_millis(150)).unwrap();
assert!(!ready);
}
#[test]
fn test_wait_for_socket_until_ignores_clean_child_exit_if_socket_appears() {
let dir = tempfile::tempdir().unwrap();
let socket_path = dir.path().join("daemon.sock");
let socket_path_bg = socket_path.clone();
let (done_tx, done_rx) = mpsc::channel::<()>();
let handle = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(150));
let listener = bind_sync_listener(&socket_path_bg);
done_rx
.recv_timeout(Duration::from_secs(60))
.expect("main thread should signal before the bound");
drop(listener);
});
let mut child = spawn_quick_exit_child();
let ready =
wait_for_socket_until(&socket_path, Some(&mut child), Duration::from_secs(30)).unwrap();
done_tx.send(()).ok();
handle.join().unwrap();
assert!(ready);
}
#[test]
fn test_wait_for_socket_until_kills_stuck_child_after_timeout() {
let dir = tempfile::tempdir().unwrap();
let socket_path = dir.path().join("missing.sock");
let mut child = spawn_blocking_child();
let ready =
wait_for_socket_until(&socket_path, Some(&mut child), Duration::from_millis(150))
.unwrap();
assert!(!ready);
let status = child.try_wait().unwrap();
assert!(status.is_some());
}
#[test]
fn decode_request_frame_strips_trailing_carriage_return() {
assert_eq!(decode_request_frame(b"{\"x\":1}\r"), "{\"x\":1}");
assert_eq!(decode_request_frame(b"{\"x\":1}"), "{\"x\":1}");
assert_eq!(decode_request_frame(b""), "");
}
#[test]
fn is_client_disconnect_matches_disconnect_kinds() {
use std::io::{Error, ErrorKind};
assert!(is_client_disconnect(&Error::from(ErrorKind::BrokenPipe)));
assert!(is_client_disconnect(&Error::from(
ErrorKind::ConnectionReset
)));
assert!(is_client_disconnect(&Error::from_raw_os_error(32))); assert!(!is_client_disconnect(&Error::from(ErrorKind::NotFound)));
assert!(!is_client_disconnect(&Error::from(ErrorKind::TimedOut)));
}
#[test]
fn key_prefix_is_multibyte_safe() {
let hex = "0123456789abcdef".repeat(4);
assert_eq!(key_prefix(&hex), "0123456789abcdef");
assert_eq!(key_prefix("short"), "short");
assert_eq!(key_prefix(""), "");
let s = "アアアアアアアア"; let p = key_prefix(s);
assert!(s.starts_with(p));
assert!(p.len() <= 16);
}
#[test]
fn client_epoch_comparison_ignores_zero_and_detects_newer() {
assert!(!client_epoch_is_newer(0, 10));
assert!(!client_epoch_is_newer(10, 0));
assert!(!client_epoch_is_newer(10, 10));
assert!(!client_epoch_is_newer(9, 10));
assert!(client_epoch_is_newer(11, 10));
}
#[test]
fn send_retry_delay_uses_linear_backoff_and_pid_jitter() {
assert_eq!(send_retry_delay(1, 7), Duration::from_millis(100 + 49));
assert_eq!(send_retry_delay(3, 8), Duration::from_millis(300 + 6));
}
#[test]
fn key_cache_refresh_warning_cadence_is_first_and_every_tenth() {
assert!(should_warn_key_cache_refresh_failure(1));
assert!(!should_warn_key_cache_refresh_failure(2));
assert!(!should_warn_key_cache_refresh_failure(9));
assert!(should_warn_key_cache_refresh_failure(10));
assert!(should_warn_key_cache_refresh_failure(20));
}
#[test]
fn rotate_daemon_log_if_large_truncates_only_oversized_logs() {
let dir = tempfile::tempdir().unwrap();
let small = dir.path().join("small.log");
std::fs::write(&small, b"small log").unwrap();
rotate_daemon_log_if_large(&small);
assert_eq!(std::fs::read(&small).unwrap(), b"small log");
let large = dir.path().join("large.log");
std::fs::write(&large, vec![b'x'; 2 * 1024 * 1024 + 1]).unwrap();
rotate_daemon_log_if_large(&large);
assert_eq!(std::fs::read(&large).unwrap(), b"--- log rotated ---\n");
}
#[test]
fn daemon_state_path_uses_state_json_extension() {
assert_eq!(
daemon_state_path(Path::new("/tmp/kache/daemon.sock")),
Path::new("/tmp/kache/daemon.state.json")
);
}
#[test]
fn daemon_state_is_recent_distinguishes_fresh_from_stale() {
let fresh = DaemonCoordState {
pid: 1,
build_epoch: build_epoch(),
phase: DaemonPhase::Ready,
updated_at_ms: now_millis(),
};
assert!(daemon_state_is_recent(&fresh));
let stale = DaemonCoordState {
pid: 1,
build_epoch: build_epoch(),
phase: DaemonPhase::Ready,
updated_at_ms: now_millis()
.saturating_sub(DAEMON_COORD_STALE_AFTER.as_millis() as u64 * 2),
};
assert!(!daemon_state_is_recent(&stale));
let future = DaemonCoordState {
pid: 1,
build_epoch: build_epoch(),
phase: DaemonPhase::Ready,
updated_at_ms: now_millis() + DAEMON_COORD_STALE_AFTER.as_millis() as u64 * 2,
};
assert!(!daemon_state_is_recent(&future));
}
#[test]
fn test_daemon_coord_state_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let socket_path = dir.path().join("daemon.sock");
let coord = DaemonCoordFile::for_socket(&socket_path);
coord.write_phase(DaemonPhase::Starting).unwrap();
let state = read_daemon_state(&socket_path).unwrap();
assert_eq!(state.pid, std::process::id());
assert_eq!(state.build_epoch, build_epoch());
assert_eq!(state.phase, DaemonPhase::Starting);
assert!(daemon_state_is_recent(&state));
}
#[cfg(unix)]
#[test]
fn comm_is_daemon_exe_accepts_only_the_kache_executable() {
assert!(super::comm_is_daemon_exe("/Users/x/.cargo/bin/kache"));
assert!(super::comm_is_daemon_exe("kache"));
assert!(super::comm_is_daemon_exe(" /usr/local/bin/kache "));
assert!(super::comm_is_daemon_exe("./target/debug/kache"));
assert!(!super::comm_is_daemon_exe("/bin/sh"));
assert!(!super::comm_is_daemon_exe("zsh"));
assert!(!super::comm_is_daemon_exe("vim"));
assert!(!super::comm_is_daemon_exe("kache-wrapper"));
assert!(!super::comm_is_daemon_exe("mykache"));
assert!(!super::comm_is_daemon_exe(""));
assert!(!super::comm_is_daemon_exe(" "));
}
#[cfg(unix)]
#[test]
fn pid_alive_rejects_broadcast_pids() {
assert!(super::pid_alive(std::process::id()));
assert!(!super::pid_alive(0), "0 is the caller's process group");
assert!(!super::pid_alive(1), "1 is init/launchd, never a compile");
assert!(
!super::pid_alive(u32::MAX),
"u32::MAX casts to the -1 broadcast"
);
}
#[cfg(unix)]
#[test]
fn process_comm_reports_the_executable_behind_a_pid() {
if find_in_path("ps").is_none() {
return;
}
let comm =
super::process_comm(std::process::id()).expect("ps is present, so it must answer");
let name = std::path::Path::new(&comm)
.file_name()
.expect("comm has a file name")
.to_string_lossy()
.into_owned();
assert!(
name.starts_with("kache"),
"expected the test binary's own executable name, got {comm:?}"
);
assert_eq!(super::process_comm(0), None);
}
#[test]
fn cmdline_is_daemon_run_matches_only_the_daemon_subcommand() {
assert!(super::cmdline_is_daemon_run(r"C:\bin\kache.exe daemon run"));
assert!(super::cmdline_is_daemon_run(
"kache.exe daemon run --foreground"
));
assert!(super::cmdline_is_daemon_run(
r#""C:\Program Files\kache.exe" daemon run"#
));
assert!(!super::cmdline_is_daemon_run("kache.exe build"));
assert!(!super::cmdline_is_daemon_run("kache.exe daemon status"));
assert!(!super::cmdline_is_daemon_run("kache.exe daemon stop"));
assert!(!super::cmdline_is_daemon_run("kache.exe daemon"));
assert!(!super::cmdline_is_daemon_run("kache.exe run"));
assert!(!super::cmdline_is_daemon_run(""));
}
#[cfg(unix)]
fn find_in_path(name: &str) -> Option<std::path::PathBuf> {
std::env::split_paths(&std::env::var_os("PATH")?)
.map(|dir| dir.join(name))
.find(|candidate| candidate.is_file())
}
#[cfg(unix)]
#[test]
fn find_daemon_pids_finds_a_process_running_the_kache_executable() {
let (Some(sleep_bin), Some(_pgrep), Some(_ps)) = (
find_in_path("sleep"),
find_in_path("pgrep"),
find_in_path("ps"),
) else {
return;
};
let dir = tempfile::tempdir().unwrap();
let bin_dir = dir.path().join("kache daemon run");
std::fs::create_dir_all(&bin_dir).unwrap();
let fake = bin_dir.join("kache");
std::fs::copy(&sleep_bin, &fake).unwrap();
let mut child = std::process::Command::new(&fake)
.arg("30")
.spawn()
.expect("spawn fake daemon");
let fake_pid = child.id();
let found = super::find_daemon_pids();
child.kill().expect("kill fake daemon");
child.wait().expect("reap fake daemon");
assert!(
found.contains(&fake_pid),
"a process running an executable named kache should be found: \
{found:?} is missing {fake_pid}"
);
}
#[cfg(unix)]
#[test]
fn find_daemon_pids_ignores_processes_that_merely_mention_the_daemon() {
let mut decoy = std::process::Command::new("/bin/sh")
.args(["-c", "sleep 20; : kache daemon run"])
.spawn()
.expect("spawn decoy");
let decoy_pid = decoy.id();
let found = super::find_daemon_pids();
decoy.kill().expect("kill decoy");
decoy.wait().expect("reap decoy");
assert!(
!found.contains(&decoy_pid),
"force_recover would have killed a non-kache process: {found:?} \
contains decoy {decoy_pid}"
);
}
#[test]
fn existing_run_lock_probe_separates_missing_from_unreadable() {
let dir = tempfile::tempdir().unwrap();
let socket_path = dir.path().join("daemon.sock");
assert!(!existing_daemon_run_lock_is_held(&socket_path).unwrap());
assert!(!daemon_run_lock_path(&socket_path).exists());
let lock = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(daemon_run_lock_path(&socket_path))
.unwrap();
assert!(!existing_daemon_run_lock_is_held(&socket_path).unwrap());
lock.try_lock().unwrap();
assert!(existing_daemon_run_lock_is_held(&socket_path).unwrap());
}
#[test]
fn existing_run_lock_probe_propagates_an_unreadable_lock() {
let dir = tempfile::tempdir().unwrap();
let socket_path = dir.path().join("daemon.sock");
std::fs::create_dir_all(daemon_run_lock_path(&socket_path)).unwrap();
assert!(
existing_daemon_run_lock_is_held(&socket_path).is_err(),
"an unopenable lock file must not be reported as unheld"
);
}
#[test]
fn daemon_is_live_covers_serving_and_starting_daemons() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let socket_path = config.socket_path();
std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap();
assert!(!daemon_is_live(&config), "nothing running");
let lock = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(daemon_run_lock_path(&socket_path))
.unwrap();
lock.try_lock().unwrap();
write_json_atomically(
&daemon_state_path(&socket_path),
&DaemonCoordState {
pid: std::process::id(),
build_epoch: 4242,
phase: DaemonPhase::Starting,
updated_at_ms: now_millis(),
},
)
.unwrap();
assert!(daemon_is_live(&config), "starting daemon");
}
#[test]
fn starting_daemon_epoch_reports_only_a_live_starting_daemon() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let socket_path = config.socket_path();
std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap();
let state_path = daemon_state_path(&socket_path);
assert_eq!(starting_daemon_epoch(&config), None);
let mut state = DaemonCoordState {
pid: std::process::id(),
build_epoch: 4242,
phase: DaemonPhase::Starting,
updated_at_ms: now_millis(),
};
write_json_atomically(&state_path, &state).unwrap();
assert_eq!(starting_daemon_epoch(&config), None);
assert!(!daemon_run_lock_path(&socket_path).exists());
let run_lock = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(daemon_run_lock_path(&socket_path))
.unwrap();
run_lock.try_lock().unwrap();
assert_eq!(starting_daemon_epoch(&config), Some(4242));
state.phase = DaemonPhase::Ready;
write_json_atomically(&state_path, &state).unwrap();
assert_eq!(starting_daemon_epoch(&config), None);
state.phase = DaemonPhase::Starting;
state.updated_at_ms =
now_millis().saturating_sub(DAEMON_COORD_STALE_AFTER.as_millis() as u64 * 2);
write_json_atomically(&state_path, &state).unwrap();
assert_eq!(starting_daemon_epoch(&config), None);
let mut child = std::process::Command::new(if cfg!(windows) { "cmd" } else { "true" })
.args(if cfg!(windows) {
vec!["/C", "exit"]
} else {
vec![]
})
.spawn()
.unwrap();
let dead_pid = child.id();
child.wait().unwrap();
state.updated_at_ms = now_millis();
state.pid = dead_pid;
write_json_atomically(&state_path, &state).unwrap();
assert_eq!(starting_daemon_epoch(&config), None);
}
#[test]
fn test_recover_unhealthy_daemon_cleans_stale_socket_and_state() {
let dir = tempfile::tempdir().unwrap();
let socket_path = dir.path().join("daemon.sock");
std::fs::write(&socket_path, b"stale").unwrap();
let mut child = std::process::Command::new(if cfg!(windows) { "cmd" } else { "true" })
.args(if cfg!(windows) {
vec!["/C", "exit"]
} else {
vec![]
})
.spawn()
.unwrap();
let dead_pid = child.id();
child.wait().unwrap();
let state = DaemonCoordState {
pid: dead_pid,
build_epoch: build_epoch(),
phase: DaemonPhase::Starting,
updated_at_ms: now_millis(),
};
write_json_atomically(&daemon_state_path(&socket_path), &state).unwrap();
assert!(recover_unhealthy_daemon(&socket_path, "test").unwrap());
assert!(!socket_path.exists());
assert!(read_daemon_state(&socket_path).is_none());
}
#[test]
fn test_recover_unhealthy_daemon_terminates_recent_recorded_pid() {
let dir = tempfile::tempdir().unwrap();
let socket_path = dir.path().join("daemon.sock");
std::fs::write(&socket_path, b"stale").unwrap();
let (child_pid, child_handle) = spawn_blocking_child_holding_run_lock(&socket_path);
let state = DaemonCoordState {
pid: child_pid,
build_epoch: build_epoch(),
phase: DaemonPhase::Ready,
updated_at_ms: now_millis(),
};
write_json_atomically(&daemon_state_path(&socket_path), &state).unwrap();
assert!(recover_unhealthy_daemon(&socket_path, "test").unwrap());
assert_ne!(child_handle.join().unwrap().code(), Some(0));
assert!(!socket_path.exists());
assert!(read_daemon_state(&socket_path).is_none());
}
#[test]
fn test_recover_unhealthy_daemon_terminates_stale_recorded_pid() {
let dir = tempfile::tempdir().unwrap();
let socket_path = dir.path().join("daemon.sock");
std::fs::write(&socket_path, b"stale").unwrap();
let (child_pid, child_handle) = spawn_blocking_child_holding_run_lock(&socket_path);
let state = DaemonCoordState {
pid: child_pid,
build_epoch: build_epoch(),
phase: DaemonPhase::Ready,
updated_at_ms: now_millis()
.saturating_sub(DAEMON_COORD_STALE_AFTER.as_millis() as u64 + 1),
};
write_json_atomically(&daemon_state_path(&socket_path), &state).unwrap();
assert!(recover_unhealthy_daemon(&socket_path, "test").unwrap());
assert_ne!(child_handle.join().unwrap().code(), Some(0));
assert!(!socket_path.exists());
assert!(read_daemon_state(&socket_path).is_none());
}
#[test]
fn test_recover_unhealthy_daemon_does_not_kill_pid_without_run_lock() {
let dir = tempfile::tempdir().unwrap();
let socket_path = dir.path().join("daemon.sock");
std::fs::write(&socket_path, b"stale").unwrap();
let mut child = spawn_blocking_child();
let state = DaemonCoordState {
pid: child.id(),
build_epoch: build_epoch(),
phase: DaemonPhase::Ready,
updated_at_ms: now_millis(),
};
write_json_atomically(&daemon_state_path(&socket_path), &state).unwrap();
assert!(recover_unhealthy_daemon(&socket_path, "test").unwrap());
assert!(child.try_wait().unwrap().is_none());
let _ = child.kill();
let _ = child.wait();
assert!(!socket_path.exists());
assert!(read_daemon_state(&socket_path).is_none());
}
#[test]
fn test_recover_unhealthy_daemon_refuses_held_lock_without_state() {
let dir = tempfile::tempdir().unwrap();
let socket_path = dir.path().join("daemon.sock");
let (release_tx, run_lock_handle) = hold_run_lock_until_released(&socket_path);
assert!(!recover_unhealthy_daemon(&socket_path, "test").unwrap());
release_tx.send(()).unwrap();
run_lock_handle.join().unwrap();
}
#[test]
fn test_request_gc_serde() {
let req = Request::Gc(GcRequest::explicit_age(168));
let json = serde_json::to_string(&req).unwrap();
let parsed: Request = serde_json::from_str(&json).unwrap();
assert_eq!(req, parsed);
assert!(json.contains("\"gc\""));
assert!(json.contains("\"max_age_hours\":168"));
}
#[test]
fn test_request_gc_null_age_serde() {
let req = Request::Gc(GcRequest::legacy(None));
let json = serde_json::to_string(&req).unwrap();
let parsed: Request = serde_json::from_str(&json).unwrap();
assert_eq!(req, parsed);
assert!(json.contains("\"max_age_hours\":null"));
let old_wire: Request = serde_json::from_str(r#"{"gc":{"max_age_hours":null}}"#).unwrap();
assert_eq!(old_wire, Request::Gc(GcRequest::legacy(None)));
}
#[test]
fn test_request_gc_automatic_carries_effective_age() {
let req = Request::Gc(GcRequest::automatic(72));
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"mode\":\"automatic\""));
assert!(json.contains("\"effective_max_age_hours\":72"));
assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), req);
}
#[test]
fn gc_v2_is_atomic_compatibility_gate_for_old_daemons() {
#[allow(dead_code)]
#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
enum LegacyRequest {
Gc(GcRequest),
Stats(StatsRequest),
}
let req = Request::GcV2(GcRequest::automatic(72));
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"gc_v2\""));
assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), req);
assert!(
serde_json::from_str::<LegacyRequest>(&json).is_err(),
"a pre-v2 daemon must reject the request before mutation"
);
}
#[test]
fn gc_rejects_any_response_without_policy_reporting() {
let error = match gc_outcome_from_response(Response::ok_evicted(1)) {
Ok(_) => panic!("legacy aggregate response must be rejected"),
Err(error) => error,
};
assert!(error.to_string().contains("omitted GC policy reporting"));
}
#[test]
fn daemon_start_requirement_distinguishes_success_and_failure() {
assert!(require_daemon_started(true).is_ok());
let error = match require_daemon_started(false) {
Ok(()) => panic!("a failed daemon start must stop the request"),
Err(error) => error,
};
assert_eq!(error.to_string(), "could not reach or start daemon");
}
#[test]
fn test_request_remote_check_serde() {
let req = Request::RemoteCheck(RemoteCheckRequest {
key: "abc123".into(),
entry_dir: "/tmp/store/abc123".into(),
crate_name: String::new(),
deadline_ms: None,
});
let json = serde_json::to_string(&req).unwrap();
let parsed: Request = serde_json::from_str(&json).unwrap();
assert_eq!(req, parsed);
assert!(json.contains("\"remote_check\""));
assert!(json.contains("\"key\":\"abc123\""));
assert!(json.contains("\"entry_dir\":\"/tmp/store/abc123\""));
}
#[test]
fn test_response_ok_serde() {
let resp = Response::ok();
let json = serde_json::to_string(&resp).unwrap();
assert_eq!(json, r#"{"ok":true}"#);
}
#[test]
fn test_response_ok_evicted_serde() {
let resp = Response::ok_evicted(5);
let json = serde_json::to_string(&resp).unwrap();
assert_eq!(json, r#"{"ok":true,"evicted":5}"#);
}
#[test]
fn test_response_gc_skipped_serde() {
let resp =
Response::ok_gc_skipped(GcRunReport::skipped(GcRequestMode::Automatic).breakdown());
let json = serde_json::to_string(&resp).unwrap();
let parsed: Response = serde_json::from_str(&json).unwrap();
assert!(parsed.skipped);
assert_eq!(parsed.evicted, Some(0));
assert_eq!(parsed.gc.unwrap().mode, GcRequestMode::Automatic);
}
#[test]
fn test_response_found_true_serde() {
let resp = Response::found(true);
let json = serde_json::to_string(&resp).unwrap();
assert_eq!(json, r#"{"ok":true,"found":true}"#);
}
#[test]
fn test_response_found_false_serde() {
let resp = Response::found(false);
let json = serde_json::to_string(&resp).unwrap();
assert_eq!(json, r#"{"ok":true,"found":false}"#);
}
#[test]
fn test_response_found_prefetched_serde() {
let resp = Response::found_prefetched(true, true);
let json = serde_json::to_string(&resp).unwrap();
assert_eq!(json, r#"{"ok":true,"found":true,"prefetched":true}"#);
}
#[test]
fn test_stats_response_prefetch_field_is_backward_compatible() {
let old_json = r#"{"total_size":0,"max_size":0,"entry_count":0,"entries":null,
"events":{"local_hits":0,"prefetch_hits":0,"remote_hits":0,"dups":0,
"misses":0,"errors":0,"total_elapsed_ms":0,"hit_elapsed_ms":0,
"miss_elapsed_ms":0,"hit_compile_time_ms":0,"miss_compile_time_ms":0,
"store_output_blobs":0,"store_duplicate_blobs":0,"store_new_blobs":0}}"#;
let parsed: StatsResponse = serde_json::from_str(old_json).unwrap();
assert_eq!(parsed.prefetch, PrefetchStatsSnapshot::default());
let snap = PrefetchStatsSnapshot {
downloads_completed: 3,
bytes_downloaded: 1024,
keys_used: 2,
keys_cancelled: 1,
keys_over_budget: 5,
cancelled: true,
plans_advisory: 1,
plans_fallback: 4,
last_plan_candidates: 17,
dedup_join_waits: 2,
dedup_join_wait_ms: 250,
last_list_duration_ms: 42,
last_list_key_count: 9001,
list_requests_total: 7,
list_failures_total: 1,
list_duration_ms_total: 900,
list_keys_total: 63007,
pack_requests_total: 5,
pack_bytes_downloaded: 4096,
v3_requests_total: 2,
v3_bytes_downloaded: 1024,
pack_validation_failures: 1,
pack_fallback_entries: 2,
last_plan_wall_ms: 123,
plan_wall_ms_total: 456,
};
let json = serde_json::to_string(&snap).unwrap();
let back: PrefetchStatsSnapshot = serde_json::from_str(&json).unwrap();
assert_eq!(back, snap);
}
#[test]
fn test_response_err_serde() {
let resp = Response::err("something broke");
let json = serde_json::to_string(&resp).unwrap();
let parsed: Response = serde_json::from_str(&json).unwrap();
assert!(!parsed.ok);
assert_eq!(parsed.error.as_deref(), Some("something broke"));
assert_eq!(parsed.evicted, None);
assert_eq!(parsed.found, None);
}
#[test]
fn test_invalid_request_json() {
let result = serde_json::from_str::<Request>(r#"{"bogus": 42}"#);
assert!(result.is_err());
}
#[tokio::test]
async fn test_key_cache_unpopulated_returns_none() {
let cache = S3KeyCache::new();
assert_eq!(cache.check("any_key").await, None);
}
#[tokio::test]
async fn test_key_cache_populate_and_check() {
let cache = S3KeyCache::new();
let mut keys = HashMap::new();
keys.insert("key_a".to_string(), "crate_a".to_string());
keys.insert("key_b".to_string(), "crate_b".to_string());
cache.populate(keys).await;
assert_eq!(cache.check("key_a").await, Some(true));
assert_eq!(cache.check("key_b").await, Some(true));
assert_eq!(cache.check("key_c").await, Some(false));
let crate_a_keys = cache.keys_for_crate("crate_a").await;
assert_eq!(crate_a_keys, vec!["key_a"]);
assert!(cache.keys_for_crate("unknown").await.is_empty());
}
#[tokio::test]
async fn test_key_cache_insert_after_populate() {
let cache = S3KeyCache::new();
cache.populate(HashMap::new()).await;
assert_eq!(cache.check("new_key").await, Some(false));
cache.insert("new_key".to_string(), Some("my_crate")).await;
assert_eq!(cache.check("new_key").await, Some(true));
let keys = cache.keys_for_crate("my_crate").await;
assert_eq!(keys, vec!["new_key"]);
}
#[tokio::test]
async fn test_key_cache_insert_before_populate_is_noop() {
let cache = S3KeyCache::new();
cache.insert("key".to_string(), Some("crate")).await;
assert_eq!(cache.check("key").await, None);
assert!(cache.keys_for_crate("crate").await.is_empty());
}
#[tokio::test]
async fn stale_list_snapshot_cannot_erase_newer_point_knowledge() {
let cache = S3KeyCache::new();
cache.populate(HashMap::new()).await;
let before_list = cache.refresh_revision();
let uploaded = test_cache_key("upload-during-list");
cache.insert(uploaded.clone(), Some("serde")).await;
assert_eq!(
cache.refresh_revision(),
before_list.wrapping_add(1),
"a point update must advance the LIST-staleness revision"
);
assert!(
!cache
.populate_if_unchanged(HashMap::new(), before_list)
.await,
"a LIST started before the upload must be discarded"
);
assert_eq!(cache.check(&uploaded).await, Some(true));
}
#[tokio::test]
async fn test_key_cache_views_stay_consistent_under_concurrency() {
use std::sync::Arc;
let cache = Arc::new(S3KeyCache::new());
let seed: HashMap<String, String> = (0..50)
.map(|i| (format!("seed_{i}"), format!("crate_{}", i % 5)))
.collect();
cache.populate(seed).await;
let mut tasks = Vec::new();
for r in 0..8 {
let c = cache.clone();
tasks.push(tokio::spawn(async move {
let mut m: HashMap<String, String> = (0..50)
.map(|i| (format!("seed_{i}"), format!("crate_{}", i % 5)))
.collect();
m.insert(format!("refresh_{r}"), "crate_r".to_string());
c.populate(m).await;
}));
}
for k in 0..8 {
let c = cache.clone();
tasks.push(tokio::spawn(async move {
c.insert(format!("up_{k}"), Some("crate_up")).await;
}));
}
for t in tasks {
t.await.unwrap();
}
assert_eq!(cache.check("seed_0").await, Some(true));
let guard = cache.index.read().await;
let idx = guard.as_ref().expect("populated");
let reverse_total: usize = idx.by_crate.values().map(Vec::len).sum();
assert_eq!(
idx.keys.len(),
reverse_total,
"forward set and reverse index must agree on key count"
);
for keys in idx.by_crate.values() {
for key in keys {
assert!(
idx.keys.contains(key),
"key {key} is in by_crate but missing from the forward set"
);
}
}
}
#[test]
fn test_handle_gc_empty_store() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let daemon = Daemon::new(config);
let resp = daemon.handle_gc(&GcRequest::automatic(daemon.config.gc_max_age_hours));
assert!(resp.ok);
assert_eq!(resp.evicted, Some(0));
assert_eq!(resp.gc.as_ref().unwrap().mode, GcRequestMode::Automatic);
}
#[test]
fn test_handle_gc_reports_lock_skip() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let _gc_lock = store.try_gc_lock().unwrap().expect("gc lock");
let daemon = Daemon::new(config);
let resp = daemon.handle_gc(&GcRequest::automatic(daemon.config.gc_max_age_hours));
assert!(resp.ok);
assert!(resp.skipped);
assert_eq!(resp.evicted, Some(0));
}
#[test]
fn test_handle_gc_with_max_age() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let daemon = Daemon::new(config);
let resp = daemon.handle_gc(&GcRequest::explicit_age(24));
assert!(resp.ok);
assert_eq!(resp.evicted, Some(0));
let breakdown = resp.gc.unwrap();
assert_eq!(breakdown.mode, GcRequestMode::ExplicitAge);
assert_eq!(breakdown.duplicate.entries_evicted, 0);
assert_eq!(breakdown.size.entries_evicted, 0);
}
#[test]
fn explicit_age_request_without_hours_fails_closed() {
let dir = tempfile::tempdir().unwrap();
let daemon = Daemon::new(test_config(dir.path()));
let resp = daemon.handle_gc(&GcRequest {
max_age_hours: None,
mode: GcRequestMode::ExplicitAge,
effective_max_age_hours: None,
});
assert!(!resp.ok);
assert!(
resp.error
.as_deref()
.unwrap()
.contains("missing max_age_hours")
);
}
#[test]
fn automatic_request_without_effective_age_fails_closed() {
let dir = tempfile::tempdir().unwrap();
let daemon = Daemon::new(test_config(dir.path()));
let resp = daemon.handle_gc(&GcRequest {
max_age_hours: None,
mode: GcRequestMode::Automatic,
effective_max_age_hours: None,
});
assert!(!resp.ok);
assert!(
resp.error
.as_deref()
.unwrap()
.contains("missing effective_max_age_hours")
);
}
#[test]
fn test_handle_gc_evicts_entries() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
let src_file = dir.path().join("big.rlib");
std::fs::write(&src_file, vec![0u8; 200]).unwrap();
let store = Store::open(&config).unwrap();
store
.put(
"testkey",
"testcrate",
&["lib".into()],
&[],
"host",
"dev",
&[(src_file, "lib.rlib".into())],
"",
"",
)
.unwrap();
assert!(store.contains("testkey"));
assert!(store.total_size().unwrap() >= 200);
store.set_last_accessed_for_test("testkey", "-1 hour");
drop(store);
config.max_size = 100;
let daemon = Daemon::new(config);
let stats = daemon
.run_gc(GcPolicy::Automatic {
max_age_hours: daemon.config.gc_max_age_hours,
})
.unwrap();
assert!(
stats.total.entries_evicted > 0,
"should have evicted at least 1 entry"
);
}
#[test]
fn automatic_gc_applies_configured_max_age_even_under_size_budget() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.max_size = 1024 * 1024 * 1024;
config.gc_max_age_hours = 1;
let src_file = dir.path().join("stale.rlib");
std::fs::write(&src_file, vec![0u8; 32]).unwrap();
let store = Store::open(&config).unwrap();
store
.put(
"stale_key",
"testcrate",
&["lib".into()],
&[],
"host",
"dev",
&[(src_file, "lib.rlib".into())],
"",
"",
)
.unwrap();
store.set_last_accessed_for_test("stale_key", "-2 hours");
drop(store);
let daemon = Daemon::new(config);
let stats = daemon
.run_gc(GcPolicy::Automatic {
max_age_hours: daemon.config.gc_max_age_hours,
})
.unwrap();
assert_eq!(stats.total.entries_evicted, 1);
let store = Store::open(&daemon.config).unwrap();
assert!(!store.contains("stale_key"));
}
#[test]
fn automatic_gc_skips_age_eviction_when_max_age_hours_is_zero() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.max_size = 1024 * 1024 * 1024;
config.gc_max_age_hours = 0;
let src_file = dir.path().join("stale.rlib");
std::fs::write(&src_file, vec![0u8; 32]).unwrap();
let store = Store::open(&config).unwrap();
store
.put(
"stale_key",
"testcrate",
&["lib".into()],
&[],
"host",
"dev",
&[(src_file, "lib.rlib".into())],
"",
"",
)
.unwrap();
store.set_last_accessed_for_test("stale_key", "-2 hours");
drop(store);
let daemon = Daemon::new(config);
let stats = daemon
.run_gc(GcPolicy::Automatic {
max_age_hours: daemon.config.gc_max_age_hours,
})
.unwrap();
assert_eq!(stats.total.entries_evicted, 0);
let store = Store::open(&daemon.config).unwrap();
assert!(store.contains("stale_key"));
}
#[test]
fn manual_automatic_gc_sends_effective_age_and_runs_age_before_size() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.max_size = 1_000; config.gc_max_age_hours = 0;
let old_file = dir.path().join("old.rlib");
let fresh_file = dir.path().join("fresh.rlib");
std::fs::write(&old_file, vec![b'o'; 400]).unwrap();
std::fs::write(&fresh_file, vec![b'f'; 800]).unwrap();
let store = Store::open(&config).unwrap();
store
.put(
"old_valuable",
"testcrate",
&["lib".into()],
&[],
"host",
"dev",
&[(old_file, "old.rlib".into())],
"",
"",
)
.unwrap();
for _ in 0..1_000 {
assert!(store.get("old_valuable").unwrap().is_some());
}
store
.put(
"fresh_cheap",
"testcrate",
&["lib".into()],
&[],
"host",
"dev",
&[(fresh_file, "fresh.rlib".into())],
"",
"",
)
.unwrap();
store.set_last_accessed_for_test("old_valuable", "-2 hours");
store.set_last_accessed_for_test("fresh_cheap", "-2 minutes");
drop(store);
let daemon = Daemon::new(config);
let resp = daemon.handle_gc(&GcRequest::automatic(1));
assert!(resp.ok);
assert_eq!(resp.evicted, Some(1));
let breakdown = resp.gc.expect("new daemon returns policy breakdown");
assert_eq!(breakdown.mode, GcRequestMode::Automatic);
assert_eq!(breakdown.age.entries_evicted, 1);
assert_eq!(breakdown.size.entries_evicted, 0);
let store = Store::open(&daemon.config).unwrap();
assert!(!store.contains("old_valuable"));
assert!(store.contains("fresh_cheap"));
}
#[test]
fn test_upload_triggered_eviction_respects_gc_lock() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.max_size = 100;
let src_file = dir.path().join("big.rlib");
std::fs::write(&src_file, vec![0u8; 200]).unwrap();
let store = Store::open(&config).unwrap();
store
.put(
"upload_evict_key",
"testcrate",
&["lib".into()],
&[],
"host",
"dev",
&[(src_file, "lib.rlib".into())],
"",
"",
)
.unwrap();
store.set_last_accessed_for_test("upload_evict_key", "-1 hour");
let gc_lock = store.try_gc_lock().unwrap().expect("gc lock");
let daemon = Daemon::new(config);
daemon.maybe_evict_after_upload();
assert!(
store.contains("upload_evict_key"),
"upload-triggered eviction must skip while gc.lock is held"
);
drop(gc_lock);
daemon.maybe_evict_after_upload();
assert!(
!store.contains("upload_evict_key"),
"eviction should run once gc.lock is available"
);
}
#[test]
fn test_handle_request_sync_dispatches_gc() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let daemon = Daemon::new(config);
let req = Request::Gc(GcRequest::automatic(daemon.config.gc_max_age_hours));
let resp = daemon.handle_request_sync(&req);
assert!(resp.ok);
assert_eq!(resp.evicted, Some(0));
}
#[tokio::test]
async fn offload_returns_the_handler_response() {
let resp = offload(Response::ok).await;
assert!(resp.ok);
}
#[tokio::test]
async fn offload_maps_a_handler_panic_to_an_error_response() {
let resp = offload(|| panic!("handler boom")).await;
assert!(!resp.ok, "a panicking handler must yield an error response");
assert!(
resp.error
.as_deref()
.unwrap_or_default()
.contains("task failed"),
"error should explain the handler task failed, got {:?}",
resp.error
);
}
#[test]
fn test_handle_request_sync_rejects_upload() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let daemon = Daemon::new(config);
let req = Request::Upload(UploadJob {
key: "k".into(),
entry_dir: "/tmp".into(),
crate_name: String::new(),
client_epoch: 0,
});
let resp = daemon.handle_request_sync(&req);
assert!(!resp.ok);
assert!(resp.error.as_deref().unwrap().contains("async"));
}
#[test]
fn test_handle_request_sync_rejects_remote_check() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let daemon = Daemon::new(config);
let req = Request::RemoteCheck(RemoteCheckRequest {
key: "k".into(),
entry_dir: "/tmp".into(),
crate_name: String::new(),
deadline_ms: None,
});
let resp = daemon.handle_request_sync(&req);
assert!(!resp.ok);
assert!(resp.error.as_deref().unwrap().contains("async"));
}
#[tokio::test]
async fn test_handle_upload_no_remote() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path()); let daemon = Daemon::new(config);
let job = UploadJob {
key: test_cache_key("no-remote-upload"),
entry_dir: "/tmp".into(),
crate_name: "serde".into(),
client_epoch: 0,
};
let resp = daemon.handle_upload(&job).await;
assert!(!resp.ok);
assert!(
resp.error
.as_deref()
.unwrap()
.contains("no remote configured")
);
}
#[tokio::test]
async fn test_handle_remote_check_rejects_invalid_crate_name() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let daemon = Daemon::new(config);
let invalid_crate = RemoteCheckRequest {
entry_dir: "/unused".into(),
key: test_cache_key("invalid-remote-crate"),
crate_name: "../escape".into(),
deadline_ms: None,
};
let resp = daemon.handle_remote_check(&invalid_crate).await;
assert!(!resp.ok);
assert_eq!(resp.error.as_deref(), Some("invalid crate name"));
}
#[tokio::test]
async fn test_handle_upload_remote_readonly() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote_readonly = true;
let daemon = Daemon::new(config);
let job = UploadJob {
key: test_cache_key("readonly-upload"),
entry_dir: "/tmp".into(),
crate_name: "serde".into(),
client_epoch: 0,
};
let resp = daemon.handle_upload(&job).await;
assert!(resp.ok);
assert!(resp.error.is_none());
let resp_do = daemon.do_upload(&job).await;
assert!(resp_do.ok);
assert!(resp_do.error.is_none());
}
#[tokio::test]
async fn test_handle_remote_check_no_remote() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path()); let daemon = Daemon::new(config);
let key = test_cache_key("no-remote-check");
let req = RemoteCheckRequest {
entry_dir: daemon.entry_dir_for(&key).to_string_lossy().into_owned(),
key,
crate_name: "serde".into(),
deadline_ms: None,
};
let resp = daemon.handle_remote_check(&req).await;
assert!(!resp.ok);
assert!(
resp.error
.as_deref()
.unwrap()
.contains("no remote configured")
);
}
#[test]
fn test_run_gc_returns_count() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let daemon = Daemon::new(config);
let stats = daemon
.run_gc(GcPolicy::Automatic {
max_age_hours: daemon.config.gc_max_age_hours,
})
.unwrap();
assert_eq!(stats.total.entries_evicted, 0);
}
#[test]
fn gc_pinned_lower_bound_is_policy_aware() {
assert_eq!(
gc_entries_pinned_lower_bound(GcPolicy::ExplicitAge { hours: 24 }, 9, 2, 8),
2
);
for (duplicate, age, size) in [(7, 2, 3), (2, 7, 3), (2, 3, 7)] {
assert_eq!(
gc_entries_pinned_lower_bound(
GcPolicy::Automatic { max_age_hours: 24 },
duplicate,
age,
size,
),
7
);
}
assert_eq!(
gc_entries_pinned_lower_bound(GcPolicy::Automatic { max_age_hours: 0 }, 0, 0, 0,),
0
);
}
#[test]
fn test_run_gc_cleans_registered_incremental_dirs_once() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.clean_incremental = true;
let incremental_dir = dir.path().join("workspace/target/debug/incremental");
std::fs::create_dir_all(&incremental_dir).unwrap();
std::fs::write(incremental_dir.join("junk"), b"tmp").unwrap();
let store = Store::open(&config).unwrap();
store.remember_incremental_dir(&incremental_dir).unwrap();
drop(store);
let daemon = Daemon::new(config.clone());
let stats = daemon
.run_gc(GcPolicy::Automatic {
max_age_hours: daemon.config.gc_max_age_hours,
})
.unwrap();
assert_eq!(stats.total.entries_evicted, 0);
assert!(!incremental_dir.exists());
std::fs::create_dir_all(&incremental_dir).unwrap();
std::fs::write(incremental_dir.join("junk"), b"tmp2").unwrap();
let stats = daemon
.run_gc(GcPolicy::Automatic {
max_age_hours: daemon.config.gc_max_age_hours,
})
.unwrap();
assert_eq!(stats.total.entries_evicted, 0);
assert!(incremental_dir.exists());
}
#[test]
fn clean_tool_version_caches_removes_only_old_tool_version_txt() {
let dir = tempfile::tempdir().unwrap();
let old_rustc = dir.path().join("rustc-ver-old.txt");
let old_linker = dir.path().join("linker-ver-old.txt");
let fresh_rustc = dir.path().join("rustc-ver-fresh.txt");
let old_other = dir.path().join("other-ver-old.txt");
for path in [&old_rustc, &old_linker, &fresh_rustc, &old_other] {
std::fs::write(path, b"version").unwrap();
}
let old = filetime::FileTime::from_system_time(
std::time::SystemTime::now() - Duration::from_secs(8 * 24 * 3600),
);
for path in [&old_rustc, &old_linker, &old_other] {
filetime::set_file_mtime(path, old).unwrap();
}
Daemon::clean_tool_version_caches(dir.path());
assert!(!old_rustc.exists());
assert!(!old_linker.exists());
assert!(fresh_rustc.exists());
assert!(old_other.exists());
}
#[tokio::test]
async fn test_socket_gc_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let socket_path = config.socket_path();
std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap();
let daemon = Arc::new(Daemon::new(config));
let resp = one_shot_request(
&daemon,
&socket_path,
&Request::Gc(GcRequest::automatic(daemon.config.gc_max_age_hours)),
)
.await;
assert!(resp.ok);
assert_eq!(resp.evicted, Some(0));
}
#[tokio::test]
async fn test_socket_remote_check_no_remote_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path()); let socket_path = config.socket_path();
std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap();
let key = test_cache_key("socket-no-remote-check");
let entry_dir = config.store_dir().join(&key).to_string_lossy().into_owned();
let daemon = Arc::new(Daemon::new(config));
let resp = one_shot_request(
&daemon,
&socket_path,
&Request::RemoteCheck(RemoteCheckRequest {
key,
entry_dir,
crate_name: "serde".into(),
deadline_ms: None,
}),
)
.await;
assert!(!resp.ok);
assert!(
resp.error
.as_deref()
.unwrap()
.contains("no remote configured")
);
}
#[cfg(unix)]
#[test]
fn test_stale_socket_cleanup() {
let dir = tempfile::tempdir().unwrap();
let socket_path = dir.path().join("daemon.sock");
std::fs::write(&socket_path, b"stale").unwrap();
assert!(socket_path.exists());
let result = std::os::unix::net::UnixStream::connect(&socket_path);
assert!(result.is_err());
std::fs::remove_file(&socket_path).unwrap();
assert!(!socket_path.exists());
}
#[test]
fn test_send_request_to_nonexistent_socket() {
let dir = tempfile::tempdir().unwrap();
let socket_path = dir.path().join("nonexistent.sock");
let req = Request::Gc(GcRequest::automatic(0));
let result = send_request(&socket_path, &req);
assert!(result.is_err());
}
#[test]
fn test_send_remote_check_unreachable_returns_none() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let result = send_remote_check(&config, "some_key", Path::new("/tmp/test"), "unknown");
assert!(result.is_none());
}
#[test]
fn remote_check_response_parser_handles_prefetched_error_and_malformed() {
let hit = serde_json::to_string(&Response::found_prefetched(true, true)).unwrap();
let result = remote_check_result_from_response_line(&hit).unwrap();
assert!(result.found);
assert!(result.prefetched);
let plain_hit = serde_json::to_string(&Response::found(true)).unwrap();
let result = remote_check_result_from_response_line(&plain_hit).unwrap();
assert!(result.found);
assert!(!result.prefetched);
let err = serde_json::to_string(&Response::err("remote down")).unwrap();
assert!(remote_check_result_from_response_line(&err).is_none());
assert!(remote_check_result_from_response_line("{not json").is_none());
}
#[test]
fn test_response_constructors() {
let ok = Response::ok();
assert!(ok.ok && ok.evicted.is_none() && ok.error.is_none() && ok.found.is_none());
assert!(ok.batch_results.is_none());
let evicted = Response::ok_evicted(3);
assert!(evicted.ok && evicted.evicted == Some(3));
let found_true = Response::found(true);
assert!(found_true.ok && found_true.found == Some(true));
let found_false = Response::found(false);
assert!(found_false.ok && found_false.found == Some(false));
let batch = Response::ok_batch(vec![Response::found(true), Response::found(false)]);
assert!(batch.ok && batch.batch_results.as_ref().unwrap().len() == 2);
let err = Response::err("oops");
assert!(!err.ok && err.error.as_deref() == Some("oops"));
}
#[test]
fn test_stats_request_serde() {
let req = Request::Stats(StatsRequest {
include_entries: true,
include_summaries: true,
sort_by: Some("size".into()),
event_hours: Some(48),
client_epoch: 0,
});
let json = serde_json::to_string(&req).unwrap();
let parsed: Request = serde_json::from_str(&json).unwrap();
assert_eq!(req, parsed);
assert!(json.contains("\"stats\""));
assert!(json.contains("\"include_entries\":true"));
assert!(json.contains("\"include_summaries\":true"));
assert!(json.contains("\"sort_by\":\"size\""));
assert!(json.contains("\"event_hours\":48"));
let mut old = serde_json::to_value(&req).unwrap();
old.get_mut("stats")
.and_then(serde_json::Value::as_object_mut)
.unwrap()
.remove("include_summaries");
let parsed: Request = serde_json::from_value(old).unwrap();
assert!(matches!(
parsed,
Request::Stats(StatsRequest {
include_summaries: false,
..
})
));
}
#[test]
fn test_stats_response_serde() {
let stats = StatsResponse {
total_size: 1024,
max_size: 4096,
entry_count: 5,
entries: None,
events: EventStatsResponse {
local_hits: 10,
prefetch_hits: 0,
remote_hits: 2,
dups: 1,
misses: 3,
errors: 1,
total_elapsed_ms: 5000,
hit_elapsed_ms: 120,
miss_elapsed_ms: 4880,
hit_compile_time_ms: 22000,
miss_compile_time_ms: 9000,
store_output_blobs: 4,
store_duplicate_blobs: 1,
store_new_blobs: 3,
},
blob_stats: None,
recent_summaries: Vec::new(),
version: String::new(),
build_epoch: 0,
gc_policy_version: GC_POLICY_PROTOCOL_VERSION,
pending_uploads: 0,
active_downloads: 0,
s3_concurrency_total: 0,
s3_concurrency_used: 0,
upload_queue_capacity: 0,
uploads_completed: 0,
uploads_failed: 0,
uploads_skipped: 0,
uploads_suppressed: 0,
downloads_completed: 0,
downloads_failed: 0,
downloads_suppressed: 0,
remote_check_roundtrips: 0,
negative_hits: 0,
negative_entries: 0,
remote_degraded: false,
bytes_uploaded: 0,
bytes_downloaded: 0,
recent_transfers: Vec::new(),
prefetch: PrefetchStatsSnapshot::default(),
in_flight: Vec::new(),
effective_config: None,
};
let resp = Response::ok_stats(stats.clone());
let json = serde_json::to_string(&resp).unwrap();
let parsed: Response = serde_json::from_str(&json).unwrap();
assert!(parsed.ok);
let parsed_stats = parsed.stats.unwrap();
assert_eq!(parsed_stats, stats);
}
#[test]
fn test_stats_response_with_entries() {
let stats = StatsResponse {
total_size: 2048,
max_size: 8192,
entry_count: 2,
entries: Some(vec![
StatsEntry {
cache_key: "abc123def456".into(),
crate_name: "serde".into(),
crate_type: "lib".into(),
profile: "release".into(),
size: 1024,
hit_count: 5,
created_at: "2025-01-01 00:00:00".into(),
last_accessed: "2025-06-01 12:00:00".into(),
content_hash: None,
},
StatsEntry {
cache_key: "789abc012def".into(),
crate_name: "tokio".into(),
crate_type: "lib".into(),
profile: "dev".into(),
size: 1024,
hit_count: 3,
created_at: "2025-02-01 00:00:00".into(),
last_accessed: "2025-05-15 08:00:00".into(),
content_hash: None,
},
]),
events: EventStatsResponse {
local_hits: 0,
prefetch_hits: 0,
remote_hits: 0,
dups: 0,
misses: 0,
errors: 0,
total_elapsed_ms: 0,
hit_elapsed_ms: 0,
miss_elapsed_ms: 0,
hit_compile_time_ms: 0,
miss_compile_time_ms: 0,
store_output_blobs: 0,
store_duplicate_blobs: 0,
store_new_blobs: 0,
},
blob_stats: None,
recent_summaries: Vec::new(),
version: String::new(),
build_epoch: 0,
gc_policy_version: GC_POLICY_PROTOCOL_VERSION,
pending_uploads: 0,
active_downloads: 0,
s3_concurrency_total: 0,
s3_concurrency_used: 0,
upload_queue_capacity: 0,
uploads_completed: 0,
uploads_failed: 0,
uploads_skipped: 0,
uploads_suppressed: 0,
downloads_completed: 0,
downloads_failed: 0,
downloads_suppressed: 0,
remote_check_roundtrips: 0,
negative_hits: 0,
negative_entries: 0,
remote_degraded: false,
bytes_uploaded: 0,
bytes_downloaded: 0,
recent_transfers: Vec::new(),
prefetch: PrefetchStatsSnapshot::default(),
in_flight: Vec::new(),
effective_config: None,
};
let resp = Response::ok_stats(stats);
let json = serde_json::to_string(&resp).unwrap();
let parsed: Response = serde_json::from_str(&json).unwrap();
let entries = parsed.stats.unwrap().entries.unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].crate_name, "serde");
assert_eq!(entries[1].crate_name, "tokio");
}
#[test]
fn daemon_keeps_the_load_time_config_provenance_after_a_file_edit() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, "[cache]\nlocal_max_size = \"10MiB\"\n").unwrap();
let provenance = crate::config::config_file_provenance_at(config_path.clone());
let mut config = test_config(dir.path());
config.max_size = 10 * 1024 * 1024;
std::fs::write(&config_path, "[cache]\nlocal_max_size = \"20MiB\"\n").unwrap();
let daemon = Daemon::new_with_provenance(config, &provenance);
assert_eq!(daemon.effective_config.max_size, 10 * 1024 * 1024);
assert_eq!(
daemon.effective_config.config_path,
config_path.display().to_string()
);
assert_eq!(
daemon.effective_config.config_fingerprint.as_deref(),
Some(provenance.fingerprint.as_str())
);
assert!(
crate::config::config_file_has_changed(&provenance),
"the watcher must compare against the parsed snapshot, not a fresh startup baseline"
);
}
#[test]
fn test_handle_stats_empty_store() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let mut summaries = (0..7)
.map(|index| {
format!(
"{{\"ts\":\"2026-08-09T00:00:0{index}Z\",\"schema\":1,\"session_id\":\"s{index}\"}}"
)
})
.collect::<Vec<_>>()
.join("\n");
summaries.push('\n');
std::fs::write(config.summary_log_path(), summaries).unwrap();
let daemon = Daemon::new(config);
let resp = daemon.handle_stats(&StatsRequest {
include_entries: true,
include_summaries: false,
sort_by: None,
event_hours: Some(24),
client_epoch: 0,
});
assert!(resp.ok);
let stats = resp.stats.unwrap();
assert_eq!(stats.total_size, 0);
assert_eq!(stats.entry_count, 0);
assert_eq!(stats.max_size, 50 * 1024 * 1024);
assert_eq!(stats.entries.unwrap().len(), 0);
assert_eq!(stats.events.local_hits, 0);
assert_eq!(stats.events.misses, 0);
assert_eq!(stats.blob_stats.as_ref().unwrap().total_blobs, 0);
assert!(
stats.recent_summaries.is_empty(),
"polling requests must not read summaries"
);
let eff = stats.effective_config.expect("effective config reported");
assert_eq!(eff.max_size, 50 * 1024 * 1024);
assert_eq!(eff.cache_dir, dir.path().display().to_string());
assert_eq!(
eff.socket_path,
dir.path().join("daemon.sock").display().to_string()
);
assert!(eff.started_at_ms > 0, "startup capture stamps a time");
assert!(eff.config_fingerprint.is_some());
assert!(
!eff.config_path.is_empty(),
"resolved config path is always reportable, even when the file is absent"
);
let with_summaries = daemon.handle_stats(&StatsRequest {
include_entries: false,
include_summaries: true,
sort_by: None,
event_hours: Some(24),
client_epoch: 0,
});
let ids = with_summaries
.stats
.unwrap()
.recent_summaries
.into_iter()
.map(|summary| summary.session_id)
.collect::<Vec<_>>();
assert_eq!(
ids,
["s2", "s3", "s4", "s5", "s6"],
"one-shot stats requests receive the newest bounded summary tail"
);
}
#[test]
fn test_daemon_reuses_store_handle() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let daemon = Daemon::new(config);
let first = daemon.store_lock().unwrap() as *const _;
let second = daemon.store_lock().unwrap() as *const _;
assert_eq!(first, second);
}
#[test]
fn test_handle_hash_files_uses_memory_cache() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let daemon = Daemon::new(config);
let file = dir.path().join("large.rlib");
std::fs::write(&file, vec![7u8; 70 * 1024]).unwrap();
let metadata = std::fs::metadata(&file).unwrap();
let req = HashFilesRequest {
files: vec![HashFileRequest {
path: file.to_string_lossy().into_owned(),
size: i64::try_from(metadata.len()).unwrap(),
mtime_ns: crate::cache_key::metadata_mtime_ns(&metadata),
ctime_ns: crate::cache_key::metadata_ctime_ns(&metadata),
inode: crate::cache_key::metadata_inode(&metadata),
}],
};
let first = daemon.handle_hash_files(&req);
assert!(first.ok);
let first_result = &first.hash_results.as_ref().unwrap()[0];
assert!(first_result.hash.is_some());
assert!(!first_result.cache_hit);
assert!(first_result.bytes_hashed > 0);
let second = daemon.handle_hash_files(&req);
assert!(second.ok);
let second_result = &second.hash_results.as_ref().unwrap()[0];
assert_eq!(first_result.hash, second_result.hash);
assert!(second_result.cache_hit);
assert_eq!(second_result.bytes_hashed, 0);
}
#[test]
fn handle_hash_files_persistent_cache_hit_across_daemons() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let file = dir.path().join("big.rlib");
std::fs::write(&file, vec![3u8; 80 * 1024]).unwrap(); let metadata = std::fs::metadata(&file).unwrap();
let req = HashFilesRequest {
files: vec![HashFileRequest {
path: file.to_string_lossy().into_owned(),
size: i64::try_from(metadata.len()).unwrap(),
mtime_ns: crate::cache_key::metadata_mtime_ns(&metadata),
ctime_ns: crate::cache_key::metadata_ctime_ns(&metadata),
inode: crate::cache_key::metadata_inode(&metadata),
}],
};
let expected = crate::cache_key::hash_file(&file).unwrap();
let a = Daemon::new(config.clone());
let ra = a.handle_hash_files(&req);
let ra = &ra.hash_results.as_ref().unwrap()[0];
assert_eq!(ra.hash.as_deref(), Some(expected.as_str()));
assert!(!ra.cache_hit, "first hash is a persistent-cache miss");
assert!(ra.bytes_hashed > 0);
let b = Daemon::new(config);
let rb = b.handle_hash_files(&req);
let rb = &rb.hash_results.as_ref().unwrap()[0];
assert_eq!(rb.hash.as_deref(), Some(expected.as_str()));
assert!(rb.cache_hit, "second daemon must hit the persistent cache");
assert_eq!(rb.bytes_hashed, 0);
}
#[test]
fn handle_hash_files_rejects_changed_metadata_before_hashing() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let daemon = Daemon::new(config);
let file = dir.path().join("input.bin");
std::fs::write(&file, b"stable bytes").unwrap();
let metadata = std::fs::metadata(&file).unwrap();
let resp = daemon.handle_hash_files(&HashFilesRequest {
files: vec![HashFileRequest {
path: file.to_string_lossy().into_owned(),
size: i64::try_from(metadata.len()).unwrap() + 1,
mtime_ns: crate::cache_key::metadata_mtime_ns(&metadata),
ctime_ns: crate::cache_key::metadata_ctime_ns(&metadata),
inode: crate::cache_key::metadata_inode(&metadata),
}],
});
assert!(resp.ok);
let result = &resp.hash_results.as_ref().unwrap()[0];
assert_eq!(result.hash, None);
assert_eq!(
result.error.as_deref(),
Some("file metadata changed before hashing")
);
}
#[test]
fn handle_hash_files_reports_hash_io_error() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let daemon = Daemon::new(config);
let input_dir = dir.path().join("not-a-file");
std::fs::create_dir(&input_dir).unwrap();
let metadata = std::fs::metadata(&input_dir).unwrap();
let resp = daemon.handle_hash_files(&HashFilesRequest {
files: vec![HashFileRequest {
path: input_dir.to_string_lossy().into_owned(),
size: i64::try_from(metadata.len()).unwrap(),
mtime_ns: crate::cache_key::metadata_mtime_ns(&metadata),
ctime_ns: crate::cache_key::metadata_ctime_ns(&metadata),
inode: crate::cache_key::metadata_inode(&metadata),
}],
});
assert!(resp.ok);
let result = &resp.hash_results.as_ref().unwrap()[0];
assert_eq!(result.hash, None);
assert_eq!(result.bytes_hashed, 0);
assert!(
result
.error
.as_deref()
.unwrap_or_default()
.contains("hashing"),
"got {:?}",
result.error
);
}
#[test]
fn test_handle_stats_with_store_entries() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let src_file = dir.path().join("lib.rlib");
std::fs::write(&src_file, vec![0u8; 100]).unwrap();
let store = Store::open(&config).unwrap();
store
.put(
"key1",
"mycrate",
&["lib".into()],
&[],
"host",
"dev",
&[(src_file, "lib.rlib".into())],
"",
"",
)
.unwrap();
drop(store);
let daemon = Daemon::new(config);
let resp = daemon.handle_stats(&StatsRequest {
include_entries: true,
include_summaries: false,
sort_by: Some("size".into()),
event_hours: Some(24),
client_epoch: 0,
});
assert!(resp.ok);
let stats = resp.stats.unwrap();
assert_eq!(stats.entry_count, 1);
assert!(stats.total_size >= 100);
assert_eq!(stats.blob_stats.as_ref().unwrap().total_blobs, 1);
let entries = stats.entries.unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].crate_name, "mycrate");
}
#[test]
fn test_handle_request_sync_dispatches_stats() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let daemon = Daemon::new(config);
let req = Request::Stats(StatsRequest {
include_entries: false,
include_summaries: false,
sort_by: None,
event_hours: None,
client_epoch: 0,
});
let resp = daemon.handle_request_sync(&req);
assert!(resp.ok);
assert!(resp.stats.is_some());
}
#[test]
fn test_send_stats_request_unreachable() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let result = send_stats_request(&config, false, None, None);
assert!(result.is_err());
}
#[tokio::test]
async fn test_socket_stats_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let socket_path = config.socket_path();
std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap();
let daemon = Arc::new(Daemon::new(config));
let resp = one_shot_request(
&daemon,
&socket_path,
&Request::Stats(StatsRequest {
include_entries: true,
include_summaries: false,
sort_by: Some("size".into()),
event_hours: Some(24),
client_epoch: 0,
}),
)
.await;
assert!(resp.ok);
let stats = resp.stats.unwrap();
assert_eq!(stats.total_size, 0);
assert_eq!(stats.entry_count, 0);
assert!(stats.entries.unwrap().is_empty());
}
#[tokio::test]
async fn test_socket_local_lookup_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let socket_path = config.socket_path();
std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap();
let store = Store::open(&config).unwrap();
let output_file = dir.path().join("out.rlib");
std::fs::write(&output_file, b"artifact-bytes").unwrap();
store
.put(
"0000000000000000000000000000000000000000000000000000000000000001",
"probe_crate",
&["lib".to_string()],
&[],
"x86_64-unknown-linux-gnu",
"dev",
&[(output_file, "libout.rlib".to_string())],
"cached stdout",
"",
)
.unwrap();
let index_db = crate::store::open_index_db(&config.index_db_path()).unwrap();
index_db
.execute(
"UPDATE entries SET last_accessed = datetime('now', '-1 hour')",
[],
)
.unwrap();
drop(store);
let daemon = Arc::new(Daemon::new_with_local_lookup_budget(config, None));
assert_eq!(daemon.local_lookup_budget, None);
let key = "0000000000000000000000000000000000000000000000000000000000000001";
tokio::time::timeout(Duration::from_secs(10), async {
daemon
.ensure_local_hit_service()
.await
.expect("prewarm local-hit service");
let resp = one_shot_request(
&daemon,
&socket_path,
&Request::LocalLookup(LocalLookupRequest {
key: key.to_string(),
client_epoch: 0,
}),
)
.await;
assert!(resp.ok);
let reply = resp.local_lookup.expect("local_lookup payload");
assert_eq!(
reply.outcome.as_str(),
"hit",
"unexpected local lookup reply: {reply:?}"
);
assert_eq!(reply.reason, None, "a hit has no fallback reason");
let meta = reply.meta.expect("hit carries meta");
assert_eq!(meta.cache_key, key);
assert_eq!(meta.stdout, "cached stdout");
assert_eq!(meta.files.len(), 1);
let (hits, recent): (i64, i64) = index_db
.query_row(
"SELECT hit_count, last_accessed >= datetime('now', '-60 seconds')
FROM entries WHERE cache_key = ?1",
[key],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap();
assert_eq!(hits, 1, "hit must be accounted by the pin writer");
assert_eq!(recent, 1, "pin must refresh last_accessed before the reply");
let resp = one_shot_request(
&daemon,
&socket_path,
&Request::LocalLookup(LocalLookupRequest {
key: "0000000000000000000000000000000000000000000000000000000000000002"
.to_string(),
client_epoch: 0,
}),
)
.await;
assert!(resp.ok);
assert_eq!(
resp.local_lookup.expect("payload"),
LocalLookupReply::miss()
);
})
.await
.expect("local lookup semantic roundtrip must not hang");
}
#[test]
fn daemon_local_lookup_defaults_to_the_shedding_budget() {
let dir = tempfile::tempdir().unwrap();
let daemon = Daemon::new(test_config(dir.path()));
assert_eq!(
daemon.local_lookup_budget,
Some(crate::daemon_local::LOCAL_LOOKUP_DEADLINE)
);
}
#[tokio::test]
async fn local_lookup_deadline_sheds_pending_work() {
let reply = await_local_lookup(
Some(Instant::now()),
std::future::pending::<LocalLookupReply>(),
)
.await;
assert_eq!(reply, LocalLookupReply::fallback("deadline exceeded"));
}
#[tokio::test]
async fn local_lookup_handler_applies_its_finite_budget() {
let dir = tempfile::tempdir().unwrap();
let daemon = Arc::new(Daemon::new_with_local_lookup_budget(
test_config(dir.path()),
Some(Duration::ZERO),
));
let response = daemon
.handle_local_lookup(&LocalLookupRequest {
key: "0000000000000000000000000000000000000000000000000000000000000001".to_string(),
client_epoch: 0,
})
.await;
assert!(response.ok);
assert_eq!(
response.local_lookup.expect("local lookup payload"),
LocalLookupReply::fallback("deadline exceeded")
);
}
#[tokio::test]
async fn detached_local_hit_initialization_survives_its_waiter() {
let dir = tempfile::tempdir().unwrap();
let daemon = Arc::new(Daemon::new(test_config(dir.path())));
let waiter = daemon.start_local_hit_initialization();
drop(waiter);
tokio::time::timeout(Duration::from_secs(10), async {
while daemon.local_hit.get().is_none() {
tokio::task::yield_now().await;
}
})
.await
.expect("detached initializer must populate the service");
}
#[tokio::test]
async fn prewarm_initializes_local_hit_service() {
let dir = tempfile::tempdir().unwrap();
let daemon = Arc::new(Daemon::new(test_config(dir.path())));
prewarm_local_hit_service(&daemon, Duration::from_secs(10)).await;
assert!(daemon.local_hit.get().is_some());
tokio::time::timeout(Duration::ZERO, daemon.ensure_local_hit_service())
.await
.expect("a warm lookup must not yield to task scheduling")
.expect("warm local-hit service");
}
#[tokio::test]
async fn test_socket_hash_files_roundtrip_hashes_a_real_file() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let socket_path = config.socket_path();
std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap();
let file_path = dir.path().join("input.bin");
std::fs::write(&file_path, b"hash me please").unwrap();
let meta = std::fs::metadata(&file_path).unwrap();
let req = HashFileRequest {
path: file_path.to_string_lossy().into_owned(),
size: meta.len() as i64,
mtime_ns: crate::cache_key::metadata_mtime_ns(&meta),
ctime_ns: crate::cache_key::metadata_ctime_ns(&meta),
inode: crate::cache_key::metadata_inode(&meta),
};
let expected = blake3::hash(b"hash me please").to_hex().to_string();
let daemon = Arc::new(Daemon::new(config));
let resp = one_shot_request(
&daemon,
&socket_path,
&Request::HashFiles(HashFilesRequest { files: vec![req] }),
)
.await;
assert!(resp.ok, "hash-files request should succeed: {resp:?}");
let results = resp.hash_results.expect("hash_results present");
assert_eq!(results.len(), 1);
assert_eq!(results[0].hash.as_deref(), Some(expected.as_str()));
assert_eq!(results[0].error, None);
}
#[tokio::test]
async fn test_socket_hash_files_missing_file_reports_error_result() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let socket_path = config.socket_path();
std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap();
let req = HashFileRequest {
path: dir
.path()
.join("does-not-exist")
.to_string_lossy()
.into_owned(),
size: 10,
mtime_ns: 0,
ctime_ns: 0,
inode: 0,
};
let daemon = Arc::new(Daemon::new(config));
let resp = one_shot_request(
&daemon,
&socket_path,
&Request::HashFiles(HashFilesRequest { files: vec![req] }),
)
.await;
assert!(resp.ok);
let results = resp.hash_results.expect("hash_results present");
assert_eq!(results.len(), 1);
assert!(results[0].hash.is_none());
assert!(results[0].error.is_some(), "missing file should error");
}
#[test]
fn send_hash_files_request_empty_is_ok_without_socket() {
let result = send_hash_files_request(Path::new("/nonexistent/socket"), Vec::new()).unwrap();
assert!(result.is_empty());
}
#[test]
fn send_hash_files_request_missing_socket_errors() {
let req = HashFileRequest {
path: "/some/file".into(),
size: 1,
mtime_ns: 0,
ctime_ns: 0,
inode: 0,
};
let err = send_hash_files_request(Path::new("/nonexistent/socket.sock"), vec![req])
.expect_err("missing socket -> error");
assert!(
err.to_string().contains("socket does not exist"),
"got: {err}"
);
}
#[test]
fn hash_files_response_parser_handles_results_error_and_malformed() {
let ok = Response::ok_hash_results(vec![HashFileResult {
path: "/tmp/a".into(),
size: 1,
mtime_ns: 2,
ctime_ns: 3,
inode: 4,
hash: Some("abc".into()),
cache_hit: false,
bytes_hashed: 1,
error: None,
}]);
let ok_json = serde_json::to_string(&ok).unwrap();
assert_eq!(
hash_files_results_from_response_line(&ok_json)
.unwrap()
.len(),
1
);
let err_json = serde_json::to_string(&Response::err("bad hash")).unwrap();
let err = hash_files_results_from_response_line(&err_json).unwrap_err();
assert!(err.to_string().contains("daemon hash_files error"));
let err = hash_files_results_from_response_line("{not json").unwrap_err();
assert!(err.to_string().contains("key must be a string"));
}
#[cfg(unix)]
#[tokio::test]
async fn send_hash_files_request_client_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let socket_path = config.socket_path();
std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap();
let file_path = dir.path().join("input.bin");
std::fs::write(&file_path, b"hash me please").unwrap();
let meta = std::fs::metadata(&file_path).unwrap();
let req = HashFileRequest {
path: file_path.to_string_lossy().into_owned(),
size: meta.len() as i64,
mtime_ns: crate::cache_key::metadata_mtime_ns(&meta),
ctime_ns: crate::cache_key::metadata_ctime_ns(&meta),
inode: crate::cache_key::metadata_inode(&meta),
};
let expected = blake3::hash(b"hash me please").to_hex().to_string();
let listener = bind_listener(&socket_path);
let daemon = Arc::new(Daemon::new(config.clone()));
let server = tokio::spawn(async move {
let stream = listener.accept().await.expect("accept");
let _ =
handle_connection(stream, &daemon, &AtomicBool::new(false), &Notify::new()).await;
});
let sp = socket_path.clone();
let results = tokio::task::spawn_blocking(move || send_hash_files_request(&sp, vec![req]))
.await
.unwrap()
.expect("send_hash_files_request should succeed");
server.await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].hash.as_deref(), Some(expected.as_str()));
}
#[tokio::test]
async fn test_socket_build_started_roundtrip_without_remote() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let socket_path = config.socket_path();
std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap();
let daemon = Arc::new(Daemon::new(config));
let resp = one_shot_request(
&daemon,
&socket_path,
&Request::BuildStarted(BuildStartedRequest {
intent: kache_core::BuildIntent {
crate_names: vec!["serde".into()],
namespace: Some("ns".into()),
cargo_lock_deps: vec![],
},
client_epoch: 0,
session_id: String::new(),
}),
)
.await;
assert!(!resp.ok);
assert!(resp.error.is_some());
}
#[tokio::test]
async fn test_socket_batch_remote_check_roundtrip_without_remote() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let socket_path = config.socket_path();
std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap();
let key = test_cache_key("socket-batch-no-remote");
let entry_dir = config.store_dir().join(&key).to_string_lossy().into_owned();
let daemon = Arc::new(Daemon::new(config));
let resp = one_shot_request(
&daemon,
&socket_path,
&Request::BatchRemoteCheck(BatchRemoteCheckRequest {
checks: vec![RemoteCheckRequest {
key,
entry_dir,
crate_name: "serde".into(),
deadline_ms: None,
}],
}),
)
.await;
assert!(resp.batch_results.is_some() || resp.error.is_some());
}
fn seed_store_entry(config: &Config, cache_key: &str, crate_name: &str, dir: &Path) {
let store = Store::open(config).unwrap();
let src = dir.join(format!("{cache_key}-src"));
std::fs::create_dir_all(&src).unwrap();
let artifact = src.join("libfoo.rlib");
std::fs::write(&artifact, b"artifact bytes").unwrap();
store
.put(
cache_key,
crate_name,
&["lib".to_string()],
&[],
"x86_64-unknown-linux-gnu",
"debug",
&[(artifact, "libfoo.rlib".to_string())],
"",
"",
)
.unwrap();
}
#[tokio::test]
async fn test_socket_stats_roundtrip_with_populated_store() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let socket_path = config.socket_path();
std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap();
seed_store_entry(&config, "statskey1", "serde", dir.path());
let daemon = Arc::new(Daemon::new(config));
let resp = one_shot_request(
&daemon,
&socket_path,
&Request::Stats(StatsRequest {
include_entries: true,
include_summaries: false,
sort_by: Some("size".into()),
event_hours: Some(24),
client_epoch: 0,
}),
)
.await;
assert!(resp.ok);
let stats = resp.stats.unwrap();
assert_eq!(stats.entry_count, 1);
assert!(stats.total_size > 0);
let entries = stats.entries.unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].crate_name, "serde");
}
#[tokio::test]
async fn test_send_stats_request_client_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let socket_path = config.socket_path();
std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap();
seed_store_entry(&config, "ckey1", "serde", dir.path());
let listener = bind_listener(&socket_path);
let daemon = Arc::new(Daemon::new(config.clone()));
let server = tokio::spawn(async move {
let stream = listener.accept().await.expect("accept");
handle_connection(stream, &daemon, &AtomicBool::new(false), &Notify::new())
.await
.expect("handle_connection");
});
let cfg = config.clone();
let stats = tokio::task::spawn_blocking(move || {
send_stats_request(&cfg, true, Some("size"), Some(24))
})
.await
.unwrap()
.expect("send_stats_request should succeed");
server.await.unwrap();
assert_eq!(stats.entry_count, 1);
assert_eq!(stats.entries.unwrap()[0].crate_name, "serde");
}
#[tokio::test]
async fn test_send_gc_request_client_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let socket_path = config.socket_path();
std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap();
seed_store_entry(&config, "gcc1", "serde", dir.path());
let listener = bind_listener(&socket_path);
let daemon = Arc::new(Daemon::new(config.clone()));
let server = tokio::spawn(async move {
for _ in 0..2 {
let stream = listener.accept().await.expect("accept");
handle_connection(stream, &daemon, &AtomicBool::new(false), &Notify::new())
.await
.expect("handle_connection");
}
});
let cfg = config.clone();
let outcome = tokio::task::spawn_blocking(move || send_gc_request(&cfg, Some(0)))
.await
.unwrap()
.expect("send_gc_request should succeed");
server.await.unwrap();
assert!(!outcome.skipped);
assert!(outcome.evicted.is_some());
}
#[tokio::test]
async fn test_send_gc_request_rejects_old_daemon_before_mutation() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let socket_path = config.socket_path();
std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap();
let daemon = Daemon::new(config.clone());
let response = daemon.handle_stats(&StatsRequest {
include_entries: false,
include_summaries: false,
sort_by: None,
event_hours: None,
client_epoch: build_epoch(),
});
let mut response_value = serde_json::to_value(response).unwrap();
response_value
.get_mut("stats")
.and_then(serde_json::Value::as_object_mut)
.unwrap()
.remove("gc_policy_version");
let mut response_line = serde_json::to_string(&response_value).unwrap();
response_line.push('\n');
let listener = bind_listener(&socket_path);
let server = tokio::spawn(async move {
let mut stream = listener.accept().await.expect("accept stats probe");
let mut request_line = String::new();
{
let mut reader = BufReader::new(&stream);
reader
.read_line(&mut request_line)
.await
.expect("read stats probe");
}
assert!(matches!(
serde_json::from_str::<Request>(&request_line).unwrap(),
Request::Stats(_)
));
stream
.write_all(response_line.as_bytes())
.await
.expect("write old stats response");
drop(stream);
assert!(
tokio::time::timeout(Duration::from_millis(200), listener.accept())
.await
.is_err(),
"client sent a request after the unsupported stats response"
);
});
let cfg = config.clone();
let error = match tokio::task::spawn_blocking(move || send_gc_request(&cfg, Some(0)))
.await
.unwrap()
{
Ok(_) => panic!("old daemon must be rejected before GC"),
Err(error) => error,
};
assert!(error.to_string().contains("predates GC policy version"));
server.await.unwrap();
}
#[tokio::test]
async fn test_send_remote_check_client_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(crate::config::RemoteConfig::test_s3("test", "artifacts"));
let socket_path = config.socket_path();
std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap();
let listener = bind_listener(&socket_path);
let daemon = Arc::new(Daemon::new(config.clone()));
daemon.signal_warming_complete();
let mut keys = HashMap::new();
keys.insert("c".repeat(64), "othercrate".to_string());
daemon.key_cache.populate(keys).await;
let server = tokio::spawn(async move {
loop {
let stream = listener.accept().await.expect("accept");
let _ = handle_connection(stream, &daemon, &AtomicBool::new(false), &Notify::new())
.await;
}
});
let cfg = config.clone();
let missing = "d".repeat(64);
let entry_dir = cfg.store_dir().join(&missing);
let result = tokio::task::spawn_blocking(move || {
send_remote_check(&cfg, &missing, &entry_dir, "crate")
})
.await
.unwrap();
server.abort();
let result = result.expect("authoritative miss yields a definitive result");
assert!(
!result.found,
"the missing key should round-trip as not found"
);
}
#[tokio::test]
async fn test_send_remote_check_error_response_yields_none() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path()); let socket_path = config.socket_path();
std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap();
let listener = bind_listener(&socket_path);
let daemon = Arc::new(Daemon::new(config.clone()));
daemon.signal_warming_complete();
let server = tokio::spawn(async move {
loop {
let stream = listener.accept().await.expect("accept");
let _ = handle_connection(stream, &daemon, &AtomicBool::new(false), &Notify::new())
.await;
}
});
let cfg = config.clone();
let key = "e".repeat(64);
let entry_dir = cfg.store_dir().join(&key);
let result =
tokio::task::spawn_blocking(move || send_remote_check(&cfg, &key, &entry_dir, "crate"))
.await
.unwrap();
server.abort();
assert!(
result.is_none(),
"an error response (no remote) must yield None"
);
}
#[tokio::test]
async fn test_send_shutdown_request_client_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let socket_path = config.socket_path();
std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap();
let listener = bind_listener(&socket_path);
let daemon = Arc::new(Daemon::new(config.clone()));
let server = tokio::spawn(async move {
let stream = listener.accept().await.expect("accept");
handle_connection(stream, &daemon, &AtomicBool::new(false), &Notify::new())
.await
.expect("handle_connection");
});
let cfg = config.clone();
let result = tokio::task::spawn_blocking(move || send_shutdown_request(&cfg))
.await
.unwrap();
server.await.unwrap();
assert!(result.is_ok(), "shutdown request should round-trip ok");
}
#[tokio::test]
async fn test_socket_gc_roundtrip_evicts_populated_store() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let socket_path = config.socket_path();
std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap();
seed_store_entry(&config, "gckey1", "tokio", dir.path());
let daemon = Arc::new(Daemon::new(config.clone()));
let resp = one_shot_request(
&daemon,
&socket_path,
&Request::Gc(GcRequest::explicit_age(0)),
)
.await;
assert!(resp.ok, "gc should succeed: {resp:?}");
assert!(resp.evicted.is_some(), "gc reports an evicted count");
}
fn test_remote_config() -> crate::config::RemoteConfig {
crate::config::RemoteConfig::test_s3("bucket", "prefix")
}
fn test_remote_backend() -> Arc<dyn crate::remote_backend::RemoteBackend> {
Arc::new(crate::remote_backend::memory_backend())
}
fn test_manifest_object_key(cache_key: &str, crate_name: &str) -> String {
format!("prefix/v3/manifests/{crate_name}/{cache_key}.json")
}
fn test_pack_object_key(cache_key: &str, crate_name: &str) -> String {
format!("prefix/v3/packs/{crate_name}/{cache_key}.tar.zst")
}
fn test_build_manifest_object_key() -> String {
format!(
"prefix/_manifests/{}.json",
crate::cli::default_manifest_key()
)
}
async fn put_test_object(
backend: &Arc<dyn crate::remote_backend::RemoteBackend>,
key: &str,
body: &[u8],
) {
backend
.put(key, body.to_vec(), None)
.await
.expect("seed test remote object");
}
struct PutFailBackend;
#[async_trait::async_trait]
impl crate::remote_backend::RemoteBackend for PutFailBackend {
async fn head(&self, _key: &str) -> Result<bool> {
Ok(false)
}
async fn get(
&self,
_key: &str,
_max_bytes: Option<u64>,
) -> Result<Option<crate::remote_backend::GetObject>> {
Ok(None)
}
async fn put(&self, _key: &str, _body: Vec<u8>, _content_type: Option<&str>) -> Result<()> {
anyhow::bail!("injected PUT failure")
}
async fn list(&self, _prefix: &str) -> Result<Vec<String>> {
Ok(Vec::new())
}
fn describe(&self, key: &str) -> String {
format!("failure://test/{key}")
}
}
struct BlockingPackBackend {
inner: Arc<dyn crate::remote_backend::RemoteBackend>,
pack_started: Arc<Notify>,
release_pack: Arc<Notify>,
}
#[async_trait::async_trait]
impl crate::remote_backend::RemoteBackend for BlockingPackBackend {
async fn head(&self, key: &str) -> Result<bool> {
self.inner.head(key).await
}
async fn get(
&self,
key: &str,
max_bytes: Option<u64>,
) -> Result<Option<crate::remote_backend::GetObject>> {
if key.contains("/v4/prefetch/packs/") {
self.pack_started.notify_waiters();
self.release_pack.notified().await;
}
self.inner.get(key, max_bytes).await
}
async fn put(&self, key: &str, body: Vec<u8>, content_type: Option<&str>) -> Result<()> {
self.inner.put(key, body, content_type).await
}
async fn list(&self, prefix: &str) -> Result<Vec<String>> {
self.inner.list(prefix).await
}
fn describe(&self, key: &str) -> String {
self.inner.describe(key)
}
}
#[tokio::test]
async fn test_socket_remote_check_miss_with_injected_mock_client() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
let socket_path = config.socket_path();
std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap();
let key = test_cache_key("socket-remote-miss");
let entry_dir = config.store_dir().join(&key).to_string_lossy().into_owned();
let client = test_remote_backend();
let daemon = Arc::new(Daemon::new(config.clone()));
assert!(
daemon.remote_backend.set(client).is_ok(),
"inject mock backend"
);
let resp = one_shot_request(
&daemon,
&socket_path,
&Request::RemoteCheck(RemoteCheckRequest {
key,
entry_dir,
crate_name: "serde".into(),
deadline_ms: None,
}),
)
.await;
assert!(resp.ok, "remote check should return a response: {resp:?}");
assert_eq!(resp.found, Some(false), "missing remote key -> found=false");
}
#[tokio::test]
async fn planner_shard_download_returns_the_remote_payload() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
let shard = crate::remote::Shard {
version: 3,
entries: vec![crate::remote::ShardEntry {
cache_key: test_cache_key("planner-shard-entry"),
crate_name: "serde".into(),
compile_time_ms: Some(1234),
artifact_size: Some(5678),
}],
};
let client = test_remote_backend();
put_test_object(
&client,
&crate::remote::shard_object_key("prefix", "workspace", "abc"),
&serde_json::to_vec(&shard).unwrap(),
)
.await;
let daemon = Daemon::new(config);
assert!(daemon.remote_backend.set(client).is_ok());
let downloaded = daemon
.download_planner_shard("workspace", "abc")
.await
.expect("planner shard download")
.expect("seeded shard must be returned");
assert_eq!(downloaded.version, 3);
assert_eq!(downloaded.entries, shard.entries);
}
#[tokio::test]
async fn test_socket_prefetch_empty_keys_lists_remote_then_no_op() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
let socket_path = config.socket_path();
std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap();
let client = test_remote_backend();
let daemon = Arc::new(Daemon::new(config));
assert!(
daemon.remote_backend.set(client).is_ok(),
"inject mock backend"
);
let resp = one_shot_request(
&daemon,
&socket_path,
&Request::Prefetch(PrefetchRequest {
keys: Vec::new(),
warm_all: false,
}),
)
.await;
assert!(resp.ok, "prefetch over empty remote should be ok: {resp:?}");
}
#[tokio::test]
async fn test_do_upload_skips_when_entry_already_in_remote() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
let key = test_cache_key("already-remote-upload");
let client = test_remote_backend();
put_test_object(&client, &test_manifest_object_key(&key, "serde"), b"{}").await;
let daemon = Arc::new(Daemon::new(config));
assert!(
daemon.remote_backend.set(client).is_ok(),
"inject mock backend"
);
let resp = daemon
.do_upload(&UploadJob {
key,
entry_dir: dir.path().join("entry").to_string_lossy().into_owned(),
crate_name: "serde".into(),
client_epoch: 0,
})
.await;
assert!(
resp.ok,
"already-present upload should be a no-op ok: {resp:?}"
);
}
#[tokio::test]
async fn test_do_upload_uploads_when_not_in_remote_records_v3_transfer_timestamps() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
config.prefetch_enabled = false;
let key = test_cache_key("new-upload");
seed_store_entry(&config, &key, "serde", dir.path());
let entry_dir = config.store_dir().join(&key);
let client = test_remote_backend();
let daemon = Arc::new(Daemon::new(config));
assert!(
daemon.remote_backend.set(client.clone()).is_ok(),
"inject mock backend"
);
let resp = daemon
.do_upload(&UploadJob {
key: key.clone(),
entry_dir: entry_dir.to_string_lossy().into_owned(),
crate_name: "serde".into(),
client_epoch: 0,
})
.await;
assert!(resp.ok, "upload of a new entry should succeed: {resp:?}");
assert!(
client
.head(&test_pack_object_key(&key, "serde"))
.await
.unwrap()
);
assert!(
client
.head(&test_manifest_object_key(&key, "serde"))
.await
.unwrap()
);
assert_v3_transfer_timestamps(&latest_transfer(&daemon));
}
#[tokio::test]
async fn test_do_upload_failure_records_v3_transfer_timestamps() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
let key = test_cache_key("failed-upload");
seed_store_entry(&config, &key, "serde", dir.path());
let entry_dir = config.store_dir().join(&key);
let client: Arc<dyn crate::remote_backend::RemoteBackend> = Arc::new(PutFailBackend);
let daemon = Arc::new(Daemon::new(config));
assert!(
daemon.remote_backend.set(client).is_ok(),
"inject mock backend"
);
let resp = daemon
.do_upload(&UploadJob {
key,
entry_dir: entry_dir.to_string_lossy().into_owned(),
crate_name: "serde".into(),
client_epoch: 0,
})
.await;
assert!(!resp.ok, "a denied upload PUT must fail: {resp:?}");
assert_eq!(
daemon
.transfer_counters
.uploads_failed
.load(Ordering::Relaxed),
1
);
assert_v3_transfer_timestamps(&latest_transfer(&daemon));
}
#[tokio::test]
async fn test_handle_build_started_falls_back_to_local_planning() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
let client = test_remote_backend();
let daemon = Arc::new(Daemon::new(config));
assert!(
daemon.remote_backend.set(client).is_ok(),
"inject mock backend"
);
let req = BuildStartedRequest {
intent: kache_core::BuildIntent {
crate_names: vec!["serde".into(), "tokio".into()],
namespace: None,
cargo_lock_deps: vec![],
},
client_epoch: 0,
session_id: String::new(),
};
let resp = daemon.handle_build_started(&req).await;
assert!(
resp.ok,
"fallback with nothing to prefetch should be ok: {resp:?}"
);
}
#[tokio::test]
async fn test_batch_remote_check_remote_path_with_injected_mock() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
let key_a = test_cache_key("batch-a");
let key_b = test_cache_key("batch-b");
let entry_a = config
.store_dir()
.join(&key_a)
.to_string_lossy()
.into_owned();
let entry_b = config
.store_dir()
.join(&key_b)
.to_string_lossy()
.into_owned();
let client = test_remote_backend();
let daemon = Arc::new(Daemon::new(config));
assert!(
daemon.remote_backend.set(client).is_ok(),
"inject mock backend"
);
let resp = daemon
.handle_batch_remote_check(&BatchRemoteCheckRequest {
checks: vec![
RemoteCheckRequest {
key: key_a,
entry_dir: entry_a,
crate_name: "serde".into(),
deadline_ms: None,
},
RemoteCheckRequest {
key: key_b,
entry_dir: entry_b,
crate_name: "tokio".into(),
deadline_ms: None,
},
],
})
.await;
assert!(resp.ok);
let results = resp.batch_results.expect("batch results present");
assert_eq!(results.len(), 2);
assert!(results.iter().all(|r| r.found == Some(false)));
}
#[tokio::test]
async fn test_remote_check_failure_records_v3_transfer_timestamps() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
let key = test_cache_key("corrupt-download");
let entry_dir = config.store_dir().join(&key).to_string_lossy().into_owned();
let client = test_remote_backend();
put_test_object(&client, &test_manifest_object_key(&key, "serde"), b"{}").await;
put_test_object(&client, &test_pack_object_key(&key, "serde"), b"not a pack").await;
let daemon = Arc::new(Daemon::new(config));
assert!(
daemon.remote_backend.set(client).is_ok(),
"inject mock backend"
);
let resp = daemon
.handle_remote_check(&RemoteCheckRequest {
key,
entry_dir,
crate_name: "serde".into(),
deadline_ms: None,
})
.await;
assert!(
!resp.ok,
"download failure should surface as an error: {resp:?}"
);
assert!(resp.error.is_some());
assert_v3_transfer_timestamps(&latest_transfer(&daemon));
}
#[test]
fn test_prefetch_concurrency_cap_reserves_interactive_permits() {
assert_eq!(prefetch_concurrency_cap(16), 12); assert_eq!(prefetch_concurrency_cap(8), 6); assert_eq!(prefetch_concurrency_cap(4), 3); assert_eq!(prefetch_concurrency_cap(2), 1); assert_eq!(prefetch_concurrency_cap(1), 1); assert_eq!(prefetch_concurrency_cap(0), 1); assert_eq!(prefetch_concurrency_cap(64), 60); for n in 2..=64u32 {
assert!(
prefetch_concurrency_cap(n) < n as usize,
"pool {n}: prefetch must never be able to hold every permit"
);
}
}
#[tokio::test]
async fn test_remote_check_known_positive_get_404_is_clean_miss() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
let client = test_remote_backend();
let daemon = Arc::new(Daemon::new(config));
assert!(
daemon.remote_backend.set(client).is_ok(),
"inject mock backend"
);
let key = test_cache_key("gone-positive");
let mut keys = HashMap::new();
keys.insert(key.clone(), "serde".to_string());
daemon.key_cache.populate(keys).await;
let resp = daemon
.handle_remote_check(&RemoteCheckRequest {
key: key.clone(),
entry_dir: daemon.entry_dir_for(&key).to_string_lossy().into_owned(),
crate_name: "serde".into(),
deadline_ms: None,
})
.await;
assert!(
resp.ok,
"GET 404 must be a clean miss, not an error: {resp:?}"
);
assert_eq!(resp.found, Some(false));
assert_eq!(daemon.key_cache.check(&key).await, Some(false));
assert_eq!(
daemon
.transfer_counters
.downloads_failed
.load(Ordering::Relaxed),
0
);
}
fn build_entry_pack_with_meta(key: &str, crate_name: &str) -> (Vec<u8>, String) {
let tmp = tempfile::tempdir().unwrap();
let cfg = test_config(tmp.path());
let store = Store::open(&cfg).unwrap();
let src = tmp.path().join("src");
std::fs::create_dir_all(&src).unwrap();
let artifact = src.join("libfoo.rlib");
std::fs::write(&artifact, b"real artifact bytes").unwrap();
store
.put(
key,
crate_name,
&["lib".to_string()],
&[],
"x86_64-unknown-linux-gnu",
"debug",
&[(artifact, "libfoo.rlib".to_string())],
"",
"",
)
.unwrap();
let entry_dir = store.entry_dir(key);
let meta_bytes = std::fs::read(entry_dir.join("meta.json")).unwrap();
let meta: crate::store::EntryMeta = serde_json::from_slice(&meta_bytes).unwrap();
let packed =
crate::remote_layout::create_entry_pack_zstd(&entry_dir, &store.blobs_dir(), &meta, 3)
.unwrap();
(packed, blake3::hash(&meta_bytes).to_hex().to_string())
}
fn build_entry_pack(key: &str, crate_name: &str) -> Vec<u8> {
build_entry_pack_with_meta(key, crate_name).0
}
#[test]
fn packed_prefetch_context_is_derived_from_a_complete_build_intent() {
let intent = kache_core::BuildIntent {
crate_names: vec!["serde".into()],
namespace: Some("linux/toolchain/release".into()),
cargo_lock_deps: vec![("serde".into(), "1.0.0".into())],
};
let context = PackPrefetchContext::from_intent(&intent)
.expect("a namespaced lockfile intent must enable catalog discovery");
assert_eq!(context.namespace, "linux/toolchain/release");
assert_eq!(context.shard_hashes.len(), 1);
assert!(crate::cache_key::is_valid_cache_key(&context.selector));
}
async fn seed_packed_catalog(
backend: &Arc<dyn crate::remote_backend::RemoteBackend>,
context: &PackPrefetchContext,
entries: Vec<crate::remote_pack::PackInputEntry>,
object_override: Option<Vec<u8>>,
) -> crate::remote_pack::BuiltPack {
let built = crate::remote_pack::build_pack(
"prefix",
entries,
crate::remote_pack::DEFAULT_MAX_PACK_BYTES,
)
.unwrap();
put_test_object(
backend,
&built.object_key,
object_override.as_deref().unwrap_or(&built.bytes),
)
.await;
let created_at_ms = epoch_ms();
let catalog = crate::remote_pack::PackCatalog {
version: crate::remote_pack::CATALOG_VERSION,
key_schema: crate::cache_key::CACHE_KEY_VERSION,
manifest_key: context.manifest_key.clone(),
namespace: context.namespace.clone(),
selector_hash: context.selector.clone(),
shard_hashes: context.shard_hashes.clone(),
created_at_ms,
expires_at_ms: created_at_ms + 60_000,
packs: vec![crate::remote_pack::CatalogPackRef {
digest: built.digest.clone(),
pack_bytes: built.bytes.len() as u64,
entries: built
.index
.entries
.iter()
.map(|entry| crate::remote_pack::CatalogEntry {
cache_key: entry.cache_key.clone(),
crate_name: entry.crate_name.clone(),
meta_digest: entry.meta_digest.clone(),
})
.collect(),
}],
fallback_entries: Vec::new(),
};
let encoded = crate::remote_pack::encode_catalog("prefix", catalog).unwrap();
put_test_object(backend, &encoded.object_key, &encoded.bytes).await;
built
}
async fn wait_for_store_entry(daemon: &Arc<Daemon>, key: &str) {
for _ in 0..200 {
if daemon
.with_store(|store| Ok(store.get(key)?.is_some()))
.unwrap_or(false)
{
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("timed out waiting for imported entry {}", key_prefix(key));
}
fn traversal_entry_payload() -> Vec<u8> {
let body = b"escape";
let mut header = [0u8; 512];
header[..13].copy_from_slice(b"../escape.txt");
header[100..107].copy_from_slice(b"0000644");
let size = format!("{:011o}", body.len());
header[124..135].copy_from_slice(size.as_bytes());
header[156] = b'0';
header[148..156].fill(b' ');
let checksum: u32 = header.iter().map(|byte| u32::from(*byte)).sum();
header[148..156].copy_from_slice(format!("{checksum:06o}\0 ").as_bytes());
let mut tar = Vec::new();
tar.extend_from_slice(&header);
tar.extend_from_slice(body);
tar.extend(std::iter::repeat_n(0, 512 - body.len()));
tar.extend(std::iter::repeat_n(0, 1024));
zstd::stream::encode_all(std::io::Cursor::new(tar), 3).unwrap()
}
#[tokio::test]
async fn packed_prefetch_discovers_one_pack_and_batch_imports_all_entries() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
config.prefetch_max_bytes = 0;
let backend = test_remote_backend();
let daemon = Arc::new(Daemon::new(config));
assert!(daemon.remote_backend.set(backend.clone()).is_ok());
let deps = vec![("serde".to_string(), "1.0.0".to_string())];
let context = PackPrefetchContext::from_deps(
crate::cli::default_manifest_key(),
"linux/toolchain/release",
&deps,
)
.unwrap();
let key_a = test_cache_key("packed-batch-a");
let key_b = test_cache_key("packed-batch-b");
let (payload_a, meta_a) = build_entry_pack_with_meta(&key_a, "serde");
let (payload_b, meta_b) = build_entry_pack_with_meta(&key_b, "tokio");
seed_packed_catalog(
&backend,
&context,
vec![
crate::remote_pack::PackInputEntry {
cache_key: key_a.clone(),
crate_name: "serde".into(),
meta_digest: meta_a,
payload: payload_a,
},
crate::remote_pack::PackInputEntry {
cache_key: key_b.clone(),
crate_name: "tokio".into(),
meta_digest: meta_b,
payload: payload_b,
},
],
None,
)
.await;
let sentinel = test_cache_key("existing-prefetched-key");
daemon
.prefetched_keys
.write()
.await
.insert(sentinel.clone());
let response = daemon
.handle_prefetch_with_context(
&PrefetchRequest {
keys: vec![
(key_a.clone(), "serde".into()),
(key_b.clone(), "tokio".into()),
],
warm_all: false,
},
Some(context),
Instant::now(),
)
.await;
assert!(response.ok);
wait_for_store_entry(&daemon, &key_a).await;
wait_for_store_entry(&daemon, &key_b).await;
assert!(
daemon
.with_store(|store| Ok(store.get(&key_a)?.is_some()))
.unwrap()
);
assert!(
daemon
.with_store(|store| Ok(store.get(&key_b)?.is_some()))
.unwrap()
);
assert_eq!(
daemon
.prefetch_stats
.pack_requests_total
.load(Ordering::Relaxed),
3
);
assert_eq!(
daemon
.prefetch_stats
.v3_requests_total
.load(Ordering::Relaxed),
0
);
let prefetched = daemon.prefetched_keys.read().await;
assert!(prefetched.contains(&sentinel));
assert!(prefetched.contains(&key_a));
assert!(prefetched.contains(&key_b));
}
#[tokio::test]
async fn packed_prefetch_response_returns_before_blocked_pack_get() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
let inner = test_remote_backend();
let context = PackPrefetchContext::from_deps(
crate::cli::default_manifest_key(),
"linux/toolchain/release",
&[("serde".to_string(), "1.0.0".to_string())],
)
.unwrap();
let key = test_cache_key("nonblocking-prefetch-pack");
let (payload, meta_digest) = build_entry_pack_with_meta(&key, "serde");
seed_packed_catalog(
&inner,
&context,
vec![crate::remote_pack::PackInputEntry {
cache_key: key.clone(),
crate_name: "serde".into(),
meta_digest,
payload,
}],
None,
)
.await;
let pack_started = Arc::new(Notify::new());
let release_pack = Arc::new(Notify::new());
let backend: Arc<dyn crate::remote_backend::RemoteBackend> =
Arc::new(BlockingPackBackend {
inner,
pack_started: pack_started.clone(),
release_pack: release_pack.clone(),
});
let daemon = Arc::new(Daemon::new(config));
assert!(daemon.remote_backend.set(backend).is_ok());
let started = pack_started.notified();
tokio::pin!(started);
started.as_mut().enable();
let response = tokio::time::timeout(
Duration::from_millis(250),
daemon.handle_prefetch_with_context(
&PrefetchRequest {
keys: vec![(key.clone(), "serde".into())],
warm_all: false,
},
Some(context),
Instant::now(),
),
)
.await
.expect("prefetch acknowledgement must not await the pack GET");
assert!(response.ok);
tokio::time::timeout(Duration::from_secs(1), started)
.await
.expect("background coordinator should start the pack GET");
assert!(!daemon.entry_dir_for(&key).exists());
release_pack.notify_waiters();
wait_for_store_entry(&daemon, &key).await;
}
#[tokio::test]
async fn demand_v3_read_completes_while_a_prefetch_pack_is_blocked() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
let inner = test_remote_backend();
let deps = vec![("serde".to_string(), "1.0.0".to_string())];
let context = PackPrefetchContext::from_deps(
crate::cli::default_manifest_key(),
"linux/toolchain/release",
&deps,
)
.unwrap();
let packed_key = test_cache_key("blocked-prefetch-pack");
let (packed_payload, packed_meta) = build_entry_pack_with_meta(&packed_key, "serde");
seed_packed_catalog(
&inner,
&context,
vec![crate::remote_pack::PackInputEntry {
cache_key: packed_key.clone(),
crate_name: "serde".into(),
meta_digest: packed_meta,
payload: packed_payload,
}],
None,
)
.await;
let demand_key = test_cache_key("demand-during-packed-prefetch");
let demand_payload = build_entry_pack(&demand_key, "tokio");
put_test_object(
&inner,
&test_manifest_object_key(&demand_key, "tokio"),
b"{}",
)
.await;
put_test_object(
&inner,
&test_pack_object_key(&demand_key, "tokio"),
&demand_payload,
)
.await;
let pack_started = Arc::new(Notify::new());
let release_pack = Arc::new(Notify::new());
let backend: Arc<dyn crate::remote_backend::RemoteBackend> =
Arc::new(BlockingPackBackend {
inner,
pack_started: pack_started.clone(),
release_pack: release_pack.clone(),
});
let daemon = Arc::new(Daemon::new(config));
assert!(daemon.remote_backend.set(backend).is_ok());
let started = pack_started.notified();
tokio::pin!(started);
started.as_mut().enable();
let packed_daemon = daemon.clone();
let packed = tokio::spawn(async move {
packed_daemon
.handle_prefetch_with_context(
&PrefetchRequest {
keys: vec![(packed_key, "serde".into())],
warm_all: false,
},
Some(context),
Instant::now(),
)
.await
});
tokio::time::timeout(Duration::from_secs(1), started)
.await
.expect("pack GET should block in the injected backend");
let demand = tokio::time::timeout(
Duration::from_secs(1),
daemon.handle_remote_check(&RemoteCheckRequest {
key: demand_key.clone(),
entry_dir: daemon
.entry_dir_for(&demand_key)
.to_string_lossy()
.into_owned(),
crate_name: "tokio".into(),
deadline_ms: None,
}),
)
.await
.expect("demand v3 read must not wait for the pack GET");
assert_eq!(demand.found, Some(true));
release_pack.notify_waiters();
assert!(packed.await.unwrap().ok);
}
#[tokio::test]
async fn corrupt_pack_falls_back_only_to_the_existing_v3_entry() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
let backend = test_remote_backend();
let daemon = Arc::new(Daemon::new(config));
assert!(daemon.remote_backend.set(backend.clone()).is_ok());
let deps = vec![("serde".to_string(), "1.0.0".to_string())];
let context = PackPrefetchContext::from_deps(
crate::cli::default_manifest_key(),
"linux/toolchain/release",
&deps,
)
.unwrap();
let key = test_cache_key("packed-corrupt-fallback");
let (payload, meta_digest) = build_entry_pack_with_meta(&key, "serde");
seed_packed_catalog(
&backend,
&context,
vec![crate::remote_pack::PackInputEntry {
cache_key: key.clone(),
crate_name: "serde".into(),
meta_digest,
payload: payload.clone(),
}],
Some(b"corrupt immutable pack".to_vec()),
)
.await;
put_test_object(&backend, &test_pack_object_key(&key, "serde"), &payload).await;
let response = daemon
.handle_prefetch_with_context(
&PrefetchRequest {
keys: vec![(key.clone(), "serde".into())],
warm_all: false,
},
Some(context),
Instant::now(),
)
.await;
assert!(response.ok);
wait_for_store_entry(&daemon, &key).await;
assert_eq!(
daemon
.prefetch_stats
.v3_requests_total
.load(Ordering::Relaxed),
1
);
assert_eq!(
daemon
.prefetch_stats
.pack_fallback_entries
.load(Ordering::Relaxed),
1
);
assert!(
daemon
.prefetch_stats
.pack_validation_failures
.load(Ordering::Relaxed)
>= 1
);
}
#[tokio::test]
async fn catalog_filename_timestamp_mismatch_rejects_context_and_uses_v3() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
let backend = test_remote_backend();
let daemon = Arc::new(Daemon::new(config));
assert!(daemon.remote_backend.set(backend.clone()).is_ok());
let context = PackPrefetchContext::from_deps(
crate::cli::default_manifest_key(),
"linux/toolchain/release",
&[("serde".to_string(), "1.0.0".to_string())],
)
.unwrap();
let key = test_cache_key("catalog-created-at-mismatch");
let (payload, meta_digest) = build_entry_pack_with_meta(&key, "serde");
let built = crate::remote_pack::build_pack(
"prefix",
vec![crate::remote_pack::PackInputEntry {
cache_key: key.clone(),
crate_name: "serde".into(),
meta_digest: meta_digest.clone(),
payload: payload.clone(),
}],
crate::remote_pack::DEFAULT_MAX_PACK_BYTES,
)
.unwrap();
put_test_object(&backend, &built.object_key, &built.bytes).await;
let created_at_ms = epoch_ms();
let catalog = crate::remote_pack::PackCatalog {
version: crate::remote_pack::CATALOG_VERSION,
key_schema: crate::cache_key::CACHE_KEY_VERSION,
manifest_key: context.manifest_key.clone(),
namespace: context.namespace.clone(),
selector_hash: context.selector.clone(),
shard_hashes: context.shard_hashes.clone(),
created_at_ms,
expires_at_ms: created_at_ms + 60_000,
packs: vec![crate::remote_pack::CatalogPackRef {
digest: built.digest,
pack_bytes: built.bytes.len() as u64,
entries: vec![crate::remote_pack::CatalogEntry {
cache_key: key.clone(),
crate_name: "serde".into(),
meta_digest,
}],
}],
fallback_entries: Vec::new(),
};
let encoded = crate::remote_pack::encode_catalog("prefix", catalog).unwrap();
let mismatched_key = crate::remote_pack::catalog_object_key(
"prefix",
&context.selector,
created_at_ms + 1,
&encoded.digest,
)
.unwrap();
put_test_object(&backend, &mismatched_key, &encoded.bytes).await;
put_test_object(&backend, &test_pack_object_key(&key, "serde"), &payload).await;
let response = daemon
.handle_prefetch_with_context(
&PrefetchRequest {
keys: vec![(key.clone(), "serde".into())],
warm_all: false,
},
Some(context),
Instant::now(),
)
.await;
assert!(response.ok);
wait_for_store_entry(&daemon, &key).await;
assert_eq!(
daemon
.prefetch_stats
.v3_requests_total
.load(Ordering::Relaxed),
1
);
assert_eq!(
daemon
.prefetch_stats
.pack_fallback_entries
.load(Ordering::Relaxed),
1
);
assert!(
daemon
.prefetch_stats
.pack_validation_failures
.load(Ordering::Relaxed)
>= 1
);
}
#[tokio::test]
async fn traversal_in_one_pack_entry_preserves_valid_batch_and_falls_back_per_entry() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
let backend = test_remote_backend();
let daemon = Arc::new(Daemon::new(config));
assert!(daemon.remote_backend.set(backend.clone()).is_ok());
let deps = vec![("serde".to_string(), "1.0.0".to_string())];
let context = PackPrefetchContext::from_deps(
crate::cli::default_manifest_key(),
"linux/toolchain/release",
&deps,
)
.unwrap();
let good_key = test_cache_key("packed-partial-good");
let bad_key = test_cache_key("packed-partial-traversal");
let (good_payload, good_meta) = build_entry_pack_with_meta(&good_key, "serde");
let (fallback_payload, _) = build_entry_pack_with_meta(&bad_key, "tokio");
seed_packed_catalog(
&backend,
&context,
vec![
crate::remote_pack::PackInputEntry {
cache_key: good_key.clone(),
crate_name: "serde".into(),
meta_digest: good_meta,
payload: good_payload,
},
crate::remote_pack::PackInputEntry {
cache_key: bad_key.clone(),
crate_name: "tokio".into(),
meta_digest: blake3::hash(b"malicious-meta").to_hex().to_string(),
payload: traversal_entry_payload(),
},
],
None,
)
.await;
put_test_object(
&backend,
&test_pack_object_key(&bad_key, "tokio"),
&fallback_payload,
)
.await;
let response = daemon
.handle_prefetch_with_context(
&PrefetchRequest {
keys: vec![
(good_key.clone(), "serde".into()),
(bad_key.clone(), "tokio".into()),
],
warm_all: false,
},
Some(context),
Instant::now(),
)
.await;
assert!(response.ok);
wait_for_store_entry(&daemon, &bad_key).await;
assert!(
daemon
.with_store(|store| Ok(store.get(&good_key)?.is_some()))
.unwrap()
);
assert!(!dir.path().join("escape.txt").exists());
assert_eq!(
daemon
.prefetch_stats
.v3_requests_total
.load(Ordering::Relaxed),
1
);
assert_eq!(
daemon
.prefetch_stats
.pack_fallback_entries
.load(Ordering::Relaxed),
1
);
}
#[tokio::test]
async fn test_remote_check_success_records_v3_transfer_timestamps() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
config.prefetch_enabled = false;
let key = test_cache_key("successful-download");
let pack = build_entry_pack(&key, "serde");
let entry_dir = config.store_dir().join(&key);
let client = test_remote_backend();
put_test_object(&client, &test_manifest_object_key(&key, "serde"), b"{}").await;
put_test_object(&client, &test_pack_object_key(&key, "serde"), &pack).await;
let daemon = Arc::new(Daemon::new(config.clone()));
assert!(
daemon.remote_backend.set(client).is_ok(),
"inject mock backend"
);
let resp = daemon
.handle_remote_check(&RemoteCheckRequest {
key: key.clone(),
entry_dir: entry_dir.to_string_lossy().into_owned(),
crate_name: "serde".to_string(),
deadline_ms: None,
})
.await;
assert!(resp.ok, "hit+download should succeed: {resp:?}");
assert_eq!(resp.found, Some(true));
assert!(
config.store_dir().join(&key).join("meta.json").exists(),
"entry should be imported into the local store"
);
assert_v3_transfer_timestamps(&latest_transfer(&daemon));
}
#[tokio::test]
async fn stale_meta_json_does_not_short_circuit_a_first_claim_leader() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
config.prefetch_enabled = false;
let key = test_cache_key("stale-meta");
let pack = build_entry_pack(&key, "serde");
let entry_dir = config.store_dir().join(&key);
std::fs::create_dir_all(&entry_dir).unwrap();
std::fs::write(entry_dir.join("meta.json"), "{}").unwrap();
let client = test_remote_backend();
put_test_object(&client, &test_manifest_object_key(&key, "serde"), b"{}").await;
put_test_object(&client, &test_pack_object_key(&key, "serde"), &pack).await;
let daemon = Arc::new(Daemon::new(config.clone()));
assert!(daemon.remote_backend.set(client).is_ok());
let resp = daemon
.handle_remote_check(&RemoteCheckRequest {
key: key.clone(),
entry_dir: entry_dir.to_string_lossy().into_owned(),
crate_name: "serde".to_string(),
deadline_ms: None,
})
.await;
assert!(resp.ok, "leader download should succeed: {resp:?}");
assert_eq!(resp.found, Some(true));
let store = Store::open(&config).unwrap();
assert!(
store.contains(&key),
"the leader must download and import — a stale meta.json is not a hit"
);
}
#[tokio::test]
async fn test_handle_prefetch_disabled_ignores_explicit_keys() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
config.prefetch_enabled = false;
let key = "0123456789abcdef".repeat(4);
let pack = build_entry_pack(&key, "serde");
let client = test_remote_backend();
put_test_object(&client, &test_pack_object_key(&key, "serde"), &pack).await;
let daemon = Arc::new(Daemon::new(config.clone()));
assert!(daemon.remote_backend.set(client).is_ok());
let resp = daemon
.handle_prefetch(&PrefetchRequest {
keys: vec![(key.clone(), "serde".to_string())],
warm_all: false,
})
.await;
assert!(resp.ok);
assert!(!config.store_dir().join(&key).join("meta.json").exists());
assert_eq!(
daemon
.prefetch_stats
.downloads_completed
.load(Ordering::Relaxed),
0
);
}
#[tokio::test]
async fn test_handle_prefetch_success_records_v3_transfer_timestamps() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
let key = "abcdef0123456789".repeat(4);
let key = key.as_str();
let pack = build_entry_pack(key, "serde");
let client = test_remote_backend();
put_test_object(&client, &test_pack_object_key(key, "serde"), &pack).await;
let daemon = Arc::new(Daemon::new(config.clone()));
assert!(
daemon.remote_backend.set(client).is_ok(),
"inject mock backend"
);
let resp = daemon
.handle_prefetch(&PrefetchRequest {
keys: vec![(key.to_string(), "serde".to_string())],
warm_all: false,
})
.await;
assert!(resp.ok, "prefetch dispatch should be ok: {resp:?}");
let entry_meta = config.store_dir().join(key).join("meta.json");
let mut imported = false;
for _ in 0..100 {
if entry_meta.exists() {
imported = true;
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert!(
imported,
"background prefetch coordinator should download + import the entry"
);
let mut transfer = None;
for _ in 0..100 {
transfer = daemon
.recent_transfers
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.back()
.cloned();
if transfer.is_some() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
let transfer = transfer.expect("completed prefetch should record transfer timing");
assert_v3_transfer_timestamps(&transfer);
assert!(
transfer.elapsed_ms >= transfer.import_lock_wait_ms + transfer.import_ms,
"end-to-end elapsed must include lock wait and import execution: {transfer:?}"
);
}
#[tokio::test]
async fn test_handle_prefetch_failure_records_v3_transfer_timestamps() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
let key = "abcdef0123456789".repeat(4);
let key = key.as_str();
let client = test_remote_backend();
put_test_object(
&client,
&test_pack_object_key(key, "serde"),
b"not a valid pack",
)
.await;
let daemon = Arc::new(Daemon::new(config.clone()));
assert!(
daemon.remote_backend.set(client).is_ok(),
"inject mock backend"
);
let resp = daemon
.handle_prefetch(&PrefetchRequest {
keys: vec![(key.to_string(), "serde".to_string())],
warm_all: false,
})
.await;
assert!(
resp.ok,
"prefetch dispatch is ok even if downloads fail: {resp:?}"
);
let mut failed = false;
for _ in 0..100 {
if daemon
.transfer_counters
.downloads_failed
.load(Ordering::Relaxed)
>= 1
{
failed = true;
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert!(failed, "a garbage pack must record a failed download");
assert_v3_transfer_timestamps(&latest_transfer(&daemon));
assert!(!config.store_dir().join(key).join("meta.json").exists());
}
#[tokio::test]
async fn test_populate_key_cache_lists_and_populates() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
let key_a = test_cache_key("listed-key-a");
let key_b = test_cache_key("listed-key-b");
let client = test_remote_backend();
put_test_object(&client, &test_manifest_object_key(&key_a, "serde"), b"{}").await;
put_test_object(&client, &test_manifest_object_key(&key_b, "tokio"), b"{}").await;
let daemon = Daemon::new(config);
assert!(
daemon.remote_backend.set(client).is_ok(),
"inject mock backend"
);
let count = populate_key_cache(&daemon)
.await
.expect("populate_key_cache should succeed");
assert_eq!(count, 2);
assert_eq!(daemon.key_cache.check(&key_a).await, Some(true));
}
#[tokio::test]
async fn test_monolithic_manifest_prefetch_downloads_and_filters() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
let remote = test_remote_config();
config.remote = Some(remote.clone());
let manifest = crate::remote::BuildManifest {
version: 3,
created: "2025-01-01T00:00:00Z".to_string(),
manifest_key: crate::cli::default_manifest_key(),
entries: vec![crate::remote::ManifestEntry {
cache_key: "cheapkey".to_string(),
crate_name: "cheap".to_string(),
compile_time_ms: 10, artifact_size: 100,
}],
};
let body = serde_json::to_vec(&manifest).unwrap();
let client = test_remote_backend();
put_test_object(&client, &test_build_manifest_object_key(), &body).await;
let daemon = Arc::new(Daemon::new(config));
monolithic_manifest_prefetch(&daemon, client.as_ref(), &remote).await;
}
#[tokio::test]
async fn test_manifest_prefetch_skips_when_no_manifest() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
let client = test_remote_backend();
let daemon = Arc::new(Daemon::new(config));
assert!(daemon.remote_backend.set(client).is_ok());
let queued = manifest_prefetch(&daemon, None, &dir.path().join("no-cargo-lock")).await;
assert_eq!(queued, 0, "a missing manifest must queue no entries");
}
#[tokio::test]
async fn test_manifest_prefetch_dispatches_expensive_entries() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
let key = "abcdef0123456789".repeat(4);
let manifest = crate::remote::BuildManifest {
version: 3,
created: "2025-01-01T00:00:00Z".to_string(),
manifest_key: crate::cli::default_manifest_key(),
entries: vec![crate::remote::ManifestEntry {
cache_key: key.clone(),
crate_name: "expensive".to_string(),
compile_time_ms: 5000, artifact_size: 100,
}],
};
let body = serde_json::to_vec(&manifest).unwrap();
let client = test_remote_backend();
put_test_object(&client, &test_build_manifest_object_key(), &body).await;
put_test_object(&client, &test_pack_object_key(&key, "expensive"), b"nope").await;
let daemon = Arc::new(Daemon::new(config));
assert!(
daemon.remote_backend.set(client).is_ok(),
"inject mock backend"
);
let queued = manifest_prefetch(&daemon, None, &dir.path().join("no-cargo-lock")).await;
assert_eq!(queued, 1, "the expensive manifest entry must be dispatched");
}
#[tokio::test]
async fn test_shard_prefetch_all_shards_missing_returns_zero() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
let lock = dir.path().join("Cargo.lock");
std::fs::write(
&lock,
"version = 3\n\n[[package]]\nname = \"serde\"\nversion = \"1.0.0\"\n\n\
[[package]]\nname = \"tokio\"\nversion = \"1.0.0\"\n",
)
.unwrap();
let client = test_remote_backend();
let daemon = Arc::new(Daemon::new(config));
let count = shard_prefetch(&daemon, &client, "prefix", "ns", &lock)
.await
.expect("shard prefetch should succeed");
assert_eq!(count, 0, "no shards matched -> nothing queued");
}
#[tokio::test]
async fn shard_prefetch_for_deps_returns_seeded_shard_entries() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
let key = test_cache_key("seeded-shard-prefetch");
seed_store_entry(&config, &key, "serde", dir.path());
let deps = vec![("serde".to_string(), "1.0.0".to_string())];
let shard_set = crate::shards::compute_shards("workspace", &deps);
assert!(!shard_set.shards.is_empty());
let client = test_remote_backend();
for (hash, _) in &shard_set.shards {
let shard = crate::remote::Shard {
version: 3,
entries: vec![crate::remote::ShardEntry {
cache_key: key.clone(),
crate_name: "serde".into(),
compile_time_ms: Some(5000),
artifact_size: Some(100),
}],
};
put_test_object(
&client,
&crate::remote::shard_object_key("prefix", "workspace", hash),
&serde_json::to_vec(&shard).unwrap(),
)
.await;
}
let daemon = Arc::new(Daemon::new(config));
assert!(daemon.remote_backend.set(client.clone()).is_ok());
let queued = shard_prefetch_for_deps(&daemon, &client, "prefix", "workspace", &deps)
.await
.expect("seeded shard prefetch");
assert_eq!(queued, shard_set.shards.len());
}
#[test]
fn test_batch_remote_check_request_serde() {
let req = Request::BatchRemoteCheck(BatchRemoteCheckRequest {
checks: vec![
RemoteCheckRequest {
key: "key1".into(),
entry_dir: "/tmp/key1".into(),
crate_name: String::new(),
deadline_ms: None,
},
RemoteCheckRequest {
key: "key2".into(),
entry_dir: "/tmp/key2".into(),
crate_name: String::new(),
deadline_ms: None,
},
],
});
let json = serde_json::to_string(&req).unwrap();
let parsed: Request = serde_json::from_str(&json).unwrap();
assert_eq!(req, parsed);
assert!(json.contains("\"batch_remote_check\""));
assert!(json.contains("\"key1\""));
assert!(json.contains("\"key2\""));
}
#[test]
fn test_prefetch_request_serde() {
let req = Request::Prefetch(PrefetchRequest {
keys: vec![
("key_a".into(), "serde".into()),
("key_b".into(), "tokio".into()),
],
warm_all: false,
});
let json = serde_json::to_string(&req).unwrap();
let parsed: Request = serde_json::from_str(&json).unwrap();
assert_eq!(req, parsed);
assert!(json.contains("\"prefetch\""));
assert!(json.contains("\"key_a\""));
}
#[test]
fn test_hash_files_request_serde() {
let req = Request::HashFiles(HashFilesRequest {
files: vec![HashFileRequest {
path: "/tmp/libfoo.rlib".into(),
size: 123,
mtime_ns: 456,
ctime_ns: 789,
inode: 1011,
}],
});
let json = serde_json::to_string(&req).unwrap();
let parsed: Request = serde_json::from_str(&json).unwrap();
assert_eq!(req, parsed);
assert!(json.contains("\"hash_files\""));
}
#[test]
fn test_prefetch_request_empty_keys_serde() {
let req = Request::Prefetch(PrefetchRequest {
keys: vec![],
warm_all: false,
});
let json = serde_json::to_string(&req).unwrap();
let parsed: Request = serde_json::from_str(&json).unwrap();
assert_eq!(req, parsed);
}
#[test]
fn test_prefetch_request_from_plan() {
let valid_key = "a".repeat(64);
let plan = PrefetchPlan {
plan_id: Some("plan-1".into()),
planner: Some("fallback".into()),
disposition: PrefetchDisposition::Execute,
candidates: vec![
kache_core::PrefetchCandidate::new(valid_key.clone(), "serde".into()),
kache_core::PrefetchCandidate::new("../../../etc/passwd".into(), "serde".into()),
kache_core::PrefetchCandidate::new(valid_key.clone(), "../evil".into()),
],
};
let req = PrefetchRequest::from_plan(plan);
assert_eq!(req.keys, vec![(valid_key, "serde".into())]);
}
#[test]
fn test_batch_response_serde() {
let batch = BatchResponse {
ok: true,
results: vec![Response::found(true), Response::found(false)],
error: None,
};
let json = serde_json::to_string(&batch).unwrap();
let parsed: BatchResponse = serde_json::from_str(&json).unwrap();
assert_eq!(batch, parsed);
assert_eq!(parsed.results.len(), 2);
assert_eq!(parsed.results[0].found, Some(true));
assert_eq!(parsed.results[1].found, Some(false));
}
#[tokio::test]
async fn test_wait_for_warming_already_signaled() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let daemon = Daemon::new(config);
daemon.signal_warming_complete();
let start = std::time::Instant::now();
assert!(daemon.wait_for_warming(Duration::from_millis(100)).await);
assert!(start.elapsed() < Duration::from_millis(500));
}
#[tokio::test]
async fn test_prefetch_disabled_remote_releases_warming_barrier() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(crate::config::RemoteConfig::test_s3("test", "artifacts"));
config.prefetch_enabled = false;
let daemon = Arc::new(Daemon::new(config));
assert!(
start_manifest_warming(&daemon).is_none(),
"prefetch-disabled startup must not spawn a warming task"
);
let start = std::time::Instant::now();
assert!(daemon.wait_for_warming(Duration::from_millis(100)).await);
assert!(
start.elapsed() < Duration::from_millis(500),
"prefetch-disabled exact checks must not pay the warming grace"
);
}
#[tokio::test]
async fn test_wait_for_warming_blocks_then_signals() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let daemon = Arc::new(Daemon::new(config));
let d = daemon.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
d.signal_warming_complete();
});
let start = std::time::Instant::now();
assert!(daemon.wait_for_warming(Duration::from_secs(5)).await);
let elapsed = start.elapsed();
assert!(elapsed >= Duration::from_millis(30));
assert!(elapsed < Duration::from_secs(1));
}
#[tokio::test]
async fn test_wait_for_warming_timeout() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let daemon = Daemon::new(config);
let start = std::time::Instant::now();
assert!(!daemon.wait_for_warming(Duration::from_millis(100)).await);
let elapsed = start.elapsed();
assert!(elapsed >= Duration::from_millis(90));
assert!(elapsed < Duration::from_millis(500));
}
#[tokio::test]
async fn test_wait_for_warming_multiple_waiters() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let daemon = Arc::new(Daemon::new(config));
let d1 = daemon.clone();
let d2 = daemon.clone();
let h1 = tokio::spawn(async move { d1.wait_for_warming(Duration::from_secs(5)).await });
let h2 = tokio::spawn(async move { d2.wait_for_warming(Duration::from_secs(5)).await });
tokio::time::sleep(Duration::from_millis(50)).await;
daemon.signal_warming_complete();
let (r1, r2) = tokio::join!(h1, h2);
assert!(r1.unwrap());
assert!(r2.unwrap());
}
#[tokio::test]
async fn test_handle_remote_check_skips_head_when_probe_circuit_is_open() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(crate::config::RemoteConfig::test_s3("test", "artifacts"));
let daemon = Daemon::new(config);
daemon.signal_warming_complete();
daemon.remote_breaker.note_failure("HEAD", "boom-1");
daemon.remote_breaker.note_failure("HEAD", "boom-2");
daemon.remote_breaker.note_failure("HEAD", "boom-3");
let key = test_cache_key("open-read-breaker");
let req = RemoteCheckRequest {
entry_dir: daemon.entry_dir_for(&key).to_string_lossy().into_owned(),
key,
crate_name: "crate".into(),
deadline_ms: None,
};
let resp = daemon.handle_remote_check(&req).await;
assert!(resp.ok);
assert_eq!(resp.found, Some(false));
assert_eq!(
daemon
.remote_breaker
.suppressed_ops(crate::remote_resilience::RemoteDirection::Read),
1
);
}
#[tokio::test]
async fn test_handle_remote_check_authoritative_key_cache_skips_s3() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(crate::config::RemoteConfig::test_s3("test", "artifacts"));
let daemon = Daemon::new(config);
daemon.signal_warming_complete();
let present = "a".repeat(64);
let mut keys = HashMap::new();
keys.insert(present.clone(), "othercrate".to_string());
daemon.key_cache.populate(keys).await;
let missing = "b".repeat(64);
let req = RemoteCheckRequest {
entry_dir: daemon
.entry_dir_for(&missing)
.to_string_lossy()
.into_owned(),
key: missing,
crate_name: "crate".into(),
deadline_ms: None,
};
let resp = daemon.handle_remote_check(&req).await;
assert!(resp.ok);
assert_eq!(
resp.found,
Some(false),
"fresh key cache should authoritatively report the missing key as not found"
);
assert_eq!(
daemon
.remote_breaker
.suppressed_ops(crate::remote_resilience::RemoteDirection::Read),
0
);
}
struct PanicOnGetBackend;
#[async_trait::async_trait]
impl crate::remote_backend::RemoteBackend for PanicOnGetBackend {
async fn head(&self, _key: &str) -> Result<bool> {
Ok(true)
}
async fn get(
&self,
key: &str,
_max_bytes: Option<u64>,
) -> Result<Option<crate::remote_backend::GetObject>> {
panic!("GET {key} must not be issued while the remote is degraded");
}
async fn put(&self, _key: &str, _body: Vec<u8>, _content_type: Option<&str>) -> Result<()> {
panic!("PUT must not be issued while the remote is degraded");
}
async fn list(&self, _prefix: &str) -> Result<Vec<String>> {
Ok(Vec::new())
}
fn describe(&self, key: &str) -> String {
format!("panic-on-get://test/{key}")
}
}
struct StallingGetBackend;
#[async_trait::async_trait]
impl crate::remote_backend::RemoteBackend for StallingGetBackend {
async fn head(&self, _key: &str) -> Result<bool> {
Ok(true)
}
async fn get(
&self,
_key: &str,
_max_bytes: Option<u64>,
) -> Result<Option<crate::remote_backend::GetObject>> {
std::future::pending::<()>().await;
unreachable!()
}
async fn put(&self, _key: &str, _body: Vec<u8>, _content_type: Option<&str>) -> Result<()> {
Ok(())
}
async fn list(&self, _prefix: &str) -> Result<Vec<String>> {
Ok(Vec::new())
}
fn describe(&self, key: &str) -> String {
format!("stalling://test/{key}")
}
}
struct FailingHeadBackend {
timeout: bool,
calls: std::sync::atomic::AtomicU64,
}
#[async_trait::async_trait]
impl crate::remote_backend::RemoteBackend for FailingHeadBackend {
async fn head(&self, _key: &str) -> Result<bool> {
self.calls
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if self.timeout {
Err(anyhow::Error::new(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"connect timed out",
)))
} else {
Err(anyhow::Error::new(
opendal::Error::new(opendal::ErrorKind::RateLimited, "503 Service Unavailable")
.set_temporary(),
))
}
}
async fn get(
&self,
_key: &str,
_max_bytes: Option<u64>,
) -> Result<Option<crate::remote_backend::GetObject>> {
Ok(None)
}
async fn put(&self, _key: &str, _body: Vec<u8>, _content_type: Option<&str>) -> Result<()> {
Ok(())
}
async fn list(&self, _prefix: &str) -> Result<Vec<String>> {
Ok(Vec::new())
}
fn describe(&self, key: &str) -> String {
format!("failing-head://test/{key}")
}
}
fn resilience_test_daemon(
dir: &Path,
backend: Arc<dyn crate::remote_backend::RemoteBackend>,
) -> Daemon {
let mut config = test_config(dir);
config.remote = Some(test_remote_config());
let daemon = Daemon::new(config);
daemon.signal_warming_complete();
assert!(
daemon.remote_backend.set(backend).is_ok(),
"inject mock backend"
);
daemon
}
fn check_request(dir: &Path, key: &str) -> RemoteCheckRequest {
let key = test_cache_key(key);
RemoteCheckRequest {
entry_dir: dir.join("store").join(&key).to_string_lossy().into_owned(),
key,
crate_name: "serde".into(),
deadline_ms: None,
}
}
#[tokio::test]
async fn test_negative_cache_second_check_skips_s3_and_upload_invalidates() {
let dir = tempfile::tempdir().unwrap();
let daemon = resilience_test_daemon(dir.path(), test_remote_backend());
let req = check_request(dir.path(), "cafe0123deadbeef");
let resp = daemon.handle_remote_check(&req).await;
assert_eq!(resp.found, Some(false));
let roundtrips_after_first = daemon
.transfer_counters
.remote_check_roundtrips
.load(Ordering::Relaxed);
assert_eq!(roundtrips_after_first, 1, "first check pays one HEAD");
assert_eq!(daemon.negative_keys.len(), 1, "definitive miss remembered");
let resp = daemon.handle_remote_check(&req).await;
assert_eq!(resp.found, Some(false));
assert_eq!(
daemon
.transfer_counters
.remote_check_roundtrips
.load(Ordering::Relaxed),
roundtrips_after_first,
"second check must not touch S3"
);
assert_eq!(daemon.negative_keys.hits(), 1);
daemon.note_key_present(&req.key, &req.crate_name).await;
assert_eq!(
daemon.negative_keys.len(),
0,
"upload invalidates the negative entry"
);
let resp = daemon.handle_remote_check(&req).await;
assert_eq!(
resp.found,
Some(false),
"the check after invalidation reaches S3 again instead of the negative cache"
);
assert_eq!(
daemon
.transfer_counters
.remote_check_roundtrips
.load(Ordering::Relaxed),
2,
"post-invalidation check pays a fresh round trip"
);
}
#[tokio::test]
async fn test_degraded_breaker_gates_the_download_path() {
let dir = tempfile::tempdir().unwrap();
let daemon = resilience_test_daemon(dir.path(), Arc::new(PanicOnGetBackend));
let req = check_request(dir.path(), "cafe0123deadbeef");
daemon
.key_cache
.populate(HashMap::from([(req.key.clone(), "serde".to_string())]))
.await;
daemon.remote_breaker.note_failure("GET", "boom-1");
daemon.remote_breaker.note_failure("GET", "boom-2");
daemon.remote_breaker.note_failure("GET", "boom-3");
assert!(daemon.remote_breaker.is_degraded());
let resp = daemon.handle_remote_check(&req).await;
assert!(resp.ok);
assert_eq!(resp.found, Some(false));
assert_eq!(
daemon
.transfer_counters
.downloads_suppressed
.load(Ordering::Relaxed),
1
);
assert_eq!(
daemon.negative_keys.len(),
0,
"a suppressed check is not a definitive miss"
);
}
#[tokio::test]
async fn test_restore_deadline_returns_miss_instead_of_hanging() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
config.remote_restore_timeout_secs = 1;
let daemon = Daemon::new(config);
daemon.signal_warming_complete();
assert!(
daemon
.remote_backend
.set(Arc::new(StallingGetBackend) as Arc<dyn crate::remote_backend::RemoteBackend>)
.is_ok()
);
let req = check_request(dir.path(), "cafe0123deadbeef");
daemon
.key_cache
.populate(HashMap::from([(req.key.clone(), "serde".to_string())]))
.await;
let start = std::time::Instant::now();
let resp = daemon.handle_remote_check(&req).await;
let elapsed = start.elapsed();
assert!(resp.ok);
assert_eq!(resp.found, Some(false), "deadline elapse answers miss");
assert!(
elapsed >= Duration::from_millis(900) && elapsed < Duration::from_secs(5),
"restore must return at ~the 1s deadline, took {elapsed:?}"
);
assert_eq!(
daemon
.transfer_counters
.downloads_failed
.load(Ordering::Relaxed),
1
);
assert_eq!(
daemon.negative_keys.len(),
0,
"a timeout is never negative-cached"
);
}
#[tokio::test]
async fn expired_remote_check_queued_by_handler_limiter_never_reaches_backend() {
let dir = tempfile::tempdir().unwrap();
let backend = Arc::new(FailingHeadBackend {
timeout: false,
calls: 0.into(),
});
let daemon = Arc::new(resilience_test_daemon(dir.path(), backend.clone()));
let socket_path = daemon.config.socket_path();
let listener = bind_listener(&socket_path);
let limiter = Arc::new(tokio::sync::Semaphore::new(1));
let held_slot = limiter.clone().acquire_owned().await.unwrap();
let (accepted_tx, accepted_rx) = tokio::sync::oneshot::channel();
let server_daemon = daemon.clone();
let server_limiter = limiter.clone();
let server = tokio::spawn(async move {
let stream = listener.accept().await.expect("accept");
let request_started_at = Instant::now();
accepted_tx.send(()).unwrap();
handle_connection_after_queue(
stream,
&server_daemon,
&AtomicBool::new(false),
&Notify::new(),
server_limiter,
request_started_at,
)
.await
});
let mut check = check_request(dir.path(), "expired-handler-queue");
check.deadline_ms = Some(10);
let request = Request::RemoteCheck(check);
let client_socket = socket_path.clone();
let client = tokio::spawn(async move { client_roundtrip(&client_socket, &request).await });
accepted_rx.await.expect("server accepted request");
tokio::time::sleep(Duration::from_millis(50)).await;
drop(held_slot);
let response = tokio::time::timeout(Duration::from_secs(2), client)
.await
.expect("expired queued request must receive a prompt miss")
.expect("client task");
assert!(response.ok);
assert_eq!(response.found, Some(false));
assert_eq!(
backend.calls.load(Ordering::Relaxed),
0,
"an expired request must not start HEAD after leaving the handler queue"
);
server
.await
.expect("server task")
.expect("connection handler");
}
#[tokio::test]
async fn test_do_upload_suppressed_while_degraded() {
let dir = tempfile::tempdir().unwrap();
let daemon = resilience_test_daemon(dir.path(), Arc::new(PanicOnGetBackend));
daemon.remote_breaker.note_failure("PUT", "boom-1");
daemon.remote_breaker.note_failure("PUT", "boom-2");
daemon.remote_breaker.note_failure("PUT", "boom-3");
let job = UploadJob {
key: test_cache_key("deferred-upload"),
entry_dir: dir.path().join("entry").to_string_lossy().into_owned(),
crate_name: "serde".into(),
client_epoch: 0,
};
seed_store_entry(&daemon.config, &job.key, "serde", dir.path());
let durable_job = persist_upload_job(&daemon.config, &job).unwrap();
let resp = daemon.do_upload(&durable_job).await;
assert!(!resp.ok, "a deferred upload must stay retryable: {resp:?}");
assert!(
resp.error
.as_deref()
.is_some_and(|error| error.starts_with("retryable:")),
"the worker must retain and retry the durable intent: {resp:?}"
);
assert!(upload_spool_path(&daemon.config, &job.key).is_file());
assert_eq!(
daemon
.transfer_counters
.uploads_suppressed
.load(Ordering::Relaxed),
1
);
assert_eq!(
daemon
.transfer_counters
.uploads_failed
.load(Ordering::Relaxed),
0,
"no PUT was attempted"
);
}
#[tokio::test]
async fn test_head_failure_classes_drive_retries_and_skip_negative_cache() {
let dir = tempfile::tempdir().unwrap();
let transient = Arc::new(FailingHeadBackend {
timeout: false,
calls: 0.into(),
});
let daemon = resilience_test_daemon(dir.path(), transient.clone());
let resp = daemon
.handle_remote_check(&check_request(dir.path(), "cafe0123deadbeef"))
.await;
assert_eq!(resp.found, Some(false), "fail-safe answer is miss");
assert_eq!(
transient.calls.load(std::sync::atomic::Ordering::Relaxed),
1,
"the daemon must issue one transport attempt"
);
assert_eq!(
daemon.negative_keys.len(),
0,
"soft failures are not misses"
);
let dir = tempfile::tempdir().unwrap();
let timeouts = Arc::new(FailingHeadBackend {
timeout: true,
calls: 0.into(),
});
let daemon = resilience_test_daemon(dir.path(), timeouts.clone());
for key in ["aaaa000000000001", "aaaa000000000002", "aaaa000000000003"] {
let resp = daemon
.handle_remote_check(&check_request(dir.path(), key))
.await;
assert_eq!(resp.found, Some(false));
}
assert_eq!(
timeouts.calls.load(std::sync::atomic::Ordering::Relaxed),
3,
"a timeout must not be retried at the daemon level"
);
assert!(daemon.remote_breaker.is_degraded());
let resp = daemon
.handle_remote_check(&check_request(dir.path(), "aaaa000000000004"))
.await;
assert_eq!(resp.found, Some(false));
assert_eq!(
timeouts.calls.load(std::sync::atomic::Ordering::Relaxed),
3,
"a degraded breaker suppresses the probe entirely"
);
assert_eq!(daemon.negative_keys.len(), 0);
}
#[tokio::test]
async fn test_handle_prefetch_no_remote() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path()); let daemon = Arc::new(Daemon::new(config));
let req = PrefetchRequest {
keys: vec![("k".into(), "mycrate".into())],
warm_all: false,
};
let resp = daemon.handle_prefetch(&req).await;
assert!(!resp.ok);
assert!(
resp.error
.as_deref()
.unwrap()
.contains("no remote configured")
);
}
#[tokio::test]
async fn test_prefetch_key_budget_truncates_and_reports() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
config.prefetch_max_keys = 1;
config.s3_concurrency = 2;
let keys = [
"1111111111111111".repeat(4),
"2222222222222222".repeat(4),
"3333333333333333".repeat(4),
];
let client = test_remote_backend();
for key in &keys {
put_test_object(&client, &test_manifest_object_key(key, "serde"), b"{}").await;
put_test_object(
&client,
&test_pack_object_key(key, "serde"),
&build_entry_pack(key, "serde"),
)
.await;
}
let daemon = Arc::new(Daemon::new(config.clone()));
assert!(
daemon.remote_backend.set(client).is_ok(),
"inject mock backend"
);
let _gate = daemon
.prefetch_gate
.clone()
.acquire_owned()
.await
.expect("gate permit");
let resp = daemon
.handle_prefetch(&PrefetchRequest {
keys: keys
.iter()
.map(|k| (k.clone(), "serde".to_string()))
.collect(),
warm_all: false,
})
.await;
assert!(resp.ok, "prefetch dispatch should be ok: {resp:?}");
assert_eq!(
daemon
.prefetch_stats
.keys_over_budget
.load(Ordering::Relaxed),
2,
"two of three candidates should be reported as dropped over budget"
);
}
#[test]
fn test_prefetch_key_budget_overflow() {
assert_eq!(prefetch_key_budget_overflow(10, 4), 6);
assert_eq!(prefetch_key_budget_overflow(4, 4), 0, "exactly at budget");
assert_eq!(prefetch_key_budget_overflow(3, 4), 0, "under budget");
assert_eq!(prefetch_key_budget_overflow(0, 4), 0, "empty plan");
assert_eq!(
prefetch_key_budget_overflow(10_000, 0),
0,
"0 disables the key budget"
);
}
#[test]
fn test_prefetch_byte_budget_exhausted() {
assert!(!prefetch_byte_budget_exhausted(1024, 0));
assert!(!prefetch_byte_budget_exhausted(1024, 1023));
assert!(
prefetch_byte_budget_exhausted(1024, 1024),
"a budget exactly met stops the next download"
);
assert!(prefetch_byte_budget_exhausted(1024, 4096), "overshot");
assert!(
!prefetch_byte_budget_exhausted(0, u64::MAX),
"0 disables the byte budget"
);
}
#[tokio::test]
async fn test_prefetch_deadline_stops_the_plan() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
config.prefetch_deadline_secs = 0;
let key = "4444444444444444".repeat(4);
let client = test_remote_backend();
put_test_object(&client, &test_manifest_object_key(&key, "serde"), b"{}").await;
put_test_object(
&client,
&test_pack_object_key(&key, "serde"),
&build_entry_pack(&key, "serde"),
)
.await;
let daemon = Arc::new(Daemon::new(config.clone()));
assert!(
daemon.remote_backend.set(client).is_ok(),
"inject mock backend"
);
let resp = daemon
.handle_prefetch(&PrefetchRequest {
keys: vec![(key.clone(), "serde".to_string())],
warm_all: false,
})
.await;
assert!(resp.ok, "prefetch dispatch should be ok: {resp:?}");
let entry_meta = config.store_dir().join(&key).join("meta.json");
let mut imported = false;
for _ in 0..100 {
if entry_meta.exists() {
imported = true;
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert!(
imported,
"prefetch_deadline_secs = 0 disables the deadline rather than dropping the plan"
);
}
#[tokio::test]
async fn test_empty_prefetch_request_does_not_warm_the_bucket() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
let key = "cccccccccccccccc".repeat(4);
let client = test_remote_backend();
put_test_object(&client, &test_manifest_object_key(&key, "serde"), b"{}").await;
put_test_object(
&client,
&test_pack_object_key(&key, "serde"),
&build_entry_pack(&key, "serde"),
)
.await;
let daemon = Arc::new(Daemon::new(config.clone()));
assert!(
daemon.remote_backend.set(client).is_ok(),
"inject mock backend"
);
let resp = daemon
.handle_prefetch(&PrefetchRequest {
keys: Vec::new(),
warm_all: false,
})
.await;
assert!(
resp.ok,
"an empty request is a no-op, not an error: {resp:?}"
);
tokio::time::sleep(Duration::from_millis(200)).await;
assert!(
daemon.downloading.read().await.is_empty(),
"an empty prefetch request must not claim any key"
);
assert!(
!config.store_dir().join(&key).join("meta.json").exists(),
"an empty prefetch request must not download anything"
);
assert_eq!(
daemon
.transfer_counters
.downloads_completed
.load(Ordering::Relaxed),
0
);
}
#[tokio::test]
async fn test_warm_all_prefetch_request_downloads_missing_keys() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
let key = "dddddddddddddddd".repeat(4);
let client = test_remote_backend();
put_test_object(&client, &test_manifest_object_key(&key, "serde"), b"{}").await;
put_test_object(
&client,
&test_pack_object_key(&key, "serde"),
&build_entry_pack(&key, "serde"),
)
.await;
let daemon = Arc::new(Daemon::new(config.clone()));
assert!(
daemon.remote_backend.set(client).is_ok(),
"inject mock backend"
);
let resp = daemon
.handle_prefetch(&PrefetchRequest {
keys: Vec::new(),
warm_all: true,
})
.await;
assert!(resp.ok, "warm_all dispatch should be ok: {resp:?}");
let entry_meta = config.store_dir().join(&key).join("meta.json");
let mut imported = false;
for _ in 0..100 {
if entry_meta.exists() {
imported = true;
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert!(
imported,
"warm_all should discover the key by listing and import it"
);
}
#[tokio::test]
async fn test_demand_does_not_wait_behind_unstarted_prefetch_candidates() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
config.s3_concurrency = 2;
let stalled_key = "aaaaaaaaaaaaaaaa".repeat(4);
let demanded_key = "bbbbbbbbbbbbbbbb".repeat(4);
let client = test_remote_backend();
for key in [&stalled_key, &demanded_key] {
put_test_object(&client, &test_manifest_object_key(key, "serde"), b"{}").await;
put_test_object(
&client,
&test_pack_object_key(key, "serde"),
&build_entry_pack(key, "serde"),
)
.await;
}
let daemon = Arc::new(Daemon::new(config.clone()));
assert!(
daemon.remote_backend.set(client).is_ok(),
"inject mock backend"
);
daemon.signal_warming_complete();
let _gate = daemon
.prefetch_gate
.clone()
.acquire_owned()
.await
.expect("gate permit");
let resp = daemon
.handle_prefetch(&PrefetchRequest {
keys: vec![
(stalled_key.clone(), "serde".to_string()),
(demanded_key.clone(), "serde".to_string()),
],
warm_all: false,
})
.await;
assert!(resp.ok, "prefetch dispatch should be ok: {resp:?}");
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(
!daemon.downloading.read().await.contains_key(&demanded_key),
"an unstarted prefetch candidate must not be claimed in `downloading`"
);
let resp = tokio::time::timeout(
Duration::from_secs(5),
daemon.handle_remote_check(&RemoteCheckRequest {
key: demanded_key.clone(),
entry_dir: config
.store_dir()
.join(&demanded_key)
.to_string_lossy()
.into_owned(),
crate_name: "serde".into(),
deadline_ms: None,
}),
)
.await
.expect("demand must not block behind an unstarted prefetch candidate");
assert!(resp.ok, "demand download should succeed: {resp:?}");
assert_eq!(
resp.found,
Some(true),
"the demanded entry should have been downloaded"
);
}
#[tokio::test]
async fn test_handle_upload_with_queue_returns_immediately() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(crate::config::RemoteConfig::test_s3("test", "artifacts"));
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::<UploadJob>();
let daemon = Daemon::new(config);
daemon.set_upload_tx(tx);
let job = UploadJob {
key: test_cache_key("queued-upload"),
entry_dir: "/tmp/test".into(),
crate_name: "serde".into(),
client_epoch: 0,
};
seed_store_entry(&daemon.config, &job.key, "serde", dir.path());
let resp = daemon.handle_upload(&job).await;
assert!(resp.ok);
assert!(resp.error.is_none());
assert!(
upload_spool_path(&daemon.config, &job.key).is_file(),
"queue acknowledgement must follow durable persistence"
);
}
#[tokio::test]
async fn test_handle_upload_queue_closed() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(crate::config::RemoteConfig::test_s3("test", "artifacts"));
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<UploadJob>();
let daemon = Daemon::new(config);
daemon.set_upload_tx(tx);
drop(rx);
let job = UploadJob {
key: test_cache_key("closed-upload-queue"),
entry_dir: "/tmp/test".into(),
crate_name: "serde".into(),
client_epoch: 0,
};
seed_store_entry(&daemon.config, &job.key, "serde", dir.path());
let resp = daemon.handle_upload(&job).await;
assert!(!resp.ok);
assert!(resp.error.as_deref().unwrap().contains("queue closed"));
}
#[tokio::test]
async fn test_handle_upload_dedup() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(crate::config::RemoteConfig::test_s3("test", "artifacts"));
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::<UploadJob>();
let daemon = Daemon::new(config);
daemon.set_upload_tx(tx);
let job = UploadJob {
key: test_cache_key("deduplicated-upload"),
entry_dir: "/tmp/test".into(),
crate_name: "serde".into(),
client_epoch: 0,
};
seed_store_entry(&daemon.config, &job.key, "serde", dir.path());
let resp1 = daemon.handle_upload(&job).await;
assert!(resp1.ok);
let resp2 = daemon.handle_upload(&job).await;
assert!(resp2.ok);
}
#[tokio::test]
async fn test_close_upload_queue_closes_buffer_with_daemon_clones_alive() {
let dir = tempfile::tempdir().unwrap();
let daemon = Arc::new(Daemon::new(test_config(dir.path())));
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<UploadJob>();
daemon.set_upload_tx(tx);
let worker_daemon = daemon.clone();
daemon.close_upload_queue();
let recv = tokio::time::timeout(Duration::from_millis(100), rx.recv())
.await
.expect("upload buffer should close promptly after close_upload_queue");
assert!(
recv.is_none(),
"upload buffer must close even while daemon clones remain alive"
);
drop(worker_daemon);
}
#[tokio::test]
async fn test_handle_upload_after_queue_close_rejects_without_direct_upload() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(crate::config::RemoteConfig::test_s3("test", "artifacts"));
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::<UploadJob>();
let daemon = Daemon::new(config);
daemon.set_upload_tx(tx);
daemon.close_upload_queue();
let job = UploadJob {
key: test_cache_key("late-upload"),
entry_dir: "/tmp/test".into(),
crate_name: "serde".into(),
client_epoch: 0,
};
seed_store_entry(&daemon.config, &job.key, "serde", dir.path());
let resp = daemon.handle_upload(&job).await;
assert!(!resp.ok);
assert!(resp.error.as_deref().unwrap().contains("queue closed"));
}
#[test]
fn upload_spool_policy_helpers_cover_boundaries_and_error_kinds() {
let not_found = std::io::Error::new(std::io::ErrorKind::NotFound, "missing");
let denied = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
let exists = std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists");
assert!(upload_spool_error_is_not_found(¬_found));
assert!(!upload_spool_error_is_not_found(&denied));
assert!(upload_spool_error_is_already_exists(&exists));
assert!(!upload_spool_error_is_already_exists(&denied));
assert!(upload_intent_size_is_valid(UPLOAD_SPOOL_MAX_BYTES - 1));
assert!(upload_intent_size_is_valid(UPLOAD_SPOOL_MAX_BYTES));
assert!(!upload_intent_size_is_valid(UPLOAD_SPOOL_MAX_BYTES + 1));
assert!(upload_spool_has_capacity(UPLOAD_SPOOL_MAX_JOBS - 1));
assert!(!upload_spool_has_capacity(UPLOAD_SPOOL_MAX_JOBS));
let count =
count_upload_spool_entries([Ok::<_, std::io::Error>(()), Ok::<_, std::io::Error>(())])
.unwrap();
assert_eq!(count, 2);
let count_error = count_upload_spool_entries([Err::<(), _>(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"injected unreadable entry",
))])
.unwrap_err();
assert!(format!("{count_error:#}").contains("injected unreadable entry"));
}
#[test]
fn upload_spool_paths_and_normalization_are_config_derived() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let key = test_cache_key("spool-path");
assert_eq!(config.upload_spool_dir(), dir.path().join("upload-queue"));
assert_eq!(
upload_spool_path(&config, &key),
dir.path().join("upload-queue").join(format!("{key}.json"))
);
let normalized = normalize_upload_job(
&config,
&UploadJob {
key: key.clone(),
entry_dir: "/untrusted/client/path".into(),
crate_name: "serde".into(),
client_epoch: 17,
},
)
.unwrap();
assert_eq!(normalized.key, key);
assert_eq!(
Path::new(&normalized.entry_dir),
config.store_dir().join(&normalized.key)
);
assert_eq!(normalized.crate_name, "serde");
assert_eq!(normalized.client_epoch, 17);
}
#[test]
fn upload_job_normalization_rejects_each_untrusted_component() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let invalid_key = normalize_upload_job(
&config,
&UploadJob {
key: "../escape".into(),
entry_dir: "/ignored".into(),
crate_name: "serde".into(),
client_epoch: 0,
},
)
.unwrap_err();
assert!(invalid_key.to_string().contains("invalid upload cache key"));
let invalid_crate = normalize_upload_job(
&config,
&UploadJob {
key: test_cache_key("invalid-crate"),
entry_dir: "/ignored".into(),
crate_name: "../serde".into(),
client_epoch: 0,
},
)
.unwrap_err();
assert!(
invalid_crate
.to_string()
.contains("invalid upload crate name")
);
}
#[test]
fn existing_upload_intent_accepts_the_exact_size_limit_only() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let key = test_cache_key("exact-size-intent");
fs::create_dir_all(config.upload_spool_dir()).unwrap();
assert!(existing_upload_job(&config, &key).unwrap().is_none());
let path = upload_spool_path(&config, &key);
fs::create_dir(&path).unwrap();
let non_file = existing_upload_job(&config, &key).unwrap_err();
assert!(non_file.to_string().contains("not a regular file"));
fs::remove_dir(&path).unwrap();
let job = UploadJob {
key: key.clone(),
entry_dir: "/hostile/serialized/path".into(),
crate_name: "serde".into(),
client_epoch: 23,
};
let mut exact = serde_json::to_vec(&job).unwrap();
assert!(exact.len() < UPLOAD_SPOOL_MAX_BYTES as usize);
exact.resize(UPLOAD_SPOOL_MAX_BYTES as usize, b' ');
fs::write(&path, &exact).unwrap();
let loaded = existing_upload_job(&config, &key).unwrap().unwrap();
assert_eq!(loaded.key, key);
assert_eq!(
Path::new(&loaded.entry_dir),
config.store_dir().join(&loaded.key)
);
exact.push(b' ');
fs::write(&path, exact).unwrap();
let oversized = existing_upload_job(&config, &key).unwrap_err();
assert!(oversized.to_string().contains("upload intent exceeds"));
}
#[test]
fn create_only_upload_publication_preserves_the_first_winner() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("intent.json");
assert!(publish_upload_job_create_only(&path, b"first").unwrap());
assert!(!publish_upload_job_create_only(&path, b"second").unwrap());
assert_eq!(fs::read(path).unwrap(), b"first");
}
#[test]
fn upload_spool_directory_sync_follows_creation() {
let dir = tempfile::tempdir().unwrap();
let spool = dir.path().join("upload-queue");
let steps = std::cell::RefCell::new(Vec::new());
ensure_upload_spool_dir_with(
&spool,
|path| {
steps.borrow_mut().push("create");
std::fs::create_dir_all(path)
},
|parent| {
assert_eq!(parent, dir.path());
assert!(spool.is_dir(), "parent sync must follow directory creation");
steps.borrow_mut().push("sync-parent");
Ok(())
},
)
.unwrap();
assert_eq!(steps.borrow().as_slice(), &["create", "sync-parent"]);
}
#[test]
fn upload_spool_directory_sync_failure_is_propagated() {
let dir = tempfile::tempdir().unwrap();
let spool = dir.path().join("upload-queue");
let error = ensure_upload_spool_dir_with(
&spool,
|path| std::fs::create_dir_all(path),
|_| {
Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"injected upload-spool parent fsync failure",
))
},
)
.unwrap_err();
assert!(
format!("{error:#}").contains("injected upload-spool parent fsync failure"),
"unexpected error: {error:#}"
);
}
#[test]
fn upload_spool_directory_creation_failure_is_propagated_before_sync() {
let dir = tempfile::tempdir().unwrap();
let spool = dir.path().join("upload-queue");
let error = ensure_upload_spool_dir_with(
&spool,
|_| {
Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"injected upload-spool creation failure",
))
},
|_| panic!("sync must not run after creation fails"),
)
.unwrap_err();
assert!(
format!("{error:#}").contains("injected upload-spool creation failure"),
"unexpected error: {error:#}"
);
}
#[test]
fn upload_intent_removal_is_idempotent_but_propagates_other_errors() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let key = test_cache_key("remove-upload-intent");
fs::create_dir_all(config.upload_spool_dir()).unwrap();
remove_upload_job(&config, &key).expect("a missing intent is already removed");
let path = upload_spool_path(&config, &key);
fs::create_dir(&path).unwrap();
let error = remove_upload_job(&config, &key).unwrap_err();
assert!(format!("{error:#}").contains("removing"));
assert!(path.is_dir(), "a failed removal must not hide the obstacle");
}
#[test]
fn upload_intent_loading_distinguishes_missing_from_unreadable_spools() {
let missing_dir = tempfile::tempdir().unwrap();
let missing_config = test_config(missing_dir.path());
assert!(load_upload_jobs(&missing_config).unwrap().is_empty());
let blocked_dir = tempfile::tempdir().unwrap();
let blocked_config = test_config(blocked_dir.path());
fs::write(blocked_config.upload_spool_dir(), b"not a directory").unwrap();
let error = load_upload_jobs(&blocked_config).unwrap_err();
assert!(format!("{error:#}").contains("reading"));
}
#[test]
fn upload_intent_loading_filters_each_invalid_shape_and_normalizes_paths() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let spool = config.upload_spool_dir();
fs::create_dir_all(&spool).unwrap();
let exact_key = test_cache_key("load-exact-size");
let exact_job = UploadJob {
key: exact_key.clone(),
entry_dir: "/hostile/replayed/path".into(),
crate_name: "serde".into(),
client_epoch: 31,
};
let mut exact_bytes = serde_json::to_vec(&exact_job).unwrap();
exact_bytes.resize(UPLOAD_SPOOL_MAX_BYTES as usize, b' ');
fs::write(upload_spool_path(&config, &exact_key), exact_bytes).unwrap();
let oversized_key = test_cache_key("load-oversized");
let oversized_job = UploadJob {
key: oversized_key.clone(),
entry_dir: "/ignored".into(),
crate_name: "serde".into(),
client_epoch: 0,
};
let mut oversized_bytes = serde_json::to_vec(&oversized_job).unwrap();
oversized_bytes.resize(UPLOAD_SPOOL_MAX_BYTES as usize + 1, b' ');
fs::write(upload_spool_path(&config, &oversized_key), oversized_bytes).unwrap();
let directory_key = test_cache_key("load-directory");
fs::create_dir(upload_spool_path(&config, &directory_key)).unwrap();
let mismatched_file_key = test_cache_key("load-mismatched-file");
let mismatched_job = UploadJob {
key: test_cache_key("load-mismatched-payload"),
entry_dir: "/ignored".into(),
crate_name: "serde".into(),
client_epoch: 0,
};
fs::write(
upload_spool_path(&config, &mismatched_file_key),
serde_json::to_vec(&mismatched_job).unwrap(),
)
.unwrap();
let invalid_crate_key = test_cache_key("load-invalid-crate");
let invalid_crate_job = UploadJob {
key: invalid_crate_key.clone(),
entry_dir: "/ignored".into(),
crate_name: "../serde".into(),
client_epoch: 0,
};
fs::write(
upload_spool_path(&config, &invalid_crate_key),
serde_json::to_vec(&invalid_crate_job).unwrap(),
)
.unwrap();
let jobs = load_upload_jobs(&config).unwrap();
assert_eq!(jobs.len(), 1, "only the exact-limit valid job may replay");
let loaded = &jobs[0];
assert_eq!(loaded.key, exact_key);
assert_eq!(loaded.crate_name, "serde");
assert_eq!(loaded.client_epoch, 31);
assert_eq!(
Path::new(&loaded.entry_dir),
config.store_dir().join(&loaded.key),
"serialized entry_dir must never be trusted"
);
}
#[test]
fn durable_upload_intent_replays_after_restart_and_normalizes_paths() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let key = test_cache_key("restart-upload");
let job = UploadJob {
key: key.clone(),
entry_dir: "/untrusted/client/path".into(),
crate_name: "serde".into(),
client_epoch: 7,
};
seed_store_entry(&config, &key, "serde", dir.path());
let persisted = persist_upload_job(&config, &job).unwrap();
assert_eq!(
Path::new(&persisted.entry_dir),
config.store_dir().join(&key)
);
let restarted_config = config.clone();
let replayed = load_upload_jobs(&restarted_config).unwrap();
assert_eq!(replayed, vec![persisted]);
remove_upload_job(&restarted_config, &key).unwrap();
assert!(load_upload_jobs(&restarted_config).unwrap().is_empty());
}
#[test]
fn duplicate_upload_intent_persistence_reuses_one_valid_create_only_winner() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let key = test_cache_key("double-persist-upload");
seed_store_entry(&config, &key, "serde", dir.path());
let first_job = UploadJob {
key: key.clone(),
entry_dir: "/wrapper/path".into(),
crate_name: "serde".into(),
client_epoch: 7,
};
let first = persist_upload_job(&config, &first_job).unwrap();
let path = upload_spool_path(&config, &key);
let first_bytes = fs::read(&path).unwrap();
let second = persist_upload_job(
&config,
&UploadJob {
entry_dir: "/daemon/path".into(),
client_epoch: 99,
..first_job
},
)
.unwrap();
assert_eq!(second.key, first.key);
assert_eq!(second.entry_dir, first.entry_dir);
assert_eq!(second.crate_name, first.crate_name);
assert_eq!(second.client_epoch, 99);
assert_eq!(fs::read(&path).unwrap(), first_bytes);
assert_eq!(fs::read_dir(config.upload_spool_dir()).unwrap().count(), 1);
assert_eq!(load_upload_jobs(&config).unwrap(), vec![first]);
}
#[test]
fn first_upload_intent_requires_a_committed_local_payload() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let key = test_cache_key("missing-upload-payload");
let error = persist_upload_job(
&config,
&UploadJob {
key: key.clone(),
entry_dir: "/missing".into(),
crate_name: "serde".into(),
client_epoch: 0,
},
)
.unwrap_err();
assert!(format!("{error:#}").contains("local cache entry missing"));
assert!(!upload_spool_path(&config, &key).exists());
}
#[test]
fn first_upload_intent_publication_serializes_with_gc_in_both_orders() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let key = test_cache_key("upload-gc-ordering");
seed_store_entry(&config, &key, "serde", dir.path());
let store = Store::open(&config).unwrap();
store.set_last_accessed_for_test(&key, "-48 hours");
let held_gc = store.acquire_gc_lock().unwrap();
let job = UploadJob {
key: key.clone(),
entry_dir: "/ignored".into(),
crate_name: "serde".into(),
client_epoch: 0,
};
let path = upload_spool_path(&config, &key);
let publisher_config = config.clone();
let (started_tx, started_rx) = mpsc::channel();
let (done_tx, done_rx) = mpsc::channel();
let publisher = std::thread::spawn(move || {
started_tx.send(()).unwrap();
done_tx
.send(persist_upload_job(&publisher_config, &job))
.unwrap();
});
started_rx
.recv_timeout(Duration::from_secs(1))
.expect("publisher started");
match done_rx.recv_timeout(Duration::from_millis(100)) {
Err(mpsc::RecvTimeoutError::Timeout) => {}
other => panic!("publisher must wait behind GC, got {other:?}"),
}
assert!(
!path.exists(),
"GC-first ordering must not publish outside gc.lock"
);
drop(held_gc);
done_rx
.recv_timeout(Duration::from_secs(5))
.expect("publisher unblocked")
.expect("publication succeeds after GC");
publisher.join().unwrap();
assert!(path.is_file());
let _gc_after_publication = store.acquire_gc_lock().unwrap();
let stats = store.evict_older_than(24).unwrap();
assert_eq!(stats.entries_pinned, 1);
assert!(store.contains(&key));
}
#[tokio::test]
async fn upload_pipeline_drain_deadline_includes_a_blocked_enqueue_task() {
let job = UploadJob {
key: test_cache_key("blocked-shutdown-enqueue"),
entry_dir: "/unused".into(),
crate_name: "serde".into(),
client_epoch: 0,
};
let (worker_tx, _worker_rx) = tokio::sync::mpsc::channel::<UploadJob>(1);
worker_tx.send(job.clone()).await.unwrap();
let (buffer_tx, mut buffer_rx) = tokio::sync::mpsc::unbounded_channel::<UploadJob>();
buffer_tx.send(job).unwrap();
drop(buffer_tx);
let enqueue_handle = tokio::spawn(async move {
while let Some(job) = buffer_rx.recv().await {
if worker_tx.send(job).await.is_err() {
break;
}
}
});
let timed_out = tokio::time::timeout(
Duration::from_secs(1),
drain_upload_pipeline(enqueue_handle, Vec::new(), Duration::from_millis(10)),
)
.await
.expect("the outer guard must not expire");
assert!(
timed_out,
"a full, non-draining worker channel must consume the shared drain deadline"
);
}
#[tokio::test]
async fn upload_pipeline_drain_reports_clean_completion() {
let enqueue = tokio::spawn(async {});
let workers = vec![tokio::spawn(async {}), tokio::spawn(async {})];
let timed_out = tokio::time::timeout(
Duration::from_secs(1),
drain_upload_pipeline(enqueue, workers, Duration::from_millis(100)),
)
.await
.expect("completed tasks must drain promptly");
assert!(!timed_out);
}
#[tokio::test]
async fn upload_pipeline_drain_deadline_includes_workers() {
let enqueue = tokio::spawn(async {});
let worker = tokio::spawn(std::future::pending::<()>());
let timed_out = tokio::time::timeout(
Duration::from_secs(1),
drain_upload_pipeline(enqueue, vec![worker], Duration::from_millis(10)),
)
.await
.expect("the outer guard must not expire");
assert!(
timed_out,
"a pending worker must consume the shared deadline"
);
}
#[test]
fn test_semaphore_created_with_config() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.s3_concurrency = 4;
let daemon = Daemon::new(config);
assert_eq!(daemon.s3_semaphore.available_permits(), 4);
}
#[test]
fn test_semaphore_min_one_permit() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.s3_concurrency = 0;
let daemon = Daemon::new(config);
assert_eq!(daemon.s3_semaphore.available_permits(), 1);
}
#[tokio::test]
async fn test_socket_prefetch_no_remote_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path()); let socket_path = config.socket_path();
std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap();
let daemon = Arc::new(Daemon::new(config));
let resp = one_shot_request(
&daemon,
&socket_path,
&Request::Prefetch(PrefetchRequest {
keys: vec![("key1".into(), "mycrate".into())],
warm_all: false,
}),
)
.await;
assert!(!resp.ok);
assert!(
resp.error
.as_deref()
.unwrap()
.contains("no remote configured")
);
}
#[tokio::test]
async fn test_key_cache_age_none_before_populate() {
let cache = S3KeyCache::new();
assert!(cache.age().await.is_none());
}
#[tokio::test]
async fn test_key_cache_age_some_after_populate() {
let cache = S3KeyCache::new();
cache.populate(HashMap::new()).await;
let age = cache.age().await;
assert!(age.is_some());
assert!(age.unwrap() < Duration::from_secs(1));
}
#[test]
fn test_build_started_request_serde() {
let req = Request::BuildStarted(BuildStartedRequest {
intent: kache_core::BuildIntent {
crate_names: vec!["serde".into(), "tokio".into(), "anyhow".into()],
namespace: Some("x86_64/hash/release".into()),
cargo_lock_deps: vec![("serde".into(), "1.0.0".into())],
},
client_epoch: 0,
session_id: String::new(),
});
let json = serde_json::to_string(&req).unwrap();
let parsed: Request = serde_json::from_str(&json).unwrap();
assert_eq!(req, parsed);
assert!(json.contains("\"build_started\""));
assert!(json.contains("\"serde\""));
assert!(json.contains("\"tokio\""));
assert!(json.contains("x86_64/hash/release"));
}
#[test]
fn test_build_started_request_empty_serde() {
let req = Request::BuildStarted(BuildStartedRequest {
intent: kache_core::BuildIntent::default(),
client_epoch: 0,
session_id: String::new(),
});
let json = serde_json::to_string(&req).unwrap();
let parsed: Request = serde_json::from_str(&json).unwrap();
assert_eq!(req, parsed);
}
#[tokio::test]
async fn test_send_build_started_client_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let socket_path = config.socket_path();
std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap();
let listener = bind_listener(&socket_path);
let daemon = Arc::new(Daemon::new(config.clone()));
let server = tokio::spawn(async move {
let stream = listener.accept().await.expect("accept");
let _ =
handle_connection(stream, &daemon, &AtomicBool::new(false), &Notify::new()).await;
});
let cfg = config.clone();
tokio::task::spawn_blocking(move || {
send_build_started(
&cfg,
BuildStartedRequest {
intent: kache_core::BuildIntent {
crate_names: vec!["serde".into()],
..Default::default()
},
client_epoch: 0,
session_id: String::new(),
},
)
})
.await
.unwrap();
server.await.unwrap();
}
#[tokio::test]
async fn test_send_upload_job_client_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let key = "a".repeat(64);
seed_store_entry(&config, &key, "serde", dir.path());
let socket_path = config.socket_path();
std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap();
let listener = bind_listener(&socket_path);
let daemon = Arc::new(Daemon::new(config.clone()));
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::<UploadJob>();
daemon.set_upload_tx(tx);
let server = tokio::spawn(async move {
let stream = listener.accept().await.expect("accept");
let _ =
handle_connection(stream, &daemon, &AtomicBool::new(false), &Notify::new()).await;
});
let cfg = config.clone();
let result = tokio::task::spawn_blocking(move || {
send_upload_job(&cfg, &key, Path::new("/tmp/test"), "serde")
})
.await
.unwrap();
assert!(result.is_ok(), "upload job should send to a live daemon");
assert_eq!(
load_upload_jobs(&config).unwrap().len(),
1,
"the client must durably publish the upload before sending"
);
tokio::time::timeout(Duration::from_secs(1), server)
.await
.expect("the upload request must reach the live daemon")
.unwrap();
}
#[tokio::test]
async fn test_send_prefetch_client_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let socket_path = config.socket_path();
std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap();
let listener = bind_listener(&socket_path);
let daemon = Arc::new(Daemon::new(config.clone()));
let server = tokio::spawn(async move {
let stream = listener.accept().await.expect("accept");
let _ =
handle_connection(stream, &daemon, &AtomicBool::new(false), &Notify::new()).await;
});
let cfg = config.clone();
let result = tokio::task::spawn_blocking(move || {
send_prefetch(&cfg, &[("a".repeat(64), "serde".to_string())])
})
.await
.unwrap();
server.await.unwrap();
assert!(result.is_ok(), "prefetch hint should send to a live daemon");
}
#[tokio::test]
async fn test_handle_build_started_no_remote() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path()); let daemon = Arc::new(Daemon::new(config));
let req = BuildStartedRequest {
intent: kache_core::BuildIntent {
crate_names: vec!["mycrate".into()],
..Default::default()
},
client_epoch: 0,
session_id: String::new(),
};
let resp = daemon.handle_build_started(&req).await;
assert!(!resp.ok);
assert!(
resp.error
.as_deref()
.unwrap()
.contains("no remote configured")
);
}
#[tokio::test]
async fn test_handle_build_started_prefetch_disabled_is_a_no_op() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.remote = Some(test_remote_config());
config.prefetch_enabled = false;
let daemon = Arc::new(Daemon::new(config));
let resp = daemon
.handle_build_started(&BuildStartedRequest {
intent: kache_core::BuildIntent {
crate_names: vec!["serde".into(), "tokio".into()],
..Default::default()
},
client_epoch: 0,
session_id: "disabled-prefetch".into(),
})
.await;
assert!(resp.ok);
assert!(daemon.active_plan.lock().unwrap().is_none());
assert_eq!(
daemon.prefetch_stats.plans_advisory.load(Ordering::Relaxed),
0
);
assert_eq!(
daemon.prefetch_stats.plans_fallback.load(Ordering::Relaxed),
0
);
}
#[test]
fn test_handle_request_sync_rejects_build_started() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let daemon = Daemon::new(config);
let req = Request::BuildStarted(BuildStartedRequest {
intent: kache_core::BuildIntent {
crate_names: vec!["c".into()],
..Default::default()
},
client_epoch: 0,
session_id: String::new(),
});
let resp = daemon.handle_request_sync(&req);
assert!(!resp.ok);
assert!(resp.error.as_deref().unwrap().contains("async"));
}
#[tokio::test]
async fn test_downloading_map_starts_empty() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let daemon = Daemon::new(config);
assert!(daemon.downloading.read().await.is_empty());
}
#[tokio::test]
async fn packed_download_claim_rejects_disk_and_inflight_entries_independently() {
let tmp = tempfile::tempdir().unwrap();
let map = RwLock::new(HashMap::new());
let existing_key = test_cache_key("packed-existing-on-disk");
let existing_dir = tmp.path().join("existing");
std::fs::create_dir_all(&existing_dir).unwrap();
assert!(
!try_claim_packed_download(&map, &existing_key, &existing_dir).await,
"an on-disk entry must not be claimed again"
);
assert!(map.read().await.is_empty());
let inflight_key = test_cache_key("packed-inflight");
assert!(claim_download(&map, &inflight_key).await.is_none());
assert!(
!try_claim_packed_download(&map, &inflight_key, &tmp.path().join("absent")).await,
"an in-flight entry must not acquire a second claim"
);
let fresh_key = test_cache_key("packed-fresh");
assert!(
try_claim_packed_download(&map, &fresh_key, &tmp.path().join("fresh")).await,
"an absent unclaimed entry must become the download leader"
);
let claims = map.read().await;
assert!(claims.contains_key(&inflight_key));
assert!(claims.contains_key(&fresh_key));
}
async fn park_on_claim(map: &RwLock<HashMap<String, Arc<Notify>>>, notify: &Notify, key: &str) {
let notified = notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
if map.read().await.contains_key(key) {
let _ = tokio::time::timeout(Duration::from_secs(10), notified).await;
}
}
#[tokio::test]
async fn downloading_guard_removes_key_via_runtime_when_lock_contended() {
let notify = Arc::new(Notify::new());
let mut keys = HashMap::new();
keys.insert("cache-key".to_string(), notify.clone());
let map = Arc::new(RwLock::new(keys));
let waiter = tokio::spawn({
let map = map.clone();
let notify = notify.clone();
async move {
park_on_claim(&map, ¬ify, "cache-key").await;
!map.read().await.contains_key("cache-key")
}
});
tokio::time::sleep(Duration::from_millis(20)).await;
let write_guard = map.write().await;
let guard = DownloadingGuard::new(map.clone(), "cache-key".to_string());
drop(guard);
assert!(write_guard.contains_key("cache-key"));
drop(write_guard);
let mut removed = false;
for _ in 0..20 {
if !map.read().await.contains_key("cache-key") {
removed = true;
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(removed, "contended drop should eventually remove the key");
let key_gone_at_wake = tokio::time::timeout(Duration::from_secs(5), waiter)
.await
.expect("waiter should be notified by the contended drop path")
.unwrap();
assert!(key_gone_at_wake, "wake must happen after the map removal");
}
#[tokio::test]
async fn waiter_wakes_promptly_and_reclaims_when_leader_fails() {
let map: Arc<RwLock<HashMap<String, Arc<Notify>>>> = Arc::new(RwLock::new(HashMap::new()));
assert!(
claim_download(&map, "k").await.is_none(),
"first claim is the leader"
);
let leader_guard = DownloadingGuard::new(map.clone(), "k".to_string());
let notify = claim_download(&map, "k")
.await
.expect("second claim is a waiter");
let waiter = tokio::spawn({
let map = map.clone();
async move {
let start = Instant::now();
park_on_claim(&map, ¬ify, "k").await;
let won = claim_download(&map, "k").await.is_none();
(start.elapsed(), won)
}
});
tokio::time::sleep(Duration::from_millis(50)).await; drop(leader_guard); let (elapsed, won) = waiter.await.unwrap();
assert!(won, "waiter should win the re-claim after leader failure");
assert!(
elapsed < Duration::from_secs(5),
"waiter should wake promptly, waited {elapsed:?}"
);
}
#[tokio::test]
async fn exactly_one_waiter_wins_reclaim_after_leader_failure() {
let map: Arc<RwLock<HashMap<String, Arc<Notify>>>> = Arc::new(RwLock::new(HashMap::new()));
assert!(claim_download(&map, "k").await.is_none());
let leader_guard = DownloadingGuard::new(map.clone(), "k".to_string());
let n1 = claim_download(&map, "k").await.unwrap();
let n2 = claim_download(&map, "k").await.unwrap();
let spawn_waiter = |notify: Arc<Notify>| {
let map = map.clone();
tokio::spawn(async move {
park_on_claim(&map, ¬ify, "k").await;
claim_download(&map, "k").await.is_none()
})
};
let w1 = spawn_waiter(n1);
let w2 = spawn_waiter(n2);
tokio::time::sleep(Duration::from_millis(50)).await; drop(leader_guard);
let (r1, r2) = tokio::join!(w1, w2);
let wins = usize::from(r1.unwrap()) + usize::from(r2.unwrap());
assert_eq!(wins, 1, "exactly one waiter must win the re-claim");
}
#[tokio::test]
async fn waiter_adopts_the_current_leader_generation() {
let dir = tempfile::tempdir().unwrap();
let entry_dir = dir.path().join("entry");
std::fs::create_dir_all(&entry_dir).unwrap();
let map: Arc<RwLock<HashMap<String, Arc<Notify>>>> = Arc::new(RwLock::new(HashMap::new()));
assert!(claim_download(&map, "k").await.is_none());
let leader_guard = DownloadingGuard::new(map.clone(), "k".to_string());
let stale = Arc::new(Notify::new());
let waiter = tokio::spawn({
let map = map.clone();
let entry_dir = entry_dir.clone();
async move {
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
join_inflight_download(&map, "k", &entry_dir, stale, deadline).await
}
});
tokio::time::sleep(Duration::from_millis(50)).await; std::fs::write(entry_dir.join("meta.json"), "{}").unwrap();
drop(leader_guard);
assert_eq!(
tokio::time::timeout(Duration::from_secs(2), waiter)
.await
.expect("an adopted leader's completion must wake the waiter promptly")
.unwrap(),
JoinOutcome::Found
);
}
#[tokio::test]
async fn waiter_gives_up_as_miss_when_leader_holds_claim_past_budget() {
let dir = tempfile::tempdir().unwrap();
let entry_dir = dir.path().join("entry");
let map: Arc<RwLock<HashMap<String, Arc<Notify>>>> = Arc::new(RwLock::new(HashMap::new()));
assert!(
claim_download(&map, "k").await.is_none(),
"first claim is the (wedged) leader"
);
let notify = claim_download(&map, "k").await.expect("waiter");
let start = Instant::now();
let deadline = tokio::time::Instant::now() + Duration::from_millis(200);
let outcome = join_inflight_download(&map, "k", &entry_dir, notify, deadline).await;
assert_eq!(outcome, JoinOutcome::GaveUp);
assert!(
start.elapsed() < Duration::from_secs(5),
"give-up must be prompt once the budget expires"
);
assert!(
map.read().await.contains_key("k"),
"the wedged leader's claim must remain in place — the waiter took nothing over"
);
}
#[test]
fn download_join_deadline_uses_the_earliest_budget() {
let now = tokio::time::Instant::now();
let join_budget = now.checked_add(DOWNLOAD_JOIN_BUDGET).unwrap();
let sooner = now.checked_add(Duration::from_secs(1)).unwrap();
let later = now
.checked_add(DOWNLOAD_JOIN_BUDGET + Duration::from_secs(1))
.unwrap();
assert_eq!(download_join_deadline(now, None), join_budget);
assert_eq!(download_join_deadline(now, Some(later)), join_budget);
assert_eq!(download_join_deadline(now, Some(sooner)), sooner);
}
#[tokio::test]
async fn join_inflight_download_reclaims_on_failure_and_finds_on_success() {
let dir = tempfile::tempdir().unwrap();
let entry_dir = dir.path().join("entry");
std::fs::create_dir_all(&entry_dir).unwrap();
let map: Arc<RwLock<HashMap<String, Arc<Notify>>>> = Arc::new(RwLock::new(HashMap::new()));
assert!(claim_download(&map, "k").await.is_none());
let leader_guard = DownloadingGuard::new(map.clone(), "k".to_string());
let notify = claim_download(&map, "k").await.unwrap();
let waiter = tokio::spawn({
let map = map.clone();
let entry_dir = entry_dir.clone();
async move {
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
join_inflight_download(&map, "k", &entry_dir, notify, deadline).await
}
});
tokio::time::sleep(Duration::from_millis(50)).await; drop(leader_guard);
assert_eq!(
tokio::time::timeout(Duration::from_secs(2), waiter)
.await
.expect("failed leader must wake the waiter promptly")
.unwrap(),
JoinOutcome::Reclaimed
);
assert!(
map.read().await.contains_key("k"),
"Reclaimed means the waiter now holds the claim"
);
map.write().await.clear();
assert!(claim_download(&map, "k").await.is_none());
let leader_guard = DownloadingGuard::new(map.clone(), "k".to_string());
let notify = claim_download(&map, "k").await.unwrap();
let waiter = tokio::spawn({
let map = map.clone();
let entry_dir = entry_dir.clone();
async move {
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
join_inflight_download(&map, "k", &entry_dir, notify, deadline).await
}
});
tokio::time::sleep(Duration::from_millis(50)).await;
std::fs::write(entry_dir.join("meta.json"), "{}").unwrap();
drop(leader_guard);
assert_eq!(
tokio::time::timeout(Duration::from_secs(2), waiter)
.await
.expect("successful leader must wake the waiter promptly")
.unwrap(),
JoinOutcome::Found
);
assert!(map.read().await.is_empty(), "claim fully released");
}
#[tokio::test]
async fn waiter_sees_meta_json_at_wake_on_leader_success() {
let dir = tempfile::tempdir().unwrap();
let entry_dir = dir.path().join("entry");
std::fs::create_dir_all(&entry_dir).unwrap();
let meta = entry_dir.join("meta.json");
let map: Arc<RwLock<HashMap<String, Arc<Notify>>>> = Arc::new(RwLock::new(HashMap::new()));
assert!(claim_download(&map, "k").await.is_none());
let leader_guard = DownloadingGuard::new(map.clone(), "k".to_string());
let notify = claim_download(&map, "k").await.unwrap();
let waiter = tokio::spawn({
let map = map.clone();
let meta = meta.clone();
async move {
park_on_claim(&map, ¬ify, "k").await;
meta.exists()
}
});
tokio::time::sleep(Duration::from_millis(50)).await; std::fs::write(&meta, "{}").unwrap(); drop(leader_guard); let found = tokio::time::timeout(Duration::from_secs(5), waiter)
.await
.expect("waiter should wake when the leader's guard drops")
.unwrap();
assert!(found, "waiter must observe meta.json at wake");
assert!(map.read().await.is_empty(), "claim fully released");
}
#[tokio::test]
async fn read_bounded_line_strips_and_handles_eof() {
let data = b"hello\nwith-cr\r\n\nlast"; let mut reader = BufReader::new(&data[..]);
let mut buf = Vec::new();
let r = |res: std::io::Result<Option<String>>| res.unwrap();
assert_eq!(
r(read_bounded_line(&mut reader, &mut buf).await).as_deref(),
Some("hello")
);
assert_eq!(
r(read_bounded_line(&mut reader, &mut buf).await).as_deref(),
Some("with-cr")
);
assert_eq!(
r(read_bounded_line(&mut reader, &mut buf).await).as_deref(),
Some("")
);
assert_eq!(
r(read_bounded_line(&mut reader, &mut buf).await).as_deref(),
Some("last")
);
assert_eq!(r(read_bounded_line(&mut reader, &mut buf).await), None);
}
#[tokio::test]
async fn read_bounded_line_rejects_oversized_frame() {
let big = vec![b'x'; MAX_REQUEST_FRAME_BYTES + 4096];
let mut reader = BufReader::new(&big[..]);
let mut buf = Vec::new();
let err = read_bounded_line(&mut reader, &mut buf).await.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
}