use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use crate::core::NormalizedPath;
use crate::daemon::server::{
EmbeddedCompileRequest, EmbeddedDaemon, EmbeddedFlushReport, EmbeddedStatsSnapshot,
};
pub use crate::audit::{AuditConfig, AuditContext};
pub type Result<T> = std::result::Result<T, EmbeddedError>;
#[derive(Debug, thiserror::Error)]
pub enum EmbeddedError {
#[error("failed to start embedded zccache service: {0}")]
Start(String),
#[error("embedded zccache compile failed: {0}")]
Compile(String),
#[error("embedded zccache service is already shut down")]
ShutDown,
#[error("embedded zccache operation cancelled by host token")]
Cancelled,
}
#[derive(Clone)]
pub struct ZccacheService {
daemon: Arc<EmbeddedDaemon>,
shutdown: Arc<AtomicBool>,
cancellation: Option<CancellationToken>,
_host_inflight_guard: Option<Arc<crate::daemon::process::HostInFlightGuard>>,
audit_sink: Option<Arc<crate::audit_writer::AuditSink>>,
}
#[derive(Debug, Clone)]
pub struct ZccacheConfig {
pub host: HostIdentity,
pub cache_root: NormalizedPath,
pub audit: AuditConfig,
pub limits: ServiceLimits,
pub runtime: RuntimeHooks,
pub cancellation: Option<CancellationToken>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostIdentity {
pub product: String,
pub instance_id: String,
pub workspace_id: String,
}
impl HostIdentity {
pub fn default_for_product(product: impl Into<String>) -> Self {
use blake3::Hasher;
let product = product.into();
let mut hasher = Hasher::new();
hasher.update(product.as_bytes());
hasher.update(b"\0zccache-host-identity-v1\0");
if let Ok(exe) = std::env::current_exe() {
hasher.update(exe.as_os_str().to_string_lossy().as_bytes());
}
let bytes = hasher.finalize();
let mut hex = String::with_capacity(32);
for byte in &bytes.as_bytes()[..16] {
use std::fmt::Write;
let _ = write!(hex, "{byte:02x}");
}
Self {
product,
instance_id: hex.clone(),
workspace_id: hex,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct RuntimeHooks {
pub service_name: Option<String>,
pub handle: Option<tokio::runtime::Handle>,
}
#[derive(Debug, Clone, Default)]
pub struct ServiceLimits {
pub max_parallel_compiles: Option<usize>,
pub host_in_flight: Option<Arc<std::sync::atomic::AtomicUsize>>,
}
#[derive(Debug, Clone)]
pub struct CompileRequest {
pub audit: AuditContext,
pub compiler: NormalizedPath,
pub args: Vec<String>,
pub cwd: NormalizedPath,
pub env: Vec<(String, String)>,
pub stdin: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct CompileResponse {
pub exit_code: i32,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
pub cached: bool,
pub cache_outcome: CacheOutcome,
pub compile_id: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CacheOutcome {
Hit,
Miss,
Error,
}
#[derive(Debug, Clone)]
pub enum CompileChunk {
Stdout(Vec<u8>),
Stderr(Vec<u8>),
Done {
exit_code: i32,
cached: bool,
cache_outcome: CacheOutcome,
compile_id: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShutdownMode {
Graceful,
Force,
}
#[derive(Debug, Clone)]
pub struct ShutdownReport {
pub mode: ShutdownMode,
pub flushed: FlushReport,
}
#[derive(Debug, Clone)]
pub struct FlushReport {
pub pending_writes_drained: bool,
pub artifact_entries: u64,
pub metadata_entries: u64,
}
#[derive(Debug, Clone)]
pub struct ServiceStats {
pub cache_root: NormalizedPath,
pub uptime_secs: u64,
pub total_compilations: u64,
pub cache_hits: u64,
pub cache_misses: u64,
pub non_cacheable: u64,
pub compile_errors: u64,
pub compile_errors_cached: u64,
pub time_saved_ms: u64,
pub artifact_count: u64,
pub cache_size_bytes: u64,
pub metadata_entries: u64,
pub dep_graph_contexts: u64,
pub dep_graph_files: u64,
pub sessions_total: u64,
pub sessions_active: u64,
pub phase_profile: crate::protocol::PhaseProfileSummary,
}
impl ZccacheService {
pub async fn start(config: ZccacheConfig) -> Result<Self> {
let endpoint = embedded_endpoint(&config.host);
let cache_root =
crate::core::config::effective_cache_root_from_top_level(&config.cache_root);
let daemon = EmbeddedDaemon::start(endpoint, cache_root, config.runtime.handle.clone())
.await
.map_err(|err| EmbeddedError::Start(err.to_string()))?;
let host_inflight_guard = config
.limits
.host_in_flight
.map(crate::daemon::process::register_host_in_flight_counter)
.map(Arc::new);
let audit_sink =
crate::audit_writer::AuditSink::start(&config.audit, config.runtime.handle.clone())
.map_err(|err| EmbeddedError::Start(err.to_string()))?
.map(Arc::new);
Ok(Self {
daemon: Arc::new(daemon),
shutdown: Arc::new(AtomicBool::new(false)),
cancellation: config.cancellation,
_host_inflight_guard: host_inflight_guard,
audit_sink,
})
}
pub async fn compile(&self, request: CompileRequest) -> Result<CompileResponse> {
let compile_id = request
.audit
.compile_id
.clone()
.or_else(|| request.audit.command_id.clone())
.map(String::from)
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
if self.shutdown.load(Ordering::Acquire) {
return Err(EmbeddedError::ShutDown);
}
if let Some(token) = &self.cancellation {
if token.is_cancelled() {
return Err(EmbeddedError::Cancelled);
}
}
let compile_future = self.daemon.compile(EmbeddedCompileRequest {
compiler: request.compiler.into_path_buf(),
args: request.args,
cwd: request.cwd.into_path_buf(),
env: Some(request.env),
stdin: request.stdin,
});
let response = match &self.cancellation {
Some(token) => {
let cancelled = token.cancelled();
tokio::select! {
biased;
() = cancelled => return Err(EmbeddedError::Cancelled),
result = compile_future => result.map_err(EmbeddedError::Compile)?,
}
}
None => compile_future.await.map_err(EmbeddedError::Compile)?,
};
let cache_outcome = if response.exit_code != 0 {
CacheOutcome::Error
} else if response.cached {
CacheOutcome::Hit
} else {
CacheOutcome::Miss
};
Ok(CompileResponse {
exit_code: response.exit_code,
stdout: response.stdout.as_ref().clone(),
stderr: response.stderr.as_ref().clone(),
cached: response.cached,
cache_outcome,
compile_id,
})
}
pub async fn compile_streaming<F>(&self, request: CompileRequest, mut on_chunk: F) -> Result<()>
where
F: FnMut(CompileChunk),
{
let response = self.compile(request).await?;
if !response.stdout.is_empty() {
on_chunk(CompileChunk::Stdout(response.stdout));
}
if !response.stderr.is_empty() {
on_chunk(CompileChunk::Stderr(response.stderr));
}
on_chunk(CompileChunk::Done {
exit_code: response.exit_code,
cached: response.cached,
cache_outcome: response.cache_outcome,
compile_id: response.compile_id,
});
Ok(())
}
pub async fn stats(&self) -> Result<ServiceStats> {
if self.shutdown.load(Ordering::Acquire) {
return Err(EmbeddedError::ShutDown);
}
Ok(ServiceStats::from_snapshot(self.daemon.stats().await))
}
pub async fn flush(&self) -> Result<FlushReport> {
if self.shutdown.load(Ordering::Acquire) {
return Err(EmbeddedError::ShutDown);
}
if let Some(token) = &self.cancellation {
if token.is_cancelled() {
return Err(EmbeddedError::Cancelled);
}
}
let flush_future = self.daemon.flush();
let report = match &self.cancellation {
Some(token) => {
let cancelled = token.cancelled();
tokio::select! {
biased;
() = cancelled => return Err(EmbeddedError::Cancelled),
report = flush_future => report,
}
}
None => flush_future.await,
};
if let Some(sink) = &self.audit_sink {
let _ = sink.flush().await;
}
Ok(FlushReport::from_report(report))
}
pub async fn shutdown(self, mode: ShutdownMode) -> Result<ShutdownReport> {
if self.shutdown.swap(true, Ordering::AcqRel) {
return Err(EmbeddedError::ShutDown);
}
let report = self.daemon.shutdown().await;
if matches!(mode, ShutdownMode::Graceful) {
if let Some(sink) = &self.audit_sink {
let _ = sink.shutdown().await;
}
}
Ok(ShutdownReport {
mode,
flushed: FlushReport::from_report(report),
})
}
}
impl ServiceStats {
fn from_snapshot(snapshot: EmbeddedStatsSnapshot) -> Self {
let status = snapshot.status;
Self {
cache_root: status.cache_dir,
uptime_secs: status.uptime_secs,
total_compilations: status.total_compilations,
cache_hits: status.cache_hits,
cache_misses: status.cache_misses,
non_cacheable: status.non_cacheable,
compile_errors: status.compile_errors,
compile_errors_cached: status.compile_errors_cached,
time_saved_ms: status.time_saved_ms,
artifact_count: status.artifact_count,
cache_size_bytes: status.cache_size_bytes,
metadata_entries: status.metadata_entries,
dep_graph_contexts: status.dep_graph_contexts,
dep_graph_files: status.dep_graph_files,
sessions_total: status.sessions_total,
sessions_active: status.sessions_active,
phase_profile: snapshot.phase_profile,
}
}
}
impl FlushReport {
fn from_report(report: EmbeddedFlushReport) -> Self {
Self {
pending_writes_drained: report.pending_writes_drained,
artifact_entries: report.artifact_entries,
metadata_entries: report.metadata_entries,
}
}
}
fn embedded_endpoint(host: &HostIdentity) -> String {
format!(
"embedded:{}:{}:{}",
sanitize_identity(&host.product),
sanitize_identity(&host.instance_id),
sanitize_identity(&host.workspace_id)
)
}
fn sanitize_identity(value: &str) -> String {
value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
ch
} else {
'_'
}
})
.collect()
}
#[cfg(test)]
mod streaming_tests {
use super::*;
#[test]
fn compile_chunk_done_carries_outcome_fields() {
let done = CompileChunk::Done {
exit_code: 0,
cached: true,
cache_outcome: CacheOutcome::Hit,
compile_id: "test-id".to_string(),
};
let CompileChunk::Done {
exit_code,
cached,
cache_outcome,
compile_id,
} = done
else {
panic!("constructor must produce a Done variant");
};
assert_eq!(exit_code, 0);
assert!(cached);
assert_eq!(cache_outcome, CacheOutcome::Hit);
assert_eq!(compile_id, "test-id");
}
#[test]
fn compile_chunk_stdout_stderr_carry_bytes() {
match CompileChunk::Stdout(b"hello".to_vec()) {
CompileChunk::Stdout(bytes) => assert_eq!(bytes, b"hello"),
other => panic!("expected Stdout, got {other:?}"),
}
match CompileChunk::Stderr(b"warn".to_vec()) {
CompileChunk::Stderr(bytes) => assert_eq!(bytes, b"warn"),
other => panic!("expected Stderr, got {other:?}"),
}
}
}
#[cfg(test)]
mod cancellation_tests {
use super::*;
use std::path::PathBuf;
use tempfile::TempDir;
use tokio_util::sync::CancellationToken;
fn fake_compile_request() -> CompileRequest {
CompileRequest {
audit: AuditContext::new(
crate::audit::AuditId::new("test-run").expect("non-empty"),
crate::audit::AuditId::new("test-trace").expect("non-empty"),
),
compiler: PathBuf::from("/nonexistent/compiler-that-never-runs").into(),
args: vec!["--version".into()],
cwd: std::env::current_dir().expect("cwd").into(),
env: Vec::new(),
stdin: Vec::new(),
}
}
async fn start_service_with_token(
temp: &TempDir,
token: Option<CancellationToken>,
instance_id: &str,
) -> Result<ZccacheService> {
let mut audit = AuditConfig::default();
audit.mode = crate::audit::AuditMode::Off;
ZccacheService::start(ZccacheConfig {
host: HostIdentity {
product: "zccache-test".into(),
instance_id: instance_id.into(),
workspace_id: instance_id.into(),
},
cache_root: temp.path().join("zccache").into(),
audit,
limits: ServiceLimits::default(),
runtime: RuntimeHooks::default(),
cancellation: token,
})
.await
}
#[tokio::test]
async fn precancelled_token_returns_cancelled_immediately() {
let temp = TempDir::new().expect("temp cache root");
let token = CancellationToken::new();
token.cancel();
let service = start_service_with_token(&temp, Some(token), "precancel")
.await
.expect("service start");
let outcome = service.compile(fake_compile_request()).await;
assert!(
matches!(outcome, Err(EmbeddedError::Cancelled)),
"pre-cancelled token must short-circuit compile(), got {outcome:?}"
);
let report = service.shutdown(ShutdownMode::Graceful).await;
assert!(report.is_ok(), "shutdown after Cancelled must succeed");
}
#[tokio::test]
async fn token_fired_during_compile_returns_cancelled() {
let temp = TempDir::new().expect("temp cache root");
let token = CancellationToken::new();
let token_clone = token.clone();
let service = start_service_with_token(&temp, Some(token), "midflight")
.await
.expect("service start");
let canceller = tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
token_clone.cancel();
});
let outcome = service.compile(fake_compile_request()).await;
canceller.await.expect("canceller task joined");
match outcome {
Err(EmbeddedError::Cancelled) | Err(EmbeddedError::Compile(_)) => {}
other => panic!("mid-flight cancel must yield Cancelled or Compile, got {other:?}"),
}
let report = service.shutdown(ShutdownMode::Graceful).await;
assert!(
report.is_ok(),
"shutdown after mid-flight cancel must succeed"
);
}
#[tokio::test]
async fn no_token_preserves_pre_923_behavior() {
let temp = TempDir::new().expect("temp cache root");
let service = start_service_with_token(&temp, None, "no-token")
.await
.expect("service start");
let outcome = service.compile(fake_compile_request()).await;
if let Err(EmbeddedError::Cancelled) = outcome {
panic!("cancellation: None must never yield Cancelled");
}
let report = service.shutdown(ShutdownMode::Graceful).await;
assert!(report.is_ok());
}
#[tokio::test]
async fn precancelled_token_short_circuits_flush() {
let temp = TempDir::new().expect("temp cache root");
let token = CancellationToken::new();
token.cancel();
let service = start_service_with_token(&temp, Some(token), "flush-cancel")
.await
.expect("service start");
let outcome = service.flush().await;
assert!(
matches!(outcome, Err(EmbeddedError::Cancelled)),
"pre-cancelled token must short-circuit flush(), got {outcome:?}"
);
let _ = service.shutdown(ShutdownMode::Graceful).await;
}
}
#[cfg(test)]
mod host_identity_tests {
use super::*;
#[test]
fn default_for_product_is_stable_within_one_process() {
let a = HostIdentity::default_for_product("soldr");
let b = HostIdentity::default_for_product("soldr");
assert_eq!(a, b, "same product must yield same identity");
assert_eq!(a.product, "soldr");
assert_eq!(a.workspace_id, a.instance_id);
}
#[test]
fn default_for_product_differs_per_product() {
let soldr = HostIdentity::default_for_product("soldr");
let fbuild = HostIdentity::default_for_product("fbuild");
assert_ne!(soldr, fbuild);
assert_ne!(soldr.instance_id, fbuild.instance_id);
}
#[test]
fn default_for_product_instance_id_is_16_bytes_of_hex() {
let id = HostIdentity::default_for_product("zccache-test");
assert_eq!(id.instance_id.len(), 32);
assert!(id.instance_id.chars().all(|c| c.is_ascii_hexdigit()));
}
}
#[cfg(test)]
mod runtime_hooks_tests {
use super::*;
use std::sync::atomic::AtomicUsize;
use std::sync::Arc;
use tempfile::TempDir;
#[test]
fn runtime_hooks_default_is_none() {
let hooks = RuntimeHooks::default();
assert!(hooks.handle.is_none());
assert!(hooks.service_name.is_none());
}
#[test]
fn explicit_handle_owns_background_spawns() {
let host_rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.thread_name("host-runtime-worker")
.build()
.expect("failed to build host runtime");
let host_handle = host_rt.handle().clone();
let landed_on_host: Arc<AtomicUsize> = Arc::new(AtomicUsize::new(0));
let temp = TempDir::new().expect("temp cache root");
let cache_root: NormalizedPath = temp.path().join("zccache").into();
let landed_clone = Arc::clone(&landed_on_host);
let host_handle_clone = host_handle.clone();
let service = host_rt.block_on(async move {
let mut audit = AuditConfig::default();
audit.mode = crate::audit::AuditMode::Off;
ZccacheService::start(ZccacheConfig {
host: HostIdentity {
product: "zccache-test".into(),
instance_id: "runtime-hooks".into(),
workspace_id: "runtime-hooks".into(),
},
cache_root,
audit,
limits: ServiceLimits::default(),
runtime: RuntimeHooks {
service_name: Some("runtime-hooks-test".into()),
handle: Some(host_handle_clone),
},
cancellation: None,
})
.await
});
let service = service.expect("service start");
let landed_clone2 = Arc::clone(&landed_clone);
let probe = host_handle.spawn(async move {
if std::thread::current()
.name()
.map(|n| n.starts_with("host-runtime-worker"))
.unwrap_or(false)
{
landed_clone2.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
});
host_rt.block_on(probe).expect("probe ran on host runtime");
assert!(
landed_on_host.load(std::sync::atomic::Ordering::Relaxed) >= 1,
"task spawned via supplied handle must run on host runtime workers"
);
let _ = host_rt.block_on(service.shutdown(ShutdownMode::Graceful));
}
}
#[cfg(test)]
mod journal_tests {
use super::*;
use std::path::PathBuf;
use tempfile::TempDir;
fn unreachable_compile_request() -> CompileRequest {
CompileRequest {
audit: AuditContext::new(
crate::audit::AuditId::new("journal-run").expect("non-empty"),
crate::audit::AuditId::new("journal-trace").expect("non-empty"),
),
compiler: PathBuf::from("/nonexistent/compiler-that-never-runs").into(),
args: vec!["--version".into()],
cwd: std::env::current_dir().expect("cwd").into(),
env: Vec::new(),
stdin: Vec::new(),
}
}
#[tokio::test]
async fn embedded_compile_writes_compile_journal() {
let temp = TempDir::new().expect("temp cache root");
let mut audit = AuditConfig::default();
audit.mode = crate::audit::AuditMode::Off;
let service = ZccacheService::start(ZccacheConfig {
host: HostIdentity {
product: "zccache-test".into(),
instance_id: "embedded-journal".into(),
workspace_id: "embedded-journal".into(),
},
cache_root: temp.path().join("zccache").into(),
audit,
limits: ServiceLimits::default(),
runtime: RuntimeHooks::default(),
cancellation: None,
})
.await
.expect("service start");
let _ = service.compile(unreachable_compile_request()).await;
fn find_journal(dir: &std::path::Path) -> Option<std::path::PathBuf> {
let entries = std::fs::read_dir(dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Some(found) = find_journal(&path) {
return Some(found);
}
} else if path.file_name().and_then(|n| n.to_str()) == Some("compile_journal.jsonl")
{
return Some(path);
}
}
None
}
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let content = loop {
let content = find_journal(temp.path()).and_then(|p| std::fs::read_to_string(p).ok());
match content {
Some(c) if !c.trim().is_empty() => break c,
_ if std::time::Instant::now() > deadline => {
panic!("embedded compile produced no compile_journal.jsonl record")
}
_ => tokio::time::sleep(std::time::Duration::from_millis(25)).await,
}
};
let line = content.lines().next().expect("at least one journal line");
let v: serde_json::Value = serde_json::from_str(line).expect("valid JSON journal line");
assert_eq!(
v["outcome"], "error",
"unspawnable compiler must journal as error: {v}"
);
assert!(
v["compiler"]
.as_str()
.unwrap_or_default()
.contains("compiler-that-never-runs"),
"journal must record the embedded compiler path: {v}"
);
let report = service.shutdown(ShutdownMode::Graceful).await;
assert!(report.is_ok(), "shutdown after journaled compile succeeds");
}
}