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