1#![allow(clippy::missing_errors_doc)]
2
3use std::path::Path;
4use zccache_core::NormalizedPath;
5
6#[cfg(feature = "python")]
7mod python;
8
9pub mod symbols;
10
11pub use zccache_download_client::{
12 ArchiveFormat, DownloadSource, FetchRequest, FetchResult, FetchState, FetchStateKind,
13 FetchStatus, WaitMode,
14};
15
16#[derive(Debug, Clone)]
17pub struct InoConvertOptions {
18 pub clang_args: Vec<String>,
19 pub inject_arduino_include: bool,
20}
21
22impl Default for InoConvertOptions {
23 fn default() -> Self {
24 Self {
25 clang_args: Vec::new(),
26 inject_arduino_include: true,
27 }
28 }
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct InoConvertResult {
33 pub cache_hit: bool,
34 pub skipped_write: bool,
35}
36
37#[derive(Debug, Clone)]
38pub struct DownloadParams {
39 pub source: DownloadSource,
40 pub archive_path: Option<std::path::PathBuf>,
41 pub unarchive_path: Option<std::path::PathBuf>,
42 pub expected_sha256: Option<String>,
43 pub archive_format: ArchiveFormat,
44 pub max_connections: Option<usize>,
45 pub min_segment_size: Option<u64>,
46 pub wait_mode: WaitMode,
47 pub dry_run: bool,
48 pub force: bool,
49}
50
51impl DownloadParams {
52 #[must_use]
53 pub fn new(source: impl Into<DownloadSource>) -> Self {
54 Self {
55 source: source.into(),
56 archive_path: None,
57 unarchive_path: None,
58 expected_sha256: None,
59 archive_format: ArchiveFormat::Auto,
60 max_connections: None,
61 min_segment_size: None,
62 wait_mode: WaitMode::Block,
63 dry_run: false,
64 force: false,
65 }
66 }
67}
68
69pub fn run_ino_convert_cached(
70 input: &Path,
71 output: &Path,
72 options: &InoConvertOptions,
73) -> Result<InoConvertResult, Box<dyn std::error::Error>> {
74 let input_hash = zccache_hash::hash_file(input)?;
75 let mut hasher = zccache_hash::StreamHasher::new();
76 hasher.update(b"zccache-ino-convert-v1");
77 hasher.update(input_hash.as_bytes());
78 hasher.update(input.as_os_str().to_string_lossy().as_bytes());
79 hasher.update(if options.inject_arduino_include {
80 b"include-arduino-h"
81 } else {
82 b"no-arduino-h"
83 });
84 if let Some(libclang_hash) = zccache_compiler::arduino::libclang_hash() {
85 hasher.update(libclang_hash.as_bytes());
86 }
87 for arg in &options.clang_args {
88 hasher.update(arg.as_bytes());
89 hasher.update(b"\0");
90 }
91 let cache_key = hasher.finalize().to_hex();
92
93 let cache_dir = zccache_core::config::default_cache_dir().join("ino");
94 std::fs::create_dir_all(&cache_dir)?;
95 let cached_cpp = cache_dir.join(format!("{cache_key}.ino.cpp"));
96
97 if cached_cpp.exists() {
98 return restore_cached_ino_output(&cached_cpp, output);
99 }
100
101 let generated = zccache_compiler::arduino::generate_ino_cpp(
102 input,
103 &zccache_compiler::arduino::ArduinoConversionOptions {
104 clang_args: options.clang_args.clone(),
105 inject_arduino_include: options.inject_arduino_include,
106 },
107 )?;
108
109 write_file_atomically(&cached_cpp, generated.cpp.as_bytes())?;
110 restore_cached_ino_output(&cached_cpp, output).map(|_| InoConvertResult {
111 cache_hit: false,
112 skipped_write: false,
113 })
114}
115
116fn restore_cached_ino_output(
117 cached_cpp: &Path,
118 output: &Path,
119) -> Result<InoConvertResult, Box<dyn std::error::Error>> {
120 if output.exists() {
121 let output_hash = zccache_hash::hash_file(output)?;
122 let cached_hash = zccache_hash::hash_file(cached_cpp)?;
123 if output_hash == cached_hash {
124 return Ok(InoConvertResult {
125 cache_hit: true,
126 skipped_write: true,
127 });
128 }
129 }
130
131 if let Some(parent) = output.parent() {
132 std::fs::create_dir_all(parent)?;
133 }
134 std::fs::copy(cached_cpp, output)?;
135 Ok(InoConvertResult {
136 cache_hit: true,
137 skipped_write: false,
138 })
139}
140
141fn write_file_atomically(path: &Path, data: &[u8]) -> Result<(), std::io::Error> {
142 let parent = path.parent().unwrap_or_else(|| Path::new("."));
143 std::fs::create_dir_all(parent)?;
144
145 let tmp = tempfile::NamedTempFile::new_in(parent)?;
146 std::fs::write(tmp.path(), data)?;
147 match tmp.persist(path) {
148 Ok(_) => Ok(()),
149 Err(err) => Err(err.error),
150 }
151}
152
153fn resolve_endpoint(explicit: Option<&str>) -> String {
154 if let Some(ep) = explicit {
155 return ep.to_string();
156 }
157 if let Ok(ep) = std::env::var("ZCCACHE_ENDPOINT") {
158 return ep;
159 }
160 zccache_ipc::default_endpoint()
161}
162
163pub fn infer_download_archive_path(
164 source: &DownloadSource,
165 archive_format: ArchiveFormat,
166) -> std::path::PathBuf {
167 let file_name = infer_download_file_name(source, archive_format);
168 zccache_core::config::default_cache_dir()
169 .join("downloads")
170 .join("artifacts")
171 .join(file_name)
172 .into_path_buf()
173}
174
175#[must_use]
176pub fn build_download_request(params: DownloadParams) -> FetchRequest {
177 let archive_path = params
178 .archive_path
179 .unwrap_or_else(|| infer_download_archive_path(¶ms.source, params.archive_format));
180 let mut request = FetchRequest::new(params.source, archive_path);
181 request.destination_path_expanded = params.unarchive_path;
182 request.expected_sha256 = params.expected_sha256;
183 request.archive_format = params.archive_format;
184 request.wait_mode = params.wait_mode;
185 request.dry_run = params.dry_run;
186 request.force = params.force;
187 request.download_options.force = params.force;
188 request.download_options.max_connections = params.max_connections;
189 request.download_options.min_segment_size = params.min_segment_size;
190 request
191}
192
193pub fn client_download(
194 endpoint: Option<&str>,
195 params: DownloadParams,
196) -> Result<FetchResult, String> {
197 let request = build_download_request(params);
198 let client = zccache_download_client::DownloadClient::new(endpoint.map(ToOwned::to_owned));
199 client.fetch(request)
200}
201
202pub fn client_download_exists(
203 endpoint: Option<&str>,
204 params: DownloadParams,
205) -> Result<FetchState, String> {
206 let request = build_download_request(params);
207 let client = zccache_download_client::DownloadClient::new(endpoint.map(ToOwned::to_owned));
208 client.exists(&request)
209}
210
211fn infer_download_file_name(source: &DownloadSource, archive_format: ArchiveFormat) -> String {
212 let base = infer_source_file_name(source);
213 let hash = blake3::hash(download_source_key(source).as_bytes())
214 .to_hex()
215 .to_string();
216 let suffix = archive_suffix(archive_format);
217
218 if base.contains('.') || suffix.is_empty() {
219 format!("{hash}-{base}")
220 } else {
221 format!("{hash}-{base}{suffix}")
222 }
223}
224
225fn infer_source_file_name(source: &DownloadSource) -> String {
226 match source {
227 DownloadSource::Url(url) => {
228 infer_url_file_name(url).unwrap_or_else(|| "download".to_string())
229 }
230 DownloadSource::MultipartUrls(urls) => infer_multipart_file_name(urls),
231 }
232}
233
234fn infer_url_file_name(url: &str) -> Option<String> {
235 url.split(['?', '#'])
236 .next()
237 .and_then(|value| value.rsplit('/').next())
238 .filter(|value| !value.is_empty())
239 .map(sanitize_download_file_name)
240 .filter(|value| !value.is_empty())
241}
242
243fn infer_multipart_file_name(urls: &[String]) -> String {
244 let base = urls
245 .first()
246 .and_then(|url| infer_url_file_name(url))
247 .map(|name| strip_part_suffix(&name).to_string())
248 .filter(|name| !name.is_empty())
249 .unwrap_or_else(|| "multipart-download".to_string());
250 if base.contains('.') {
251 base
252 } else {
253 "multipart-download".to_string()
254 }
255}
256
257fn strip_part_suffix(value: &str) -> &str {
258 if let Some((base, suffix)) = value.rsplit_once(".part-") {
259 if !base.is_empty() && !suffix.is_empty() {
260 return base;
261 }
262 }
263 if let Some((base, suffix)) = value.rsplit_once(".part_") {
264 if !base.is_empty() && !suffix.is_empty() {
265 return base;
266 }
267 }
268 if let Some(index) = value.rfind(".part") {
269 let suffix = &value[index + ".part".len()..];
270 if !suffix.is_empty()
271 && suffix
272 .chars()
273 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
274 {
275 return &value[..index];
276 }
277 }
278 value
279}
280
281fn download_source_key(source: &DownloadSource) -> String {
282 match source {
283 DownloadSource::Url(url) => url.clone(),
284 DownloadSource::MultipartUrls(urls) => urls.join("\n"),
285 }
286}
287
288fn sanitize_download_file_name(value: &str) -> String {
289 value
290 .chars()
291 .map(|ch| match ch {
292 '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
293 c if c.is_control() => '_',
294 c => c,
295 })
296 .collect()
297}
298
299fn archive_suffix(format: ArchiveFormat) -> &'static str {
300 match format {
301 ArchiveFormat::Auto | ArchiveFormat::None => "",
302 ArchiveFormat::Zst => ".zst",
303 ArchiveFormat::Zip => ".zip",
304 ArchiveFormat::Xz => ".xz",
305 ArchiveFormat::TarGz => ".tar.gz",
306 ArchiveFormat::TarXz => ".tar.xz",
307 ArchiveFormat::TarZst => ".tar.zst",
308 ArchiveFormat::SevenZip => ".7z",
309 }
310}
311
312fn run_async<T>(future: impl std::future::Future<Output = Result<T, String>>) -> Result<T, String> {
313 tokio::runtime::Builder::new_current_thread()
314 .enable_all()
315 .build()
316 .map_err(|e| format!("failed to create tokio runtime: {e}"))?
317 .block_on(future)
318}
319
320#[derive(Debug)]
321enum VersionCheck {
322 Ok,
323 Unreachable,
324 DaemonOlder { daemon_ver: String },
325 DaemonNewer,
326 CommError,
327}
328
329#[cfg(unix)]
330async fn connect_client(
331 endpoint: &str,
332) -> Result<zccache_ipc::IpcConnection, zccache_ipc::IpcError> {
333 let mut conn = zccache_ipc::connect(endpoint).await?;
334 conn.set_recv_timeout(zccache_ipc::DEFAULT_CLIENT_RECV_TIMEOUT);
335 Ok(conn)
336}
337
338#[cfg(windows)]
339async fn connect_client(
340 endpoint: &str,
341) -> Result<zccache_ipc::IpcClientConnection, zccache_ipc::IpcError> {
342 let mut conn = zccache_ipc::connect(endpoint).await?;
343 conn.set_recv_timeout(zccache_ipc::DEFAULT_CLIENT_RECV_TIMEOUT);
344 Ok(conn)
345}
346
347async fn check_daemon_version(endpoint: &str) -> VersionCheck {
348 let mut conn = match connect_client(endpoint).await {
349 Ok(c) => c,
350 Err(_) => return VersionCheck::Unreachable,
351 };
352 if conn.send(&zccache_protocol::Request::Status).await.is_err() {
353 return VersionCheck::CommError;
354 }
355 match conn.recv::<zccache_protocol::Response>().await {
356 Ok(Some(zccache_protocol::Response::Status(s))) => {
357 if s.version == zccache_core::VERSION {
358 return VersionCheck::Ok;
359 }
360 let client_ver = zccache_core::version::current();
361 match zccache_core::version::Version::parse(&s.version) {
362 Some(daemon_ver) => match daemon_ver.cmp(&client_ver) {
363 std::cmp::Ordering::Equal => VersionCheck::Ok,
364 std::cmp::Ordering::Greater => VersionCheck::DaemonNewer,
365 std::cmp::Ordering::Less => VersionCheck::DaemonOlder {
366 daemon_ver: s.version,
367 },
368 },
369 None => VersionCheck::DaemonOlder {
370 daemon_ver: s.version,
371 },
372 }
373 }
374 _ => VersionCheck::CommError,
375 }
376}
377
378async fn spawn_and_wait(endpoint: &str) -> Result<(), String> {
379 let daemon_bin = find_daemon_binary().ok_or("cannot find zccache-daemon binary")?;
380 spawn_daemon(&daemon_bin, endpoint)?;
381
382 for _ in 0..100 {
383 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
384 if connect_client(endpoint).await.is_ok() {
385 return Ok(());
386 }
387 }
388 Err("daemon started but not accepting connections after 10s".to_string())
389}
390
391async fn stop_stale_daemon(endpoint: &str) {
393 if let Ok(mut conn) = connect_client(endpoint).await {
394 let _ = conn.send(&zccache_protocol::Request::Shutdown).await;
395 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
396 }
397
398 if let Some(pid) = zccache_ipc::check_running_daemon() {
399 if zccache_ipc::force_kill_process(pid).is_ok() {
400 for _ in 0..50 {
401 if !zccache_ipc::is_process_alive(pid) {
402 break;
403 }
404 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
405 }
406 }
407 zccache_ipc::remove_lock_file();
408 }
409
410 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
411}
412
413async fn ensure_daemon(endpoint: &str) -> Result<(), String> {
414 match check_daemon_version(endpoint).await {
415 VersionCheck::Ok | VersionCheck::DaemonNewer => return Ok(()),
416 VersionCheck::DaemonOlder { daemon_ver } => {
417 tracing::info!(
418 daemon_ver,
419 client_ver = zccache_core::VERSION,
420 "daemon is older than client, auto-recovering"
421 );
422 stop_stale_daemon(endpoint).await;
423 return spawn_and_wait(endpoint).await;
424 }
425 VersionCheck::CommError => {
426 tracing::info!("cannot communicate with daemon, auto-recovering");
427 stop_stale_daemon(endpoint).await;
428 return spawn_and_wait(endpoint).await;
429 }
430 VersionCheck::Unreachable => {}
431 }
432
433 if let Some(pid) = zccache_ipc::check_running_daemon() {
434 let mut backoff = std::time::Duration::from_millis(100);
435 for _ in 0..20 {
436 tokio::time::sleep(backoff).await;
437 backoff = (backoff * 2).min(std::time::Duration::from_millis(500));
438 match check_daemon_version(endpoint).await {
439 VersionCheck::Ok | VersionCheck::DaemonNewer => return Ok(()),
440 VersionCheck::DaemonOlder { daemon_ver } => {
441 tracing::info!(
442 daemon_ver,
443 client_ver = zccache_core::VERSION,
444 "daemon is older than client during startup, auto-recovering"
445 );
446 stop_stale_daemon(endpoint).await;
447 return spawn_and_wait(endpoint).await;
448 }
449 VersionCheck::CommError => {
450 stop_stale_daemon(endpoint).await;
451 return spawn_and_wait(endpoint).await;
452 }
453 VersionCheck::Unreachable => continue,
454 }
455 }
456 return Err(format!(
457 "daemon process {pid} exists but not accepting connections after retrying"
458 ));
459 }
460
461 spawn_and_wait(endpoint).await
462}
463
464fn find_daemon_binary() -> Option<NormalizedPath> {
465 let name = if cfg!(windows) {
466 "zccache-daemon.exe"
467 } else {
468 "zccache-daemon"
469 };
470
471 if let Ok(exe) = std::env::current_exe() {
472 if let Some(dir) = exe.parent() {
473 let candidate = dir.join(name);
474 if candidate.exists() {
475 return Some(candidate.into());
476 }
477 }
478 }
479
480 which_on_path(name)
481}
482
483fn which_on_path(name: &str) -> Option<NormalizedPath> {
484 let path_var = std::env::var_os("PATH")?;
485 for dir in std::env::split_paths(&path_var) {
486 let candidate = dir.join(name);
487 if candidate.is_file() {
488 return Some(candidate.into());
489 }
490 #[cfg(windows)]
491 if Path::new(name).extension().is_none() {
492 let with_exe = dir.join(format!("{name}.exe"));
493 if with_exe.is_file() {
494 return Some(with_exe.into());
495 }
496 }
497 }
498 None
499}
500
501#[cfg(not(windows))]
510fn apply_cli_spawn_lineage(cmd: &mut std::process::Command) {
511 for (k, v) in cli_spawn_lineage_env() {
512 cmd.env(k, v);
513 }
514}
515
516fn cli_spawn_lineage_env() -> Vec<(String, String)> {
521 const ENV_ORIGINATOR: &str = "RUNNING_PROCESS_ORIGINATOR";
522 const ENV_LINEAGE: &str = "ZCCACHE_LINEAGE";
523 const ENV_PARENT_PID: &str = "ZCCACHE_PARENT_PID";
524 const ENV_CLIENT_PID: &str = "ZCCACHE_CLIENT_PID";
525
526 let cli_pid = std::process::id();
527 let mut out: Vec<(String, String)> = Vec::with_capacity(4);
528
529 if std::env::var(ENV_ORIGINATOR).is_err() {
532 out.push((ENV_ORIGINATOR.to_string(), format!("zccache-cli:{cli_pid}")));
533 }
534
535 let chain = match std::env::var(ENV_LINEAGE) {
537 Ok(existing)
538 if existing
539 .rsplit_once('>')
540 .map_or(existing.as_str(), |(_, last)| last)
541 != cli_pid.to_string() =>
542 {
543 format!("{existing}>{cli_pid}")
544 }
545 Ok(existing) => existing,
546 Err(_) => cli_pid.to_string(),
547 };
548 out.push((ENV_LINEAGE.to_string(), chain));
549 out.push((ENV_PARENT_PID.to_string(), cli_pid.to_string()));
550 out.push((ENV_CLIENT_PID.to_string(), cli_pid.to_string()));
551 out
552}
553
554const RUNTIME_BINARIES_SUBDIR: &str = "runtime-binaries";
560
561#[must_use]
563pub fn runtime_binaries_dir() -> NormalizedPath {
564 zccache_core::config::default_cache_dir().join(RUNTIME_BINARIES_SUBDIR)
565}
566
567pub fn prepare_daemon_exe(canonical: &Path) -> Result<std::path::PathBuf, std::io::Error> {
576 prepare_daemon_exe_in(canonical, runtime_binaries_dir().as_path())
577}
578
579pub fn prepare_daemon_exe_in(
582 canonical: &Path,
583 dir: &Path,
584) -> Result<std::path::PathBuf, std::io::Error> {
585 std::fs::create_dir_all(dir)?;
586
587 let rand_id: u32 = std::process::id()
591 ^ std::time::UNIX_EPOCH
592 .elapsed()
593 .unwrap_or_default()
594 .subsec_nanos();
595 let extension = canonical.extension().and_then(|s| s.to_str()).unwrap_or("");
596 let file_name = if extension.is_empty() {
597 format!("zccache-daemon.{rand_id}")
598 } else {
599 format!("zccache-daemon.{rand_id}.{extension}")
600 };
601 let dest = dir.join(&file_name);
602 std::fs::copy(canonical, &dest)?;
603 Ok(dest)
604}
605
606pub fn gc_runtime_binaries() {
611 gc_runtime_binaries_in(runtime_binaries_dir().as_path());
612}
613
614pub fn gc_runtime_binaries_in(dir: &Path) {
616 let entries = match std::fs::read_dir(dir) {
617 Ok(e) => e,
618 Err(_) => return,
619 };
620 for entry in entries.flatten() {
621 let _ = std::fs::remove_file(entry.path());
622 }
623}
624
625const DAEMON_SPAWN_LOGS_SUBDIR: &str = "logs";
633
634fn allocate_daemon_spawn_log_path() -> std::path::PathBuf {
639 let dir = zccache_core::config::default_cache_dir().join(DAEMON_SPAWN_LOGS_SUBDIR);
640 let _ = std::fs::create_dir_all(dir.as_path());
641 let nanos = std::time::SystemTime::now()
642 .duration_since(std::time::UNIX_EPOCH)
643 .map(|d| d.as_nanos() as u64)
644 .unwrap_or(0);
645 dir.as_path()
646 .join(format!("daemon-spawn-{}-{nanos}.log", std::process::id()))
647}
648
649pub fn gc_daemon_spawn_logs() {
653 let dir = zccache_core::config::default_cache_dir().join(DAEMON_SPAWN_LOGS_SUBDIR);
654 let entries = match std::fs::read_dir(dir.as_path()) {
655 Ok(e) => e,
656 Err(_) => return,
657 };
658 let now = std::time::SystemTime::now();
659 let cutoff = std::time::Duration::from_secs(60 * 60 * 24);
660 for entry in entries.flatten() {
661 let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
662 continue;
663 };
664 if !name.starts_with("daemon-spawn-") {
665 continue;
666 }
667 let modified = entry
668 .metadata()
669 .and_then(|m| m.modified())
670 .ok()
671 .and_then(|t| now.duration_since(t).ok());
672 if let Some(age) = modified {
673 if age > cutoff {
674 let _ = std::fs::remove_file(entry.path());
675 }
676 }
677 }
678}
679
680pub fn spawn_daemon(bin: &Path, endpoint: &str) -> Result<(), String> {
681 gc_runtime_binaries();
685 gc_daemon_spawn_logs();
686
687 let bin_owned: std::path::PathBuf;
691 let spawn_bin: &Path = match prepare_daemon_exe(bin) {
692 Ok(p) => {
693 bin_owned = p;
694 &bin_owned
695 }
696 Err(_) => bin,
697 };
698
699 let log_path = allocate_daemon_spawn_log_path();
705 let log_arg = log_path.to_string_lossy().into_owned();
706
707 let mut cmd = std::process::Command::new(spawn_bin);
724 cmd.args([
725 "--foreground",
726 "--endpoint",
727 endpoint,
728 "--log-file",
729 &log_arg,
730 ]);
731 #[cfg(not(windows))]
732 apply_cli_spawn_lineage(&mut cmd);
733 #[cfg(windows)]
734 {
735 for (k, v) in cli_spawn_lineage_env() {
739 cmd.env(k, v);
740 }
741 }
742 running_process_core::spawn_daemon(&mut cmd)
743 .map(|_child| ())
744 .map_err(|e| format!("failed to spawn daemon (sanitized): {e}"))
745}
746
747#[derive(Debug, Clone)]
748pub struct SessionStartResponse {
749 pub session_id: String,
750 pub journal_path: Option<String>,
751}
752
753pub fn client_start(endpoint: Option<&str>) -> Result<(), String> {
754 let endpoint = resolve_endpoint(endpoint);
755 run_async(async move { ensure_daemon(&endpoint).await })
756}
757
758pub fn client_stop(endpoint: Option<&str>) -> Result<bool, String> {
759 let endpoint = resolve_endpoint(endpoint);
760 run_async(async move {
761 let mut conn = match connect_client(&endpoint).await {
762 Ok(c) => c,
763 Err(_) => return Ok(false),
764 };
765 conn.send(&zccache_protocol::Request::Shutdown)
766 .await
767 .map_err(|e| format!("failed to send to daemon: {e}"))?;
768 match conn.recv::<zccache_protocol::Response>().await {
769 Ok(Some(zccache_protocol::Response::ShuttingDown)) => Ok(true),
770 Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
771 Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
772 Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
773 Err(e) => Err(format!("broken connection to daemon: {e}")),
774 }
775 })
776}
777
778pub fn client_status(endpoint: Option<&str>) -> Result<zccache_protocol::DaemonStatus, String> {
779 let endpoint = resolve_endpoint(endpoint);
780 run_async(async move {
781 let mut conn = connect_client(&endpoint)
782 .await
783 .map_err(|e| format!("daemon not running at {endpoint}: {e}"))?;
784 conn.send(&zccache_protocol::Request::Status)
785 .await
786 .map_err(|e| format!("failed to send to daemon: {e}"))?;
787 match conn.recv::<zccache_protocol::Response>().await {
788 Ok(Some(zccache_protocol::Response::Status(status))) => Ok(status),
789 Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
790 Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
791 Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
792 Err(e) => Err(format!("broken connection to daemon: {e}")),
793 }
794 })
795}
796
797pub fn client_session_start(
798 endpoint: Option<&str>,
799 cwd: &Path,
800 log_file: Option<&Path>,
801 track_stats: bool,
802 journal_path: Option<&Path>,
803) -> Result<SessionStartResponse, String> {
804 let endpoint = resolve_endpoint(endpoint);
805 let cwd = cwd.to_path_buf();
806 let log_file = log_file.map(NormalizedPath::from);
807 let journal_path = journal_path.map(NormalizedPath::from);
808
809 run_async(async move {
810 ensure_daemon(&endpoint).await?;
811 let mut conn = connect_client(&endpoint)
812 .await
813 .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
814 conn.send(&zccache_protocol::Request::SessionStart {
815 client_pid: std::process::id(),
816 working_dir: cwd.into(),
817 log_file,
818 track_stats,
819 journal_path,
820 })
821 .await
822 .map_err(|e| format!("failed to send to daemon: {e}"))?;
823
824 match conn.recv::<zccache_protocol::Response>().await {
825 Ok(Some(zccache_protocol::Response::SessionStarted {
826 session_id,
827 journal_path,
828 })) => Ok(SessionStartResponse {
829 session_id,
830 journal_path: journal_path.map(|p| p.display().to_string()),
831 }),
832 Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
833 Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
834 Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
835 Err(e) => Err(format!("broken connection to daemon: {e}")),
836 }
837 })
838}
839
840pub fn client_session_end(
850 endpoint: Option<&str>,
851 session_id: &str,
852) -> Result<Option<zccache_protocol::SessionStats>, String> {
853 let endpoint = resolve_endpoint(endpoint);
854 session_end_idempotent(&endpoint, session_id).map_err(|e| e.to_string())
855}
856
857#[must_use]
877pub fn is_daemon_unreachable_err(err: &zccache_ipc::IpcError) -> bool {
878 use std::io::ErrorKind;
879 match err {
880 zccache_ipc::IpcError::Io(io) => matches!(
881 io.kind(),
882 ErrorKind::NotFound | ErrorKind::ConnectionRefused | ErrorKind::BrokenPipe
883 ),
884 _ => false,
885 }
886}
887
888pub fn session_end_idempotent(
920 endpoint: &str,
921 session_id: &str,
922) -> Result<Option<zccache_protocol::SessionStats>, zccache_ipc::IpcError> {
923 let endpoint = endpoint.to_string();
924 let session_id = session_id.to_string();
925
926 let runtime = tokio::runtime::Builder::new_current_thread()
930 .enable_all()
931 .build()
932 .map_err(|e| {
933 zccache_ipc::IpcError::Endpoint(format!("failed to create tokio runtime: {e}"))
934 })?;
935
936 runtime.block_on(async move {
937 let mut conn = match connect_client(&endpoint).await {
938 Ok(c) => c,
939 Err(e) => {
940 if is_daemon_unreachable_err(&e) {
941 eprintln!(
942 "session-end: daemon unreachable at {endpoint}, treating session {session_id} as ended"
943 );
944 return Ok(None);
945 }
946 return Err(e);
947 }
948 };
949
950 conn.send(&zccache_protocol::Request::SessionEnd {
951 session_id: session_id.clone(),
952 })
953 .await?;
954
955 match conn.recv::<zccache_protocol::Response>().await? {
956 Some(zccache_protocol::Response::SessionEnded { stats }) => Ok(stats),
957 Some(zccache_protocol::Response::Error { message }) => Err(
958 zccache_ipc::IpcError::Endpoint(format!("session-end failed: {message}")),
959 ),
960 None => Err(zccache_ipc::IpcError::ConnectionClosed),
961 Some(other) => Err(zccache_ipc::IpcError::Endpoint(format!(
962 "unexpected response from daemon: {other:?}"
963 ))),
964 }
965 })
966}
967
968pub fn client_session_stats(
969 endpoint: Option<&str>,
970 session_id: &str,
971) -> Result<Option<zccache_protocol::SessionStats>, String> {
972 let endpoint = resolve_endpoint(endpoint);
973 let session_id = session_id.to_string();
974 run_async(async move {
975 let mut conn = connect_client(&endpoint)
976 .await
977 .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
978 conn.send(&zccache_protocol::Request::SessionStats {
979 session_id: session_id.clone(),
980 })
981 .await
982 .map_err(|e| format!("failed to send to daemon: {e}"))?;
983
984 match conn.recv::<zccache_protocol::Response>().await {
985 Ok(Some(zccache_protocol::Response::SessionStatsResult { stats })) => Ok(stats),
986 Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
987 Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
988 Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
989 Err(e) => Err(format!("broken connection to daemon: {e}")),
990 }
991 })
992}
993
994#[derive(Debug, Clone)]
995pub struct FingerprintCheckResponse {
996 pub decision: String,
997 pub reason: Option<String>,
998 pub changed_files: Vec<String>,
999}
1000
1001pub fn fingerprint_check(
1002 endpoint: Option<&str>,
1003 cache_file: &Path,
1004 cache_type: &str,
1005 root: &Path,
1006 extensions: &[String],
1007 include_globs: &[String],
1008 exclude: &[String],
1009) -> Result<FingerprintCheckResponse, String> {
1010 let endpoint = resolve_endpoint(endpoint);
1011 let cache_file = cache_file.to_path_buf();
1012 let cache_type = cache_type.to_string();
1013 let root = root.to_path_buf();
1014 let extensions = extensions.to_vec();
1015 let include_globs = include_globs.to_vec();
1016 let exclude = exclude.to_vec();
1017
1018 run_async(async move {
1019 ensure_daemon(&endpoint).await?;
1020 let mut conn = connect_client(&endpoint)
1021 .await
1022 .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
1023
1024 conn.send(&zccache_protocol::Request::FingerprintCheck {
1025 cache_file: cache_file.into(),
1026 cache_type,
1027 root: root.into(),
1028 extensions,
1029 include_globs,
1030 exclude,
1031 })
1032 .await
1033 .map_err(|e| format!("failed to send to daemon: {e}"))?;
1034
1035 match conn.recv::<zccache_protocol::Response>().await {
1036 Ok(Some(zccache_protocol::Response::FingerprintCheckResult {
1037 decision,
1038 reason,
1039 changed_files,
1040 })) => Ok(FingerprintCheckResponse {
1041 decision,
1042 reason,
1043 changed_files,
1044 }),
1045 Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
1046 Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
1047 Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
1048 Err(e) => Err(format!("broken connection to daemon: {e}")),
1049 }
1050 })
1051}
1052
1053pub fn fingerprint_mark_success(endpoint: Option<&str>, cache_file: &Path) -> Result<(), String> {
1054 fingerprint_mark(endpoint, cache_file, true)
1055}
1056
1057pub fn fingerprint_mark_failure(endpoint: Option<&str>, cache_file: &Path) -> Result<(), String> {
1058 fingerprint_mark(endpoint, cache_file, false)
1059}
1060
1061fn fingerprint_mark(
1062 endpoint: Option<&str>,
1063 cache_file: &Path,
1064 success: bool,
1065) -> Result<(), String> {
1066 let endpoint = resolve_endpoint(endpoint);
1067 let cache_file = cache_file.to_path_buf();
1068 run_async(async move {
1069 ensure_daemon(&endpoint).await?;
1070 let mut conn = connect_client(&endpoint)
1071 .await
1072 .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
1073 let request = if success {
1074 zccache_protocol::Request::FingerprintMarkSuccess {
1075 cache_file: cache_file.into(),
1076 }
1077 } else {
1078 zccache_protocol::Request::FingerprintMarkFailure {
1079 cache_file: cache_file.into(),
1080 }
1081 };
1082 conn.send(&request)
1083 .await
1084 .map_err(|e| format!("failed to send to daemon: {e}"))?;
1085 match conn.recv::<zccache_protocol::Response>().await {
1086 Ok(Some(zccache_protocol::Response::FingerprintAck)) => Ok(()),
1087 Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
1088 Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
1089 Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
1090 Err(e) => Err(format!("broken connection to daemon: {e}")),
1091 }
1092 })
1093}
1094
1095pub fn fingerprint_invalidate(endpoint: Option<&str>, cache_file: &Path) -> Result<(), String> {
1096 let endpoint = resolve_endpoint(endpoint);
1097 let cache_file = cache_file.to_path_buf();
1098 run_async(async move {
1099 ensure_daemon(&endpoint).await?;
1100 let mut conn = connect_client(&endpoint)
1101 .await
1102 .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
1103 conn.send(&zccache_protocol::Request::FingerprintInvalidate {
1104 cache_file: cache_file.into(),
1105 })
1106 .await
1107 .map_err(|e| format!("failed to send to daemon: {e}"))?;
1108 match conn.recv::<zccache_protocol::Response>().await {
1109 Ok(Some(zccache_protocol::Response::FingerprintAck)) => Ok(()),
1110 Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
1111 Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
1112 Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
1113 Err(e) => Err(format!("broken connection to daemon: {e}")),
1114 }
1115 })
1116}
1117
1118#[cfg(test)]
1119mod tests {
1120 use super::*;
1121
1122 #[test]
1123 fn infer_download_path_keeps_url_filename() {
1124 let path = infer_download_archive_path(
1125 &DownloadSource::Url("https://example.com/releases/toolchain.tar.gz?download=1".into()),
1126 ArchiveFormat::Auto,
1127 );
1128 let file_name = path.file_name().unwrap().to_string_lossy();
1129 assert!(file_name.ends_with("-toolchain.tar.gz"));
1130 }
1131
1132 #[test]
1133 fn infer_download_path_uses_archive_format_suffix_when_needed() {
1134 let path = infer_download_archive_path(
1135 &DownloadSource::Url("https://example.com/download".into()),
1136 ArchiveFormat::Zip,
1137 );
1138 let file_name = path.file_name().unwrap().to_string_lossy();
1139 assert!(file_name.ends_with(".zip"));
1140 }
1141
1142 #[test]
1143 fn build_download_request_derives_archive_path_when_missing() {
1144 let request = build_download_request(DownloadParams::new("https://example.com/file.zip"));
1145 let file_name = request
1146 .destination_path
1147 .file_name()
1148 .unwrap()
1149 .to_string_lossy();
1150 assert!(file_name.ends_with("-file.zip"));
1151 }
1152
1153 #[test]
1154 fn infer_download_path_strips_multipart_suffix_from_first_part() {
1155 let path = infer_download_archive_path(
1156 &DownloadSource::MultipartUrls(vec![
1157 "https://example.com/toolchain.tar.zst.part-aa".into(),
1158 "https://example.com/toolchain.tar.zst.part-ab".into(),
1159 ]),
1160 ArchiveFormat::Auto,
1161 );
1162 let file_name = path.file_name().unwrap().to_string_lossy();
1163 assert!(file_name.ends_with("-toolchain.tar.zst"));
1164 }
1165
1166 #[test]
1167 fn prepare_daemon_exe_in_copies_to_target_dir() {
1168 let tmp = tempfile::tempdir().expect("create tempdir");
1169 let src = tmp.path().join("zccache-daemon.exe");
1170 std::fs::write(&src, b"fake-daemon-bytes").expect("write source");
1171
1172 let dest_dir = tmp.path().join("runtime-binaries");
1173 let copied =
1174 prepare_daemon_exe_in(&src, &dest_dir).expect("prepare_daemon_exe_in succeeds");
1175
1176 assert!(
1177 copied.is_file(),
1178 "copy at {} should exist",
1179 copied.display()
1180 );
1181 assert_eq!(
1182 copied.parent().unwrap(),
1183 dest_dir,
1184 "copy should land inside dest_dir"
1185 );
1186 assert!(
1187 copied
1188 .file_name()
1189 .unwrap()
1190 .to_string_lossy()
1191 .starts_with("zccache-daemon."),
1192 "filename should start with zccache-daemon., got {}",
1193 copied.display()
1194 );
1195 assert!(
1196 copied.extension().and_then(|s| s.to_str()) == Some("exe"),
1197 "extension should be preserved"
1198 );
1199 assert_eq!(
1200 std::fs::read(&copied).unwrap(),
1201 b"fake-daemon-bytes",
1202 "copy contents should match source"
1203 );
1204 }
1205
1206 #[test]
1207 fn prepare_daemon_exe_in_creates_missing_dest_dir() {
1208 let tmp = tempfile::tempdir().expect("create tempdir");
1209 let src = tmp.path().join("zccache-daemon");
1210 std::fs::write(&src, b"x").expect("write source");
1211
1212 let dest_dir = tmp.path().join("nested").join("runtime-binaries");
1213 assert!(!dest_dir.exists(), "precondition: dest_dir does not exist");
1214
1215 let copied = prepare_daemon_exe_in(&src, &dest_dir).expect("create + copy");
1216 assert!(dest_dir.is_dir(), "dest_dir should now exist");
1217 assert!(copied.is_file());
1218 }
1219
1220 #[test]
1221 fn gc_runtime_binaries_in_removes_unlocked_entries() {
1222 let tmp = tempfile::tempdir().expect("create tempdir");
1223 let dir = tmp.path().join("runtime-binaries");
1224 std::fs::create_dir_all(&dir).expect("create dir");
1225
1226 let a = dir.join("zccache-daemon.111.exe");
1227 let b = dir.join("zccache-daemon.222.exe");
1228 std::fs::write(&a, b"a").unwrap();
1229 std::fs::write(&b, b"b").unwrap();
1230
1231 gc_runtime_binaries_in(&dir);
1232
1233 assert!(!a.exists(), "{} should be GC'd", a.display());
1234 assert!(!b.exists(), "{} should be GC'd", b.display());
1235 assert!(dir.is_dir(), "directory itself remains");
1236 }
1237
1238 #[test]
1239 fn gc_runtime_binaries_in_is_noop_for_missing_dir() {
1240 let tmp = tempfile::tempdir().expect("create tempdir");
1241 let dir = tmp.path().join("does-not-exist");
1242 gc_runtime_binaries_in(&dir);
1243 }
1244
1245 #[test]
1255 fn session_end_idempotent_swallows_vanished_daemon() {
1256 let endpoint = zccache_ipc::unique_test_endpoint();
1259 let session_id = "00000000-0000-0000-0000-000000000000";
1260
1261 let result = session_end_idempotent(&endpoint, session_id);
1262
1263 assert!(
1264 matches!(result, Ok(None)),
1265 "vanished daemon must produce Ok(None) (success no-op), got {result:?}"
1266 );
1267 }
1268
1269 #[test]
1278 fn session_end_idempotent_treats_timeout_as_real_error() {
1279 let err = zccache_ipc::IpcError::Io(std::io::Error::from(std::io::ErrorKind::TimedOut));
1280 assert!(
1281 !is_daemon_unreachable_err(&err),
1282 "TimedOut must NOT be classified as daemon-unreachable; session_end_idempotent \
1283 would otherwise silently swallow real timeouts"
1284 );
1285 }
1286
1287 #[test]
1290 fn session_end_idempotent_treats_protocol_errors_as_real() {
1291 let err = zccache_ipc::IpcError::ConnectionClosed;
1292 assert!(!is_daemon_unreachable_err(&err));
1293 let err = zccache_ipc::IpcError::Endpoint("bogus".into());
1294 assert!(!is_daemon_unreachable_err(&err));
1295 }
1296
1297 #[test]
1304 fn is_daemon_unreachable_recognizes_not_found() {
1305 let err = zccache_ipc::IpcError::Io(std::io::Error::from(std::io::ErrorKind::NotFound));
1306 assert!(is_daemon_unreachable_err(&err));
1307 }
1308
1309 #[test]
1310 fn is_daemon_unreachable_recognizes_connection_refused() {
1311 let err =
1312 zccache_ipc::IpcError::Io(std::io::Error::from(std::io::ErrorKind::ConnectionRefused));
1313 assert!(is_daemon_unreachable_err(&err));
1314 }
1315
1316 #[test]
1317 fn is_daemon_unreachable_recognizes_broken_pipe() {
1318 let err = zccache_ipc::IpcError::Io(std::io::Error::from(std::io::ErrorKind::BrokenPipe));
1319 assert!(is_daemon_unreachable_err(&err));
1320 }
1321
1322 #[test]
1329 fn is_daemon_unreachable_timeout_is_not_unreachable() {
1330 let err = zccache_ipc::IpcError::Timeout(std::time::Duration::from_secs(5));
1331 assert!(
1332 !is_daemon_unreachable_err(&err),
1333 "Timeout must propagate as a real fault, not be swallowed as daemon-unreachable"
1334 );
1335 }
1336
1337 #[test]
1342 fn is_daemon_unreachable_recognizes_raw_enoent() {
1343 let err = zccache_ipc::IpcError::Io(std::io::Error::from_raw_os_error(2));
1345 assert!(
1346 is_daemon_unreachable_err(&err),
1347 "errno 2 must map to a kind in the unreachable set; got kind={:?}",
1348 match &err {
1349 zccache_ipc::IpcError::Io(io) => io.kind(),
1350 _ => unreachable!(),
1351 }
1352 );
1353 }
1354
1355 #[test]
1363 fn client_session_end_swallows_vanished_daemon() {
1364 let endpoint = zccache_ipc::unique_test_endpoint();
1365 let session_id = "00000000-0000-0000-0000-000000000000";
1366
1367 let result = client_session_end(Some(&endpoint), session_id);
1368
1369 assert!(
1370 matches!(result, Ok(None)),
1371 "vanished daemon must produce Ok(None) (success no-op), got {result:?}"
1372 );
1373 }
1374}