1#![allow(clippy::missing_errors_doc)]
2
3use std::path::Path;
4use zccache_core::NormalizedPath;
5
6#[cfg(feature = "python")]
7mod python;
8
9pub use zccache_download_client::{
10 ArchiveFormat, DownloadSource, FetchRequest, FetchResult, FetchState, FetchStateKind,
11 FetchStatus, WaitMode,
12};
13
14#[derive(Debug, Clone)]
15pub struct InoConvertOptions {
16 pub clang_args: Vec<String>,
17 pub inject_arduino_include: bool,
18}
19
20impl Default for InoConvertOptions {
21 fn default() -> Self {
22 Self {
23 clang_args: Vec::new(),
24 inject_arduino_include: true,
25 }
26 }
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct InoConvertResult {
31 pub cache_hit: bool,
32 pub skipped_write: bool,
33}
34
35#[derive(Debug, Clone)]
36pub struct DownloadParams {
37 pub source: DownloadSource,
38 pub archive_path: Option<std::path::PathBuf>,
39 pub unarchive_path: Option<std::path::PathBuf>,
40 pub expected_sha256: Option<String>,
41 pub archive_format: ArchiveFormat,
42 pub max_connections: Option<usize>,
43 pub min_segment_size: Option<u64>,
44 pub wait_mode: WaitMode,
45 pub dry_run: bool,
46 pub force: bool,
47}
48
49impl DownloadParams {
50 #[must_use]
51 pub fn new(source: impl Into<DownloadSource>) -> Self {
52 Self {
53 source: source.into(),
54 archive_path: None,
55 unarchive_path: None,
56 expected_sha256: None,
57 archive_format: ArchiveFormat::Auto,
58 max_connections: None,
59 min_segment_size: None,
60 wait_mode: WaitMode::Block,
61 dry_run: false,
62 force: false,
63 }
64 }
65}
66
67pub fn run_ino_convert_cached(
68 input: &Path,
69 output: &Path,
70 options: &InoConvertOptions,
71) -> Result<InoConvertResult, Box<dyn std::error::Error>> {
72 let input_hash = zccache_hash::hash_file(input)?;
73 let mut hasher = zccache_hash::StreamHasher::new();
74 hasher.update(b"zccache-ino-convert-v1");
75 hasher.update(input_hash.as_bytes());
76 hasher.update(input.as_os_str().to_string_lossy().as_bytes());
77 hasher.update(if options.inject_arduino_include {
78 b"include-arduino-h"
79 } else {
80 b"no-arduino-h"
81 });
82 if let Some(libclang_hash) = zccache_compiler::arduino::libclang_hash() {
83 hasher.update(libclang_hash.as_bytes());
84 }
85 for arg in &options.clang_args {
86 hasher.update(arg.as_bytes());
87 hasher.update(b"\0");
88 }
89 let cache_key = hasher.finalize().to_hex();
90
91 let cache_dir = zccache_core::config::default_cache_dir().join("ino");
92 std::fs::create_dir_all(&cache_dir)?;
93 let cached_cpp = cache_dir.join(format!("{cache_key}.ino.cpp"));
94
95 if cached_cpp.exists() {
96 return restore_cached_ino_output(&cached_cpp, output);
97 }
98
99 let generated = zccache_compiler::arduino::generate_ino_cpp(
100 input,
101 &zccache_compiler::arduino::ArduinoConversionOptions {
102 clang_args: options.clang_args.clone(),
103 inject_arduino_include: options.inject_arduino_include,
104 },
105 )?;
106
107 write_file_atomically(&cached_cpp, generated.cpp.as_bytes())?;
108 restore_cached_ino_output(&cached_cpp, output).map(|_| InoConvertResult {
109 cache_hit: false,
110 skipped_write: false,
111 })
112}
113
114fn restore_cached_ino_output(
115 cached_cpp: &Path,
116 output: &Path,
117) -> Result<InoConvertResult, Box<dyn std::error::Error>> {
118 if output.exists() {
119 let output_hash = zccache_hash::hash_file(output)?;
120 let cached_hash = zccache_hash::hash_file(cached_cpp)?;
121 if output_hash == cached_hash {
122 return Ok(InoConvertResult {
123 cache_hit: true,
124 skipped_write: true,
125 });
126 }
127 }
128
129 if let Some(parent) = output.parent() {
130 std::fs::create_dir_all(parent)?;
131 }
132 std::fs::copy(cached_cpp, output)?;
133 Ok(InoConvertResult {
134 cache_hit: true,
135 skipped_write: false,
136 })
137}
138
139fn write_file_atomically(path: &Path, data: &[u8]) -> Result<(), std::io::Error> {
140 let parent = path.parent().unwrap_or_else(|| Path::new("."));
141 std::fs::create_dir_all(parent)?;
142
143 let tmp = tempfile::NamedTempFile::new_in(parent)?;
144 std::fs::write(tmp.path(), data)?;
145 match tmp.persist(path) {
146 Ok(_) => Ok(()),
147 Err(err) => Err(err.error),
148 }
149}
150
151fn resolve_endpoint(explicit: Option<&str>) -> String {
152 if let Some(ep) = explicit {
153 return ep.to_string();
154 }
155 if let Ok(ep) = std::env::var("ZCCACHE_ENDPOINT") {
156 return ep;
157 }
158 zccache_ipc::default_endpoint()
159}
160
161pub fn infer_download_archive_path(
162 source: &DownloadSource,
163 archive_format: ArchiveFormat,
164) -> std::path::PathBuf {
165 let file_name = infer_download_file_name(source, archive_format);
166 zccache_core::config::default_cache_dir()
167 .join("downloads")
168 .join("artifacts")
169 .join(file_name)
170 .into_path_buf()
171}
172
173#[must_use]
174pub fn build_download_request(params: DownloadParams) -> FetchRequest {
175 let archive_path = params
176 .archive_path
177 .unwrap_or_else(|| infer_download_archive_path(¶ms.source, params.archive_format));
178 let mut request = FetchRequest::new(params.source, archive_path);
179 request.destination_path_expanded = params.unarchive_path;
180 request.expected_sha256 = params.expected_sha256;
181 request.archive_format = params.archive_format;
182 request.wait_mode = params.wait_mode;
183 request.dry_run = params.dry_run;
184 request.force = params.force;
185 request.download_options.force = params.force;
186 request.download_options.max_connections = params.max_connections;
187 request.download_options.min_segment_size = params.min_segment_size;
188 request
189}
190
191pub fn client_download(
192 endpoint: Option<&str>,
193 params: DownloadParams,
194) -> Result<FetchResult, String> {
195 let request = build_download_request(params);
196 let client = zccache_download_client::DownloadClient::new(endpoint.map(ToOwned::to_owned));
197 client.fetch(request)
198}
199
200pub fn client_download_exists(
201 endpoint: Option<&str>,
202 params: DownloadParams,
203) -> Result<FetchState, String> {
204 let request = build_download_request(params);
205 let client = zccache_download_client::DownloadClient::new(endpoint.map(ToOwned::to_owned));
206 client.exists(&request)
207}
208
209fn infer_download_file_name(source: &DownloadSource, archive_format: ArchiveFormat) -> String {
210 let base = infer_source_file_name(source);
211 let hash = blake3::hash(download_source_key(source).as_bytes())
212 .to_hex()
213 .to_string();
214 let suffix = archive_suffix(archive_format);
215
216 if base.contains('.') || suffix.is_empty() {
217 format!("{hash}-{base}")
218 } else {
219 format!("{hash}-{base}{suffix}")
220 }
221}
222
223fn infer_source_file_name(source: &DownloadSource) -> String {
224 match source {
225 DownloadSource::Url(url) => {
226 infer_url_file_name(url).unwrap_or_else(|| "download".to_string())
227 }
228 DownloadSource::MultipartUrls(urls) => infer_multipart_file_name(urls),
229 }
230}
231
232fn infer_url_file_name(url: &str) -> Option<String> {
233 url.split(['?', '#'])
234 .next()
235 .and_then(|value| value.rsplit('/').next())
236 .filter(|value| !value.is_empty())
237 .map(sanitize_download_file_name)
238 .filter(|value| !value.is_empty())
239}
240
241fn infer_multipart_file_name(urls: &[String]) -> String {
242 let base = urls
243 .first()
244 .and_then(|url| infer_url_file_name(url))
245 .map(|name| strip_part_suffix(&name).to_string())
246 .filter(|name| !name.is_empty())
247 .unwrap_or_else(|| "multipart-download".to_string());
248 if base.contains('.') {
249 base
250 } else {
251 "multipart-download".to_string()
252 }
253}
254
255fn strip_part_suffix(value: &str) -> &str {
256 if let Some((base, suffix)) = value.rsplit_once(".part-") {
257 if !base.is_empty() && !suffix.is_empty() {
258 return base;
259 }
260 }
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(index) = value.rfind(".part") {
267 let suffix = &value[index + ".part".len()..];
268 if !suffix.is_empty()
269 && suffix
270 .chars()
271 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
272 {
273 return &value[..index];
274 }
275 }
276 value
277}
278
279fn download_source_key(source: &DownloadSource) -> String {
280 match source {
281 DownloadSource::Url(url) => url.clone(),
282 DownloadSource::MultipartUrls(urls) => urls.join("\n"),
283 }
284}
285
286fn sanitize_download_file_name(value: &str) -> String {
287 value
288 .chars()
289 .map(|ch| match ch {
290 '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
291 c if c.is_control() => '_',
292 c => c,
293 })
294 .collect()
295}
296
297fn archive_suffix(format: ArchiveFormat) -> &'static str {
298 match format {
299 ArchiveFormat::Auto | ArchiveFormat::None => "",
300 ArchiveFormat::Zst => ".zst",
301 ArchiveFormat::Zip => ".zip",
302 ArchiveFormat::Xz => ".xz",
303 ArchiveFormat::TarGz => ".tar.gz",
304 ArchiveFormat::TarXz => ".tar.xz",
305 ArchiveFormat::TarZst => ".tar.zst",
306 ArchiveFormat::SevenZip => ".7z",
307 }
308}
309
310fn run_async<T>(future: impl std::future::Future<Output = Result<T, String>>) -> Result<T, String> {
311 tokio::runtime::Builder::new_current_thread()
312 .enable_all()
313 .build()
314 .map_err(|e| format!("failed to create tokio runtime: {e}"))?
315 .block_on(future)
316}
317
318#[derive(Debug)]
319enum VersionCheck {
320 Ok,
321 Unreachable,
322 DaemonOlder { daemon_ver: String },
323 DaemonNewer,
324 CommError,
325}
326
327#[cfg(unix)]
328async fn connect_client(
329 endpoint: &str,
330) -> Result<zccache_ipc::IpcConnection, zccache_ipc::IpcError> {
331 zccache_ipc::connect(endpoint).await
332}
333
334#[cfg(windows)]
335async fn connect_client(
336 endpoint: &str,
337) -> Result<zccache_ipc::IpcClientConnection, zccache_ipc::IpcError> {
338 zccache_ipc::connect(endpoint).await
339}
340
341async fn check_daemon_version(endpoint: &str) -> VersionCheck {
342 let mut conn = match connect_client(endpoint).await {
343 Ok(c) => c,
344 Err(_) => return VersionCheck::Unreachable,
345 };
346 if conn.send(&zccache_protocol::Request::Status).await.is_err() {
347 return VersionCheck::CommError;
348 }
349 match conn.recv::<zccache_protocol::Response>().await {
350 Ok(Some(zccache_protocol::Response::Status(s))) => {
351 if s.version == zccache_core::VERSION {
352 return VersionCheck::Ok;
353 }
354 let client_ver = zccache_core::version::current();
355 match zccache_core::version::Version::parse(&s.version) {
356 Some(daemon_ver) => match daemon_ver.cmp(&client_ver) {
357 std::cmp::Ordering::Equal => VersionCheck::Ok,
358 std::cmp::Ordering::Greater => VersionCheck::DaemonNewer,
359 std::cmp::Ordering::Less => VersionCheck::DaemonOlder {
360 daemon_ver: s.version,
361 },
362 },
363 None => VersionCheck::DaemonOlder {
364 daemon_ver: s.version,
365 },
366 }
367 }
368 _ => VersionCheck::CommError,
369 }
370}
371
372async fn spawn_and_wait(endpoint: &str) -> Result<(), String> {
373 let daemon_bin = find_daemon_binary().ok_or("cannot find zccache-daemon binary")?;
374 spawn_daemon(&daemon_bin, endpoint)?;
375
376 for _ in 0..100 {
377 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
378 if connect_client(endpoint).await.is_ok() {
379 return Ok(());
380 }
381 }
382 Err("daemon started but not accepting connections after 10s".to_string())
383}
384
385async fn stop_stale_daemon(endpoint: &str) {
387 if let Ok(mut conn) = connect_client(endpoint).await {
388 let _ = conn.send(&zccache_protocol::Request::Shutdown).await;
389 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
390 }
391
392 if let Some(pid) = zccache_ipc::check_running_daemon() {
393 if zccache_ipc::force_kill_process(pid).is_ok() {
394 for _ in 0..50 {
395 if !zccache_ipc::is_process_alive(pid) {
396 break;
397 }
398 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
399 }
400 }
401 zccache_ipc::remove_lock_file();
402 }
403
404 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
405}
406
407async fn ensure_daemon(endpoint: &str) -> Result<(), String> {
408 match check_daemon_version(endpoint).await {
409 VersionCheck::Ok | VersionCheck::DaemonNewer => return Ok(()),
410 VersionCheck::DaemonOlder { daemon_ver } => {
411 tracing::info!(
412 daemon_ver,
413 client_ver = zccache_core::VERSION,
414 "daemon is older than client, auto-recovering"
415 );
416 stop_stale_daemon(endpoint).await;
417 return spawn_and_wait(endpoint).await;
418 }
419 VersionCheck::CommError => {
420 tracing::info!("cannot communicate with daemon, auto-recovering");
421 stop_stale_daemon(endpoint).await;
422 return spawn_and_wait(endpoint).await;
423 }
424 VersionCheck::Unreachable => {}
425 }
426
427 if let Some(pid) = zccache_ipc::check_running_daemon() {
428 let mut backoff = std::time::Duration::from_millis(100);
429 for _ in 0..20 {
430 tokio::time::sleep(backoff).await;
431 backoff = (backoff * 2).min(std::time::Duration::from_millis(500));
432 match check_daemon_version(endpoint).await {
433 VersionCheck::Ok | VersionCheck::DaemonNewer => return Ok(()),
434 VersionCheck::DaemonOlder { daemon_ver } => {
435 tracing::info!(
436 daemon_ver,
437 client_ver = zccache_core::VERSION,
438 "daemon is older than client during startup, auto-recovering"
439 );
440 stop_stale_daemon(endpoint).await;
441 return spawn_and_wait(endpoint).await;
442 }
443 VersionCheck::CommError => {
444 stop_stale_daemon(endpoint).await;
445 return spawn_and_wait(endpoint).await;
446 }
447 VersionCheck::Unreachable => continue,
448 }
449 }
450 return Err(format!(
451 "daemon process {pid} exists but not accepting connections after retrying"
452 ));
453 }
454
455 spawn_and_wait(endpoint).await
456}
457
458fn find_daemon_binary() -> Option<NormalizedPath> {
459 let name = if cfg!(windows) {
460 "zccache-daemon.exe"
461 } else {
462 "zccache-daemon"
463 };
464
465 if let Ok(exe) = std::env::current_exe() {
466 if let Some(dir) = exe.parent() {
467 let candidate = dir.join(name);
468 if candidate.exists() {
469 return Some(candidate.into());
470 }
471 }
472 }
473
474 which_on_path(name)
475}
476
477fn which_on_path(name: &str) -> Option<NormalizedPath> {
478 let path_var = std::env::var_os("PATH")?;
479 for dir in std::env::split_paths(&path_var) {
480 let candidate = dir.join(name);
481 if candidate.is_file() {
482 return Some(candidate.into());
483 }
484 #[cfg(windows)]
485 if Path::new(name).extension().is_none() {
486 let with_exe = dir.join(format!("{name}.exe"));
487 if with_exe.is_file() {
488 return Some(with_exe.into());
489 }
490 }
491 }
492 None
493}
494
495fn apply_cli_spawn_lineage(cmd: &mut std::process::Command) {
504 const ENV_ORIGINATOR: &str = "RUNNING_PROCESS_ORIGINATOR";
505 const ENV_LINEAGE: &str = "ZCCACHE_LINEAGE";
506 const ENV_PARENT_PID: &str = "ZCCACHE_PARENT_PID";
507 const ENV_CLIENT_PID: &str = "ZCCACHE_CLIENT_PID";
508
509 let cli_pid = std::process::id();
510
511 if std::env::var(ENV_ORIGINATOR).is_err() {
514 cmd.env(ENV_ORIGINATOR, format!("zccache-cli:{cli_pid}"));
515 }
516
517 let chain = match std::env::var(ENV_LINEAGE) {
519 Ok(existing)
520 if existing
521 .rsplit_once('>')
522 .map_or(existing.as_str(), |(_, last)| last)
523 != cli_pid.to_string() =>
524 {
525 format!("{existing}>{cli_pid}")
526 }
527 Ok(existing) => existing,
528 Err(_) => cli_pid.to_string(),
529 };
530 cmd.env(ENV_LINEAGE, chain);
531 cmd.env(ENV_PARENT_PID, cli_pid.to_string());
532 cmd.env(ENV_CLIENT_PID, cli_pid.to_string());
533}
534
535const RUNTIME_BINARIES_SUBDIR: &str = "runtime-binaries";
541
542#[must_use]
544pub fn runtime_binaries_dir() -> NormalizedPath {
545 zccache_core::config::default_cache_dir().join(RUNTIME_BINARIES_SUBDIR)
546}
547
548pub fn prepare_daemon_exe(canonical: &Path) -> Result<std::path::PathBuf, std::io::Error> {
557 prepare_daemon_exe_in(canonical, runtime_binaries_dir().as_path())
558}
559
560pub fn prepare_daemon_exe_in(
563 canonical: &Path,
564 dir: &Path,
565) -> Result<std::path::PathBuf, std::io::Error> {
566 std::fs::create_dir_all(dir)?;
567
568 let rand_id: u32 = std::process::id()
572 ^ std::time::UNIX_EPOCH
573 .elapsed()
574 .unwrap_or_default()
575 .subsec_nanos();
576 let extension = canonical.extension().and_then(|s| s.to_str()).unwrap_or("");
577 let file_name = if extension.is_empty() {
578 format!("zccache-daemon.{rand_id}")
579 } else {
580 format!("zccache-daemon.{rand_id}.{extension}")
581 };
582 let dest = dir.join(&file_name);
583 std::fs::copy(canonical, &dest)?;
584 Ok(dest)
585}
586
587pub fn gc_runtime_binaries() {
592 gc_runtime_binaries_in(runtime_binaries_dir().as_path());
593}
594
595pub fn gc_runtime_binaries_in(dir: &Path) {
597 let entries = match std::fs::read_dir(dir) {
598 Ok(e) => e,
599 Err(_) => return,
600 };
601 for entry in entries.flatten() {
602 let _ = std::fs::remove_file(entry.path());
603 }
604}
605
606fn spawn_daemon(bin: &Path, endpoint: &str) -> Result<(), String> {
607 gc_runtime_binaries();
610
611 let bin_owned: std::path::PathBuf;
615 let spawn_bin: &Path = match prepare_daemon_exe(bin) {
616 Ok(p) => {
617 bin_owned = p;
618 &bin_owned
619 }
620 Err(_) => bin,
621 };
622
623 let mut cmd = std::process::Command::new(spawn_bin);
624 cmd.args(["--foreground", "--endpoint", endpoint]);
625 cmd.stdin(std::process::Stdio::null());
626 cmd.stdout(std::process::Stdio::null());
627 cmd.stderr(std::process::Stdio::null());
628
629 apply_cli_spawn_lineage(&mut cmd);
630
631 #[cfg(windows)]
632 {
633 use std::os::windows::process::CommandExt;
634 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
635 cmd.creation_flags(CREATE_NO_WINDOW);
636 disable_handle_inheritance();
637 }
638
639 cmd.spawn()
640 .map_err(|e| format!("failed to spawn daemon: {e}"))?;
641
642 #[cfg(windows)]
643 restore_handle_inheritance();
644
645 Ok(())
646}
647
648#[cfg(windows)]
649fn disable_handle_inheritance() {
650 use std::os::windows::io::AsRawHandle;
651
652 extern "system" {
653 fn SetHandleInformation(handle: *mut std::ffi::c_void, mask: u32, flags: u32) -> i32;
654 }
655 const HANDLE_FLAG_INHERIT: u32 = 1;
656
657 unsafe {
658 let stdout = std::io::stdout().as_raw_handle();
659 let stderr = std::io::stderr().as_raw_handle();
660 let _ = SetHandleInformation(stdout.cast(), HANDLE_FLAG_INHERIT, 0);
661 let _ = SetHandleInformation(stderr.cast(), HANDLE_FLAG_INHERIT, 0);
662 }
663}
664
665#[cfg(windows)]
666fn restore_handle_inheritance() {
667 use std::os::windows::io::AsRawHandle;
668
669 extern "system" {
670 fn SetHandleInformation(handle: *mut std::ffi::c_void, mask: u32, flags: u32) -> i32;
671 }
672 const HANDLE_FLAG_INHERIT: u32 = 1;
673
674 unsafe {
675 let stdout = std::io::stdout().as_raw_handle();
676 let stderr = std::io::stderr().as_raw_handle();
677 let _ = SetHandleInformation(stdout.cast(), HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT);
678 let _ = SetHandleInformation(stderr.cast(), HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT);
679 }
680}
681
682#[derive(Debug, Clone)]
683pub struct SessionStartResponse {
684 pub session_id: String,
685 pub journal_path: Option<String>,
686}
687
688pub fn client_start(endpoint: Option<&str>) -> Result<(), String> {
689 let endpoint = resolve_endpoint(endpoint);
690 run_async(async move { ensure_daemon(&endpoint).await })
691}
692
693pub fn client_stop(endpoint: Option<&str>) -> Result<bool, String> {
694 let endpoint = resolve_endpoint(endpoint);
695 run_async(async move {
696 let mut conn = match connect_client(&endpoint).await {
697 Ok(c) => c,
698 Err(_) => return Ok(false),
699 };
700 conn.send(&zccache_protocol::Request::Shutdown)
701 .await
702 .map_err(|e| format!("failed to send to daemon: {e}"))?;
703 match conn.recv::<zccache_protocol::Response>().await {
704 Ok(Some(zccache_protocol::Response::ShuttingDown)) => Ok(true),
705 Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
706 Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
707 Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
708 Err(e) => Err(format!("broken connection to daemon: {e}")),
709 }
710 })
711}
712
713pub fn client_status(endpoint: Option<&str>) -> Result<zccache_protocol::DaemonStatus, String> {
714 let endpoint = resolve_endpoint(endpoint);
715 run_async(async move {
716 let mut conn = connect_client(&endpoint)
717 .await
718 .map_err(|e| format!("daemon not running at {endpoint}: {e}"))?;
719 conn.send(&zccache_protocol::Request::Status)
720 .await
721 .map_err(|e| format!("failed to send to daemon: {e}"))?;
722 match conn.recv::<zccache_protocol::Response>().await {
723 Ok(Some(zccache_protocol::Response::Status(status))) => Ok(status),
724 Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
725 Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
726 Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
727 Err(e) => Err(format!("broken connection to daemon: {e}")),
728 }
729 })
730}
731
732pub fn client_session_start(
733 endpoint: Option<&str>,
734 cwd: &Path,
735 log_file: Option<&Path>,
736 track_stats: bool,
737 journal_path: Option<&Path>,
738) -> Result<SessionStartResponse, String> {
739 let endpoint = resolve_endpoint(endpoint);
740 let cwd = cwd.to_path_buf();
741 let log_file = log_file.map(NormalizedPath::from);
742 let journal_path = journal_path.map(NormalizedPath::from);
743
744 run_async(async move {
745 ensure_daemon(&endpoint).await?;
746 let mut conn = connect_client(&endpoint)
747 .await
748 .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
749 conn.send(&zccache_protocol::Request::SessionStart {
750 client_pid: std::process::id(),
751 working_dir: cwd.into(),
752 log_file,
753 track_stats,
754 journal_path,
755 })
756 .await
757 .map_err(|e| format!("failed to send to daemon: {e}"))?;
758
759 match conn.recv::<zccache_protocol::Response>().await {
760 Ok(Some(zccache_protocol::Response::SessionStarted {
761 session_id,
762 journal_path,
763 })) => Ok(SessionStartResponse {
764 session_id,
765 journal_path: journal_path.map(|p| p.display().to_string()),
766 }),
767 Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
768 Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
769 Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
770 Err(e) => Err(format!("broken connection to daemon: {e}")),
771 }
772 })
773}
774
775pub fn client_session_end(
785 endpoint: Option<&str>,
786 session_id: &str,
787) -> Result<Option<zccache_protocol::SessionStats>, String> {
788 let endpoint = resolve_endpoint(endpoint);
789 session_end_idempotent(&endpoint, session_id).map_err(|e| e.to_string())
790}
791
792#[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]
1256 fn is_daemon_unreachable_recognizes_raw_enoent() {
1257 let err = zccache_ipc::IpcError::Io(std::io::Error::from_raw_os_error(2));
1259 assert!(
1260 is_daemon_unreachable_err(&err),
1261 "errno 2 must map to a kind in the unreachable set; got kind={:?}",
1262 match &err {
1263 zccache_ipc::IpcError::Io(io) => io.kind(),
1264 _ => unreachable!(),
1265 }
1266 );
1267 }
1268
1269 #[test]
1277 fn client_session_end_swallows_vanished_daemon() {
1278 let endpoint = zccache_ipc::unique_test_endpoint();
1279 let session_id = "00000000-0000-0000-0000-000000000000";
1280
1281 let result = client_session_end(Some(&endpoint), session_id);
1282
1283 assert!(
1284 matches!(result, Ok(None)),
1285 "vanished daemon must produce Ok(None) (success no-op), got {result:?}"
1286 );
1287 }
1288}