Skip to main content

zccache_cli/
lib.rs

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(&params.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
394/// Stop a stale daemon that is unreachable or version-incompatible.
395async 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/// Initialize spawn-lineage env vars on a command the CLI is about to spawn.
505///
506/// Mirrors the daemon-side propagation in `zccache_daemon::lineage` so that
507/// any process attribution (orphan tracking, running-process scanners) sees
508/// a consistent chain across CLI -> daemon -> compiler hops. The chain is
509/// initialized with the CLI's PID, and the originator marker (used by
510/// running-process for crash-resilient orphan discovery) is set to
511/// `zccache-cli:<pid>` unless an outer tool has already claimed it.
512#[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
519/// Compute the lineage env-var pairs the CLI sets on the daemon it
520/// spawns. Returns the same overrides `apply_cli_spawn_lineage` writes
521/// onto a `Command`, in a form usable by the Windows raw-spawn path
522/// (which needs to build its own merged environment block).
523fn 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    // Preserve any outer originator (e.g. the build tool was already wrapped
533    // by running-process). Otherwise, claim the originator slot ourselves.
534    if std::env::var(ENV_ORIGINATOR).is_err() {
535        out.push((ENV_ORIGINATOR.to_string(), format!("zccache-cli:{cli_pid}")));
536    }
537
538    // Extend or initialize the chain with our PID.
539    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
557/// Subdir of the zccache global cache directory where the CLI stores
558/// per-launch copies of the daemon binary. The daemon runs from one of
559/// these copies, never from the install path (e.g. `Scripts/zccache-daemon.exe`),
560/// so `pip install --upgrade zccache` can always overwrite the install
561/// path regardless of whether a daemon is alive. See issue #134.
562const RUNTIME_BINARIES_SUBDIR: &str = "runtime-binaries";
563
564/// Returns `<global_cache_dir>/runtime-binaries`.
565#[must_use]
566pub fn runtime_binaries_dir() -> NormalizedPath {
567    zccache_core::config::default_cache_dir().join(RUNTIME_BINARIES_SUBDIR)
568}
569
570/// Copy `canonical` (the daemon binary at its install location) to a unique
571/// path inside [`runtime_binaries_dir`] and return the new path. The caller
572/// then spawns from the returned path so the install location is never
573/// file-locked by a running daemon.
574///
575/// On copy failure the caller should fall back to spawning `canonical`
576/// directly; the in-place `unlock_exe()` in the daemon then handles the
577/// lock removal as a fallback.
578pub 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
582/// Test seam for [`prepare_daemon_exe`]: copies `canonical` into `dir`
583/// (which is created if missing) and returns the destination path.
584pub 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    // Per-launch unique name. PID alone is reused across reboots; xor with
591    // the current nanos timestamp to keep collisions rare even when several
592    // CLI processes spawn back-to-back.
593    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
609/// Best-effort delete every entry in [`runtime_binaries_dir`]. On Windows
610/// the kernel refuses to delete a file with an open handle, so files
611/// belonging to a *currently running* daemon are silently skipped — no PID
612/// tracking, no sidecar files. Cheap enough to call before every spawn.
613pub fn gc_runtime_binaries() {
614    gc_runtime_binaries_in(runtime_binaries_dir().as_path());
615}
616
617/// Test seam for [`gc_runtime_binaries`].
618pub 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 before the new spawn so the dir doesn't grow unbounded across
630    // crash-loop scenarios. Live daemons are skipped (locked files).
631    gc_runtime_binaries();
632
633    // Prefer to spawn from a relocated copy in the zccache global dir.
634    // Fall back to the canonical install path if the copy fails — the
635    // daemon's own `unlock_exe()` then handles the in-place rename.
636    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    // On Windows we cannot use std::process::Command::spawn for the
646    // daemon: it calls CreateProcessW with bInheritHandles = TRUE and
647    // the kernel duplicates every inheritable handle in our table —
648    // including orphaned pipe handles inherited from a grandparent
649    // (e.g. Python's `subprocess.Popen(stdout=PIPE)`). The daemon would
650    // then hold that pipe open and the grandparent's read would never
651    // EOF. See `spawn_daemon_windows` and issue #289 for the full
652    // root-cause analysis.
653    #[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
769/// End a session — daemon-unreachable is treated as a successful no-op.
770///
771/// Thin `String`-error wrapper around [`session_end_idempotent`]. All in-process
772/// callers (Python bindings, soldr, future tools) route through here, so the
773/// idempotency contract that #151 / #159 established for the CLI subprocess
774/// path applies equally to library users. Without this, soldr's at-exit
775/// `zccache session-end` from `rust-plan save` fails Windows CI with
776/// "cannot connect to daemon at \\.\pipe\zccache-…" when the daemon already
777/// exited — every workspace test passed but teardown failed.
778pub 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/// Is this connect-time error a "daemon process is gone entirely" error?
787///
788/// The conservative set: `NotFound` (Unix socket missing, Windows pipe
789/// missing), `ConnectionRefused` (Unix socket exists but no listener;
790/// Windows backoff helper synthesizes this when all pipe instances are
791/// permanently busy), and `BrokenPipe` (race: pipe vanished between
792/// open and use). Other errors (`TimedOut`, protocol mismatches, etc.)
793/// are NOT daemon-gone — they should still fail loudly.
794///
795/// `IpcError::Timeout` is explicitly **NOT** in the unreachable set. A
796/// timed-out recv means we connected successfully but the peer did not
797/// respond in the configured window — that's either a hung daemon (a
798/// real fault) or a per-call budget that was too tight (caller error).
799/// Either way: propagate, don't silently swallow.
800///
801/// Used by `session_end_idempotent` (issue #159) and the CLI's
802/// `cmd_session_end` (issue #150 / #151) to map "the daemon already
803/// died" connect-time failures onto a success no-op. Other request
804/// types keep their existing strict error semantics.
805#[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
817/// End a session, treating a vanished daemon as success.
818///
819/// This is the shared library entry point for ending a session. It is
820/// the contract used by the CLI's `zccache session-end <uuid>`
821/// subcommand AND by any in-process caller (e.g. soldr's at-exit
822/// `rust-plan save`) — both must agree on what "the daemon already
823/// died" means.
824///
825/// # Return shape
826///
827/// - `Ok(Some(stats))` — daemon was reached and returned stats for the
828///   session.
829/// - `Ok(None)` — daemon was reached but returned no stats (session
830///   was tracked without stats), OR the daemon was unreachable at
831///   connect time. Both are no-ops from the caller's perspective:
832///   the session is implicitly ended when the daemon dies (see #137
833///   for the daemon-side mirror), and a caller that just wants to
834///   "end the session, don't care if the daemon is still alive"
835///   should treat both as success.
836/// - `Err(IpcError)` — anything else: timeouts, protocol mismatches,
837///   send/recv mid-conversation failures, daemon error responses.
838///   These are real faults and must be surfaced.
839///
840/// # Why a separate function
841///
842/// Issue #159: soldr was failing Windows CI on every main commit
843/// because its in-process session-end (called from `rust-plan save`)
844/// did not share code with `cmd_session_end`, so #151's
845/// connect-failure idempotency only applied to the CLI subprocess
846/// path. Promoting this contract to the library lets all callers —
847/// current and future — share the same behavior.
848pub 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    // Build a dedicated current-thread runtime. Can't use the existing
856    // `run_async` helper because its `Output = Result<T, String>` shape
857    // doesn't compose with our `Result<_, IpcError>` return type.
858    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    /// Issue #159: `session_end_idempotent` is the shared library entry
1175    /// point for ending a session — used by the CLI `session-end` command
1176    /// AND by tools like soldr that call into the library directly. When
1177    /// the daemon process is gone (pipe / socket missing), this function
1178    /// must return `Ok(None)` rather than propagating the connect-time
1179    /// I/O error. Soldr's at-exit `rust-plan save` previously failed
1180    /// Windows CI because its in-process session-end did NOT go through
1181    /// `cmd_session_end` (which is gated to the CLI subprocess path) and
1182    /// so the #151 idempotency fix didn't apply.
1183    #[test]
1184    fn session_end_idempotent_swallows_vanished_daemon() {
1185        // Construct an endpoint that is guaranteed to have no listener —
1186        // a unique pipe / socket name with no server bound to it.
1187        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    /// Control: non-unreachable errors (the function shouldn't be a
1199    /// blanket "ignore everything"). We can't easily synthesize a live
1200    /// daemon error here, but we can at least assert the routing via the
1201    /// helper used inside the function: connect-time `TimedOut` must NOT
1202    /// be classified as unreachable, so the function would propagate it
1203    /// (rather than silently return Ok(None)). This guards against a
1204    /// regression where someone widens the unreachable set to "any I/O
1205    /// error".
1206    #[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    /// Control: protocol-layer errors (malformed framing, closed
1217    /// connection mid-response) must NOT be classified as unreachable.
1218    #[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    /// Issue #150: connect-time errors that mean "daemon process is gone
1227    /// entirely" must be classified as unreachable so the idempotent
1228    /// session-end paths (`session_end_idempotent` + the CLI's
1229    /// `cmd_session_end` wrapper) can fall through to the success path.
1230    /// The set covers every shape `connect()` actually returns when the
1231    /// pipe / socket is missing or has no listener.
1232    #[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    /// `IpcError::Timeout` is explicitly NOT daemon-unreachable. A
1252    /// timed-out recv means we connected successfully but the peer did
1253    /// not respond — that's a hung-daemon fault, not a vanished daemon.
1254    /// Soldr's at-exit `session_end` path classifies vanished-daemon as
1255    /// a no-op; if `Timeout` were misclassified here, a stuck daemon
1256    /// would be silently swallowed and the user would never see it.
1257    #[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    /// Mapping ENOENT through `from_raw_os_error` must yield the same
1267    /// classification as constructing from `ErrorKind::NotFound`. This
1268    /// guards against platform variance (macOS / Linux / Windows could
1269    /// in principle synthesize a different kind for the same errno).
1270    #[test]
1271    fn is_daemon_unreachable_recognizes_raw_enoent() {
1272        // ENOENT == 2 on every Unix; on Windows ERROR_FILE_NOT_FOUND == 2 too.
1273        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    /// Regression: `client_session_end` is the in-process library entry point
1285    /// used by Python bindings and external tools (soldr's `rust-plan save`).
1286    /// It must mirror `session_end_idempotent` — a vanished daemon is a no-op
1287    /// success, not a hard error. Before this fix, soldr called
1288    /// `client_session_end`, got `Err("cannot connect to daemon at …")`,
1289    /// surfaced it as "soldr: zccache session-end … failed: …", and Windows
1290    /// Test failed teardown even after every workspace test passed.
1291    #[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}