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 let mut conn = zccache_ipc::connect(endpoint).await?;
337 conn.set_recv_timeout(zccache_ipc::DEFAULT_CLIENT_RECV_TIMEOUT);
338 Ok(conn)
339}
340
341#[cfg(windows)]
342async fn connect_client(
343 endpoint: &str,
344) -> Result<zccache_ipc::IpcClientConnection, zccache_ipc::IpcError> {
345 let mut conn = zccache_ipc::connect(endpoint).await?;
346 conn.set_recv_timeout(zccache_ipc::DEFAULT_CLIENT_RECV_TIMEOUT);
347 Ok(conn)
348}
349
350async fn check_daemon_version(endpoint: &str) -> VersionCheck {
351 let mut conn = match connect_client(endpoint).await {
352 Ok(c) => c,
353 Err(_) => return VersionCheck::Unreachable,
354 };
355 if conn.send(&zccache_protocol::Request::Status).await.is_err() {
356 return VersionCheck::CommError;
357 }
358 match conn.recv::<zccache_protocol::Response>().await {
359 Ok(Some(zccache_protocol::Response::Status(s))) => {
360 if s.version == zccache_core::VERSION {
361 return VersionCheck::Ok;
362 }
363 let client_ver = zccache_core::version::current();
364 match zccache_core::version::Version::parse(&s.version) {
365 Some(daemon_ver) => match daemon_ver.cmp(&client_ver) {
366 std::cmp::Ordering::Equal => VersionCheck::Ok,
367 std::cmp::Ordering::Greater => VersionCheck::DaemonNewer,
368 std::cmp::Ordering::Less => VersionCheck::DaemonOlder {
369 daemon_ver: s.version,
370 },
371 },
372 None => VersionCheck::DaemonOlder {
373 daemon_ver: s.version,
374 },
375 }
376 }
377 _ => VersionCheck::CommError,
378 }
379}
380
381async fn spawn_and_wait(endpoint: &str) -> Result<(), String> {
382 let daemon_bin = find_daemon_binary().ok_or("cannot find zccache-daemon binary")?;
383 spawn_daemon(&daemon_bin, endpoint)?;
384
385 for _ in 0..100 {
386 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
387 if connect_client(endpoint).await.is_ok() {
388 return Ok(());
389 }
390 }
391 Err("daemon started but not accepting connections after 10s".to_string())
392}
393
394async fn stop_stale_daemon(endpoint: &str) {
396 if let Ok(mut conn) = connect_client(endpoint).await {
397 let _ = conn.send(&zccache_protocol::Request::Shutdown).await;
398 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
399 }
400
401 if let Some(pid) = zccache_ipc::check_running_daemon() {
402 if zccache_ipc::force_kill_process(pid).is_ok() {
403 for _ in 0..50 {
404 if !zccache_ipc::is_process_alive(pid) {
405 break;
406 }
407 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
408 }
409 }
410 zccache_ipc::remove_lock_file();
411 }
412
413 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
414}
415
416async fn ensure_daemon(endpoint: &str) -> Result<(), String> {
417 match check_daemon_version(endpoint).await {
418 VersionCheck::Ok | VersionCheck::DaemonNewer => return Ok(()),
419 VersionCheck::DaemonOlder { daemon_ver } => {
420 tracing::info!(
421 daemon_ver,
422 client_ver = zccache_core::VERSION,
423 "daemon is older than client, auto-recovering"
424 );
425 stop_stale_daemon(endpoint).await;
426 return spawn_and_wait(endpoint).await;
427 }
428 VersionCheck::CommError => {
429 tracing::info!("cannot communicate with daemon, auto-recovering");
430 stop_stale_daemon(endpoint).await;
431 return spawn_and_wait(endpoint).await;
432 }
433 VersionCheck::Unreachable => {}
434 }
435
436 if let Some(pid) = zccache_ipc::check_running_daemon() {
437 let mut backoff = std::time::Duration::from_millis(100);
438 for _ in 0..20 {
439 tokio::time::sleep(backoff).await;
440 backoff = (backoff * 2).min(std::time::Duration::from_millis(500));
441 match check_daemon_version(endpoint).await {
442 VersionCheck::Ok | VersionCheck::DaemonNewer => return Ok(()),
443 VersionCheck::DaemonOlder { daemon_ver } => {
444 tracing::info!(
445 daemon_ver,
446 client_ver = zccache_core::VERSION,
447 "daemon is older than client during startup, auto-recovering"
448 );
449 stop_stale_daemon(endpoint).await;
450 return spawn_and_wait(endpoint).await;
451 }
452 VersionCheck::CommError => {
453 stop_stale_daemon(endpoint).await;
454 return spawn_and_wait(endpoint).await;
455 }
456 VersionCheck::Unreachable => continue,
457 }
458 }
459 return Err(format!(
460 "daemon process {pid} exists but not accepting connections after retrying"
461 ));
462 }
463
464 spawn_and_wait(endpoint).await
465}
466
467fn find_daemon_binary() -> Option<NormalizedPath> {
468 let name = if cfg!(windows) {
469 "zccache-daemon.exe"
470 } else {
471 "zccache-daemon"
472 };
473
474 if let Ok(exe) = std::env::current_exe() {
475 if let Some(dir) = exe.parent() {
476 let candidate = dir.join(name);
477 if candidate.exists() {
478 return Some(candidate.into());
479 }
480 }
481 }
482
483 which_on_path(name)
484}
485
486fn which_on_path(name: &str) -> Option<NormalizedPath> {
487 let path_var = std::env::var_os("PATH")?;
488 for dir in std::env::split_paths(&path_var) {
489 let candidate = dir.join(name);
490 if candidate.is_file() {
491 return Some(candidate.into());
492 }
493 #[cfg(windows)]
494 if Path::new(name).extension().is_none() {
495 let with_exe = dir.join(format!("{name}.exe"));
496 if with_exe.is_file() {
497 return Some(with_exe.into());
498 }
499 }
500 }
501 None
502}
503
504#[cfg(not(windows))]
513fn apply_cli_spawn_lineage(cmd: &mut std::process::Command) {
514 for (k, v) in cli_spawn_lineage_env() {
515 cmd.env(k, v);
516 }
517}
518
519fn cli_spawn_lineage_env() -> Vec<(String, String)> {
524 const ENV_ORIGINATOR: &str = "RUNNING_PROCESS_ORIGINATOR";
525 const ENV_LINEAGE: &str = "ZCCACHE_LINEAGE";
526 const ENV_PARENT_PID: &str = "ZCCACHE_PARENT_PID";
527 const ENV_CLIENT_PID: &str = "ZCCACHE_CLIENT_PID";
528
529 let cli_pid = std::process::id();
530 let mut out: Vec<(String, String)> = Vec::with_capacity(4);
531
532 if std::env::var(ENV_ORIGINATOR).is_err() {
535 out.push((ENV_ORIGINATOR.to_string(), format!("zccache-cli:{cli_pid}")));
536 }
537
538 let chain = match std::env::var(ENV_LINEAGE) {
540 Ok(existing)
541 if existing
542 .rsplit_once('>')
543 .map_or(existing.as_str(), |(_, last)| last)
544 != cli_pid.to_string() =>
545 {
546 format!("{existing}>{cli_pid}")
547 }
548 Ok(existing) => existing,
549 Err(_) => cli_pid.to_string(),
550 };
551 out.push((ENV_LINEAGE.to_string(), chain));
552 out.push((ENV_PARENT_PID.to_string(), cli_pid.to_string()));
553 out.push((ENV_CLIENT_PID.to_string(), cli_pid.to_string()));
554 out
555}
556
557const RUNTIME_BINARIES_SUBDIR: &str = "runtime-binaries";
563
564#[must_use]
566pub fn runtime_binaries_dir() -> NormalizedPath {
567 zccache_core::config::default_cache_dir().join(RUNTIME_BINARIES_SUBDIR)
568}
569
570pub fn prepare_daemon_exe(canonical: &Path) -> Result<std::path::PathBuf, std::io::Error> {
579 prepare_daemon_exe_in(canonical, runtime_binaries_dir().as_path())
580}
581
582pub fn prepare_daemon_exe_in(
585 canonical: &Path,
586 dir: &Path,
587) -> Result<std::path::PathBuf, std::io::Error> {
588 std::fs::create_dir_all(dir)?;
589
590 let rand_id: u32 = std::process::id()
594 ^ std::time::UNIX_EPOCH
595 .elapsed()
596 .unwrap_or_default()
597 .subsec_nanos();
598 let extension = canonical.extension().and_then(|s| s.to_str()).unwrap_or("");
599 let file_name = if extension.is_empty() {
600 format!("zccache-daemon.{rand_id}")
601 } else {
602 format!("zccache-daemon.{rand_id}.{extension}")
603 };
604 let dest = dir.join(&file_name);
605 std::fs::copy(canonical, &dest)?;
606 Ok(dest)
607}
608
609pub fn gc_runtime_binaries() {
614 gc_runtime_binaries_in(runtime_binaries_dir().as_path());
615}
616
617pub fn gc_runtime_binaries_in(dir: &Path) {
619 let entries = match std::fs::read_dir(dir) {
620 Ok(e) => e,
621 Err(_) => return,
622 };
623 for entry in entries.flatten() {
624 let _ = std::fs::remove_file(entry.path());
625 }
626}
627
628pub fn spawn_daemon(bin: &Path, endpoint: &str) -> Result<(), String> {
629 gc_runtime_binaries();
632
633 let bin_owned: std::path::PathBuf;
637 let spawn_bin: &Path = match prepare_daemon_exe(bin) {
638 Ok(p) => {
639 bin_owned = p;
640 &bin_owned
641 }
642 Err(_) => bin,
643 };
644
645 #[cfg(windows)]
654 {
655 spawn_daemon_windows::spawn_daemon_sanitized(
656 spawn_bin,
657 &["--foreground", "--endpoint", endpoint],
658 &cli_spawn_lineage_env(),
659 )
660 }
661
662 #[cfg(not(windows))]
663 {
664 let mut cmd = std::process::Command::new(spawn_bin);
665 cmd.args(["--foreground", "--endpoint", endpoint]);
666 cmd.stdin(std::process::Stdio::null());
667 cmd.stdout(std::process::Stdio::null());
668 cmd.stderr(std::process::Stdio::null());
669 apply_cli_spawn_lineage(&mut cmd);
670 cmd.spawn()
671 .map_err(|e| format!("failed to spawn daemon: {e}"))?;
672 Ok(())
673 }
674}
675
676#[derive(Debug, Clone)]
677pub struct SessionStartResponse {
678 pub session_id: String,
679 pub journal_path: Option<String>,
680}
681
682pub fn client_start(endpoint: Option<&str>) -> Result<(), String> {
683 let endpoint = resolve_endpoint(endpoint);
684 run_async(async move { ensure_daemon(&endpoint).await })
685}
686
687pub fn client_stop(endpoint: Option<&str>) -> Result<bool, String> {
688 let endpoint = resolve_endpoint(endpoint);
689 run_async(async move {
690 let mut conn = match connect_client(&endpoint).await {
691 Ok(c) => c,
692 Err(_) => return Ok(false),
693 };
694 conn.send(&zccache_protocol::Request::Shutdown)
695 .await
696 .map_err(|e| format!("failed to send to daemon: {e}"))?;
697 match conn.recv::<zccache_protocol::Response>().await {
698 Ok(Some(zccache_protocol::Response::ShuttingDown)) => Ok(true),
699 Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
700 Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
701 Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
702 Err(e) => Err(format!("broken connection to daemon: {e}")),
703 }
704 })
705}
706
707pub fn client_status(endpoint: Option<&str>) -> Result<zccache_protocol::DaemonStatus, String> {
708 let endpoint = resolve_endpoint(endpoint);
709 run_async(async move {
710 let mut conn = connect_client(&endpoint)
711 .await
712 .map_err(|e| format!("daemon not running at {endpoint}: {e}"))?;
713 conn.send(&zccache_protocol::Request::Status)
714 .await
715 .map_err(|e| format!("failed to send to daemon: {e}"))?;
716 match conn.recv::<zccache_protocol::Response>().await {
717 Ok(Some(zccache_protocol::Response::Status(status))) => Ok(status),
718 Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
719 Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
720 Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
721 Err(e) => Err(format!("broken connection to daemon: {e}")),
722 }
723 })
724}
725
726pub fn client_session_start(
727 endpoint: Option<&str>,
728 cwd: &Path,
729 log_file: Option<&Path>,
730 track_stats: bool,
731 journal_path: Option<&Path>,
732) -> Result<SessionStartResponse, String> {
733 let endpoint = resolve_endpoint(endpoint);
734 let cwd = cwd.to_path_buf();
735 let log_file = log_file.map(NormalizedPath::from);
736 let journal_path = journal_path.map(NormalizedPath::from);
737
738 run_async(async move {
739 ensure_daemon(&endpoint).await?;
740 let mut conn = connect_client(&endpoint)
741 .await
742 .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
743 conn.send(&zccache_protocol::Request::SessionStart {
744 client_pid: std::process::id(),
745 working_dir: cwd.into(),
746 log_file,
747 track_stats,
748 journal_path,
749 })
750 .await
751 .map_err(|e| format!("failed to send to daemon: {e}"))?;
752
753 match conn.recv::<zccache_protocol::Response>().await {
754 Ok(Some(zccache_protocol::Response::SessionStarted {
755 session_id,
756 journal_path,
757 })) => Ok(SessionStartResponse {
758 session_id,
759 journal_path: journal_path.map(|p| p.display().to_string()),
760 }),
761 Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
762 Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
763 Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
764 Err(e) => Err(format!("broken connection to daemon: {e}")),
765 }
766 })
767}
768
769pub fn client_session_end(
779 endpoint: Option<&str>,
780 session_id: &str,
781) -> Result<Option<zccache_protocol::SessionStats>, String> {
782 let endpoint = resolve_endpoint(endpoint);
783 session_end_idempotent(&endpoint, session_id).map_err(|e| e.to_string())
784}
785
786#[must_use]
806pub fn is_daemon_unreachable_err(err: &zccache_ipc::IpcError) -> bool {
807 use std::io::ErrorKind;
808 match err {
809 zccache_ipc::IpcError::Io(io) => matches!(
810 io.kind(),
811 ErrorKind::NotFound | ErrorKind::ConnectionRefused | ErrorKind::BrokenPipe
812 ),
813 _ => false,
814 }
815}
816
817pub fn session_end_idempotent(
849 endpoint: &str,
850 session_id: &str,
851) -> Result<Option<zccache_protocol::SessionStats>, zccache_ipc::IpcError> {
852 let endpoint = endpoint.to_string();
853 let session_id = session_id.to_string();
854
855 let runtime = tokio::runtime::Builder::new_current_thread()
859 .enable_all()
860 .build()
861 .map_err(|e| {
862 zccache_ipc::IpcError::Endpoint(format!("failed to create tokio runtime: {e}"))
863 })?;
864
865 runtime.block_on(async move {
866 let mut conn = match connect_client(&endpoint).await {
867 Ok(c) => c,
868 Err(e) => {
869 if is_daemon_unreachable_err(&e) {
870 eprintln!(
871 "session-end: daemon unreachable at {endpoint}, treating session {session_id} as ended"
872 );
873 return Ok(None);
874 }
875 return Err(e);
876 }
877 };
878
879 conn.send(&zccache_protocol::Request::SessionEnd {
880 session_id: session_id.clone(),
881 })
882 .await?;
883
884 match conn.recv::<zccache_protocol::Response>().await? {
885 Some(zccache_protocol::Response::SessionEnded { stats }) => Ok(stats),
886 Some(zccache_protocol::Response::Error { message }) => Err(
887 zccache_ipc::IpcError::Endpoint(format!("session-end failed: {message}")),
888 ),
889 None => Err(zccache_ipc::IpcError::ConnectionClosed),
890 Some(other) => Err(zccache_ipc::IpcError::Endpoint(format!(
891 "unexpected response from daemon: {other:?}"
892 ))),
893 }
894 })
895}
896
897pub fn client_session_stats(
898 endpoint: Option<&str>,
899 session_id: &str,
900) -> Result<Option<zccache_protocol::SessionStats>, String> {
901 let endpoint = resolve_endpoint(endpoint);
902 let session_id = session_id.to_string();
903 run_async(async move {
904 let mut conn = connect_client(&endpoint)
905 .await
906 .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
907 conn.send(&zccache_protocol::Request::SessionStats {
908 session_id: session_id.clone(),
909 })
910 .await
911 .map_err(|e| format!("failed to send to daemon: {e}"))?;
912
913 match conn.recv::<zccache_protocol::Response>().await {
914 Ok(Some(zccache_protocol::Response::SessionStatsResult { stats })) => Ok(stats),
915 Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
916 Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
917 Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
918 Err(e) => Err(format!("broken connection to daemon: {e}")),
919 }
920 })
921}
922
923#[derive(Debug, Clone)]
924pub struct FingerprintCheckResponse {
925 pub decision: String,
926 pub reason: Option<String>,
927 pub changed_files: Vec<String>,
928}
929
930pub fn fingerprint_check(
931 endpoint: Option<&str>,
932 cache_file: &Path,
933 cache_type: &str,
934 root: &Path,
935 extensions: &[String],
936 include_globs: &[String],
937 exclude: &[String],
938) -> Result<FingerprintCheckResponse, String> {
939 let endpoint = resolve_endpoint(endpoint);
940 let cache_file = cache_file.to_path_buf();
941 let cache_type = cache_type.to_string();
942 let root = root.to_path_buf();
943 let extensions = extensions.to_vec();
944 let include_globs = include_globs.to_vec();
945 let exclude = exclude.to_vec();
946
947 run_async(async move {
948 ensure_daemon(&endpoint).await?;
949 let mut conn = connect_client(&endpoint)
950 .await
951 .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
952
953 conn.send(&zccache_protocol::Request::FingerprintCheck {
954 cache_file: cache_file.into(),
955 cache_type,
956 root: root.into(),
957 extensions,
958 include_globs,
959 exclude,
960 })
961 .await
962 .map_err(|e| format!("failed to send to daemon: {e}"))?;
963
964 match conn.recv::<zccache_protocol::Response>().await {
965 Ok(Some(zccache_protocol::Response::FingerprintCheckResult {
966 decision,
967 reason,
968 changed_files,
969 })) => Ok(FingerprintCheckResponse {
970 decision,
971 reason,
972 changed_files,
973 }),
974 Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
975 Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
976 Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
977 Err(e) => Err(format!("broken connection to daemon: {e}")),
978 }
979 })
980}
981
982pub fn fingerprint_mark_success(endpoint: Option<&str>, cache_file: &Path) -> Result<(), String> {
983 fingerprint_mark(endpoint, cache_file, true)
984}
985
986pub fn fingerprint_mark_failure(endpoint: Option<&str>, cache_file: &Path) -> Result<(), String> {
987 fingerprint_mark(endpoint, cache_file, false)
988}
989
990fn fingerprint_mark(
991 endpoint: Option<&str>,
992 cache_file: &Path,
993 success: bool,
994) -> Result<(), String> {
995 let endpoint = resolve_endpoint(endpoint);
996 let cache_file = cache_file.to_path_buf();
997 run_async(async move {
998 ensure_daemon(&endpoint).await?;
999 let mut conn = connect_client(&endpoint)
1000 .await
1001 .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
1002 let request = if success {
1003 zccache_protocol::Request::FingerprintMarkSuccess {
1004 cache_file: cache_file.into(),
1005 }
1006 } else {
1007 zccache_protocol::Request::FingerprintMarkFailure {
1008 cache_file: cache_file.into(),
1009 }
1010 };
1011 conn.send(&request)
1012 .await
1013 .map_err(|e| format!("failed to send to daemon: {e}"))?;
1014 match conn.recv::<zccache_protocol::Response>().await {
1015 Ok(Some(zccache_protocol::Response::FingerprintAck)) => Ok(()),
1016 Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
1017 Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
1018 Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
1019 Err(e) => Err(format!("broken connection to daemon: {e}")),
1020 }
1021 })
1022}
1023
1024pub fn fingerprint_invalidate(endpoint: Option<&str>, cache_file: &Path) -> Result<(), String> {
1025 let endpoint = resolve_endpoint(endpoint);
1026 let cache_file = cache_file.to_path_buf();
1027 run_async(async move {
1028 ensure_daemon(&endpoint).await?;
1029 let mut conn = connect_client(&endpoint)
1030 .await
1031 .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
1032 conn.send(&zccache_protocol::Request::FingerprintInvalidate {
1033 cache_file: cache_file.into(),
1034 })
1035 .await
1036 .map_err(|e| format!("failed to send to daemon: {e}"))?;
1037 match conn.recv::<zccache_protocol::Response>().await {
1038 Ok(Some(zccache_protocol::Response::FingerprintAck)) => Ok(()),
1039 Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
1040 Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
1041 Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
1042 Err(e) => Err(format!("broken connection to daemon: {e}")),
1043 }
1044 })
1045}
1046
1047#[cfg(test)]
1048mod tests {
1049 use super::*;
1050
1051 #[test]
1052 fn infer_download_path_keeps_url_filename() {
1053 let path = infer_download_archive_path(
1054 &DownloadSource::Url("https://example.com/releases/toolchain.tar.gz?download=1".into()),
1055 ArchiveFormat::Auto,
1056 );
1057 let file_name = path.file_name().unwrap().to_string_lossy();
1058 assert!(file_name.ends_with("-toolchain.tar.gz"));
1059 }
1060
1061 #[test]
1062 fn infer_download_path_uses_archive_format_suffix_when_needed() {
1063 let path = infer_download_archive_path(
1064 &DownloadSource::Url("https://example.com/download".into()),
1065 ArchiveFormat::Zip,
1066 );
1067 let file_name = path.file_name().unwrap().to_string_lossy();
1068 assert!(file_name.ends_with(".zip"));
1069 }
1070
1071 #[test]
1072 fn build_download_request_derives_archive_path_when_missing() {
1073 let request = build_download_request(DownloadParams::new("https://example.com/file.zip"));
1074 let file_name = request
1075 .destination_path
1076 .file_name()
1077 .unwrap()
1078 .to_string_lossy();
1079 assert!(file_name.ends_with("-file.zip"));
1080 }
1081
1082 #[test]
1083 fn infer_download_path_strips_multipart_suffix_from_first_part() {
1084 let path = infer_download_archive_path(
1085 &DownloadSource::MultipartUrls(vec![
1086 "https://example.com/toolchain.tar.zst.part-aa".into(),
1087 "https://example.com/toolchain.tar.zst.part-ab".into(),
1088 ]),
1089 ArchiveFormat::Auto,
1090 );
1091 let file_name = path.file_name().unwrap().to_string_lossy();
1092 assert!(file_name.ends_with("-toolchain.tar.zst"));
1093 }
1094
1095 #[test]
1096 fn prepare_daemon_exe_in_copies_to_target_dir() {
1097 let tmp = tempfile::tempdir().expect("create tempdir");
1098 let src = tmp.path().join("zccache-daemon.exe");
1099 std::fs::write(&src, b"fake-daemon-bytes").expect("write source");
1100
1101 let dest_dir = tmp.path().join("runtime-binaries");
1102 let copied =
1103 prepare_daemon_exe_in(&src, &dest_dir).expect("prepare_daemon_exe_in succeeds");
1104
1105 assert!(
1106 copied.is_file(),
1107 "copy at {} should exist",
1108 copied.display()
1109 );
1110 assert_eq!(
1111 copied.parent().unwrap(),
1112 dest_dir,
1113 "copy should land inside dest_dir"
1114 );
1115 assert!(
1116 copied
1117 .file_name()
1118 .unwrap()
1119 .to_string_lossy()
1120 .starts_with("zccache-daemon."),
1121 "filename should start with zccache-daemon., got {}",
1122 copied.display()
1123 );
1124 assert!(
1125 copied.extension().and_then(|s| s.to_str()) == Some("exe"),
1126 "extension should be preserved"
1127 );
1128 assert_eq!(
1129 std::fs::read(&copied).unwrap(),
1130 b"fake-daemon-bytes",
1131 "copy contents should match source"
1132 );
1133 }
1134
1135 #[test]
1136 fn prepare_daemon_exe_in_creates_missing_dest_dir() {
1137 let tmp = tempfile::tempdir().expect("create tempdir");
1138 let src = tmp.path().join("zccache-daemon");
1139 std::fs::write(&src, b"x").expect("write source");
1140
1141 let dest_dir = tmp.path().join("nested").join("runtime-binaries");
1142 assert!(!dest_dir.exists(), "precondition: dest_dir does not exist");
1143
1144 let copied = prepare_daemon_exe_in(&src, &dest_dir).expect("create + copy");
1145 assert!(dest_dir.is_dir(), "dest_dir should now exist");
1146 assert!(copied.is_file());
1147 }
1148
1149 #[test]
1150 fn gc_runtime_binaries_in_removes_unlocked_entries() {
1151 let tmp = tempfile::tempdir().expect("create tempdir");
1152 let dir = tmp.path().join("runtime-binaries");
1153 std::fs::create_dir_all(&dir).expect("create dir");
1154
1155 let a = dir.join("zccache-daemon.111.exe");
1156 let b = dir.join("zccache-daemon.222.exe");
1157 std::fs::write(&a, b"a").unwrap();
1158 std::fs::write(&b, b"b").unwrap();
1159
1160 gc_runtime_binaries_in(&dir);
1161
1162 assert!(!a.exists(), "{} should be GC'd", a.display());
1163 assert!(!b.exists(), "{} should be GC'd", b.display());
1164 assert!(dir.is_dir(), "directory itself remains");
1165 }
1166
1167 #[test]
1168 fn gc_runtime_binaries_in_is_noop_for_missing_dir() {
1169 let tmp = tempfile::tempdir().expect("create tempdir");
1170 let dir = tmp.path().join("does-not-exist");
1171 gc_runtime_binaries_in(&dir);
1172 }
1173
1174 #[test]
1184 fn session_end_idempotent_swallows_vanished_daemon() {
1185 let endpoint = zccache_ipc::unique_test_endpoint();
1188 let session_id = "00000000-0000-0000-0000-000000000000";
1189
1190 let result = session_end_idempotent(&endpoint, session_id);
1191
1192 assert!(
1193 matches!(result, Ok(None)),
1194 "vanished daemon must produce Ok(None) (success no-op), got {result:?}"
1195 );
1196 }
1197
1198 #[test]
1207 fn session_end_idempotent_treats_timeout_as_real_error() {
1208 let err = zccache_ipc::IpcError::Io(std::io::Error::from(std::io::ErrorKind::TimedOut));
1209 assert!(
1210 !is_daemon_unreachable_err(&err),
1211 "TimedOut must NOT be classified as daemon-unreachable; session_end_idempotent \
1212 would otherwise silently swallow real timeouts"
1213 );
1214 }
1215
1216 #[test]
1219 fn session_end_idempotent_treats_protocol_errors_as_real() {
1220 let err = zccache_ipc::IpcError::ConnectionClosed;
1221 assert!(!is_daemon_unreachable_err(&err));
1222 let err = zccache_ipc::IpcError::Endpoint("bogus".into());
1223 assert!(!is_daemon_unreachable_err(&err));
1224 }
1225
1226 #[test]
1233 fn is_daemon_unreachable_recognizes_not_found() {
1234 let err = zccache_ipc::IpcError::Io(std::io::Error::from(std::io::ErrorKind::NotFound));
1235 assert!(is_daemon_unreachable_err(&err));
1236 }
1237
1238 #[test]
1239 fn is_daemon_unreachable_recognizes_connection_refused() {
1240 let err =
1241 zccache_ipc::IpcError::Io(std::io::Error::from(std::io::ErrorKind::ConnectionRefused));
1242 assert!(is_daemon_unreachable_err(&err));
1243 }
1244
1245 #[test]
1246 fn is_daemon_unreachable_recognizes_broken_pipe() {
1247 let err = zccache_ipc::IpcError::Io(std::io::Error::from(std::io::ErrorKind::BrokenPipe));
1248 assert!(is_daemon_unreachable_err(&err));
1249 }
1250
1251 #[test]
1258 fn is_daemon_unreachable_timeout_is_not_unreachable() {
1259 let err = zccache_ipc::IpcError::Timeout(std::time::Duration::from_secs(5));
1260 assert!(
1261 !is_daemon_unreachable_err(&err),
1262 "Timeout must propagate as a real fault, not be swallowed as daemon-unreachable"
1263 );
1264 }
1265
1266 #[test]
1271 fn is_daemon_unreachable_recognizes_raw_enoent() {
1272 let err = zccache_ipc::IpcError::Io(std::io::Error::from_raw_os_error(2));
1274 assert!(
1275 is_daemon_unreachable_err(&err),
1276 "errno 2 must map to a kind in the unreachable set; got kind={:?}",
1277 match &err {
1278 zccache_ipc::IpcError::Io(io) => io.kind(),
1279 _ => unreachable!(),
1280 }
1281 );
1282 }
1283
1284 #[test]
1292 fn client_session_end_swallows_vanished_daemon() {
1293 let endpoint = zccache_ipc::unique_test_endpoint();
1294 let session_id = "00000000-0000-0000-0000-000000000000";
1295
1296 let result = client_session_end(Some(&endpoint), session_id);
1297
1298 assert!(
1299 matches!(result, Ok(None)),
1300 "vanished daemon must produce Ok(None) (success no-op), got {result:?}"
1301 );
1302 }
1303}