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
9pub mod symbols;
10
11pub use zccache_download_client::{
12    ArchiveFormat, DownloadSource, FetchRequest, FetchResult, FetchState, FetchStateKind,
13    FetchStatus, WaitMode,
14};
15
16#[derive(Debug, Clone)]
17pub struct InoConvertOptions {
18    pub clang_args: Vec<String>,
19    pub inject_arduino_include: bool,
20}
21
22impl Default for InoConvertOptions {
23    fn default() -> Self {
24        Self {
25            clang_args: Vec::new(),
26            inject_arduino_include: true,
27        }
28    }
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct InoConvertResult {
33    pub cache_hit: bool,
34    pub skipped_write: bool,
35}
36
37#[derive(Debug, Clone)]
38pub struct DownloadParams {
39    pub source: DownloadSource,
40    pub archive_path: Option<std::path::PathBuf>,
41    pub unarchive_path: Option<std::path::PathBuf>,
42    pub expected_sha256: Option<String>,
43    pub archive_format: ArchiveFormat,
44    pub max_connections: Option<usize>,
45    pub min_segment_size: Option<u64>,
46    pub wait_mode: WaitMode,
47    pub dry_run: bool,
48    pub force: bool,
49}
50
51impl DownloadParams {
52    #[must_use]
53    pub fn new(source: impl Into<DownloadSource>) -> Self {
54        Self {
55            source: source.into(),
56            archive_path: None,
57            unarchive_path: None,
58            expected_sha256: None,
59            archive_format: ArchiveFormat::Auto,
60            max_connections: None,
61            min_segment_size: None,
62            wait_mode: WaitMode::Block,
63            dry_run: false,
64            force: false,
65        }
66    }
67}
68
69pub fn run_ino_convert_cached(
70    input: &Path,
71    output: &Path,
72    options: &InoConvertOptions,
73) -> Result<InoConvertResult, Box<dyn std::error::Error>> {
74    let input_hash = zccache_hash::hash_file(input)?;
75    let mut hasher = zccache_hash::StreamHasher::new();
76    hasher.update(b"zccache-ino-convert-v1");
77    hasher.update(input_hash.as_bytes());
78    hasher.update(input.as_os_str().to_string_lossy().as_bytes());
79    hasher.update(if options.inject_arduino_include {
80        b"include-arduino-h"
81    } else {
82        b"no-arduino-h"
83    });
84    if let Some(libclang_hash) = zccache_compiler::arduino::libclang_hash() {
85        hasher.update(libclang_hash.as_bytes());
86    }
87    for arg in &options.clang_args {
88        hasher.update(arg.as_bytes());
89        hasher.update(b"\0");
90    }
91    let cache_key = hasher.finalize().to_hex();
92
93    let cache_dir = zccache_core::config::default_cache_dir().join("ino");
94    std::fs::create_dir_all(&cache_dir)?;
95    let cached_cpp = cache_dir.join(format!("{cache_key}.ino.cpp"));
96
97    if cached_cpp.exists() {
98        return restore_cached_ino_output(&cached_cpp, output);
99    }
100
101    let generated = zccache_compiler::arduino::generate_ino_cpp(
102        input,
103        &zccache_compiler::arduino::ArduinoConversionOptions {
104            clang_args: options.clang_args.clone(),
105            inject_arduino_include: options.inject_arduino_include,
106        },
107    )?;
108
109    write_file_atomically(&cached_cpp, generated.cpp.as_bytes())?;
110    restore_cached_ino_output(&cached_cpp, output).map(|_| InoConvertResult {
111        cache_hit: false,
112        skipped_write: false,
113    })
114}
115
116fn restore_cached_ino_output(
117    cached_cpp: &Path,
118    output: &Path,
119) -> Result<InoConvertResult, Box<dyn std::error::Error>> {
120    if output.exists() {
121        let output_hash = zccache_hash::hash_file(output)?;
122        let cached_hash = zccache_hash::hash_file(cached_cpp)?;
123        if output_hash == cached_hash {
124            return Ok(InoConvertResult {
125                cache_hit: true,
126                skipped_write: true,
127            });
128        }
129    }
130
131    if let Some(parent) = output.parent() {
132        std::fs::create_dir_all(parent)?;
133    }
134    std::fs::copy(cached_cpp, output)?;
135    Ok(InoConvertResult {
136        cache_hit: true,
137        skipped_write: false,
138    })
139}
140
141fn write_file_atomically(path: &Path, data: &[u8]) -> Result<(), std::io::Error> {
142    let parent = path.parent().unwrap_or_else(|| Path::new("."));
143    std::fs::create_dir_all(parent)?;
144
145    let tmp = tempfile::NamedTempFile::new_in(parent)?;
146    std::fs::write(tmp.path(), data)?;
147    match tmp.persist(path) {
148        Ok(_) => Ok(()),
149        Err(err) => Err(err.error),
150    }
151}
152
153fn resolve_endpoint(explicit: Option<&str>) -> String {
154    if let Some(ep) = explicit {
155        return ep.to_string();
156    }
157    if let Ok(ep) = std::env::var("ZCCACHE_ENDPOINT") {
158        return ep;
159    }
160    zccache_ipc::default_endpoint()
161}
162
163pub fn infer_download_archive_path(
164    source: &DownloadSource,
165    archive_format: ArchiveFormat,
166) -> std::path::PathBuf {
167    let file_name = infer_download_file_name(source, archive_format);
168    zccache_core::config::default_cache_dir()
169        .join("downloads")
170        .join("artifacts")
171        .join(file_name)
172        .into_path_buf()
173}
174
175#[must_use]
176pub fn build_download_request(params: DownloadParams) -> FetchRequest {
177    let archive_path = params
178        .archive_path
179        .unwrap_or_else(|| infer_download_archive_path(&params.source, params.archive_format));
180    let mut request = FetchRequest::new(params.source, archive_path);
181    request.destination_path_expanded = params.unarchive_path;
182    request.expected_sha256 = params.expected_sha256;
183    request.archive_format = params.archive_format;
184    request.wait_mode = params.wait_mode;
185    request.dry_run = params.dry_run;
186    request.force = params.force;
187    request.download_options.force = params.force;
188    request.download_options.max_connections = params.max_connections;
189    request.download_options.min_segment_size = params.min_segment_size;
190    request
191}
192
193pub fn client_download(
194    endpoint: Option<&str>,
195    params: DownloadParams,
196) -> Result<FetchResult, String> {
197    let request = build_download_request(params);
198    let client = zccache_download_client::DownloadClient::new(endpoint.map(ToOwned::to_owned));
199    client.fetch(request)
200}
201
202pub fn client_download_exists(
203    endpoint: Option<&str>,
204    params: DownloadParams,
205) -> Result<FetchState, String> {
206    let request = build_download_request(params);
207    let client = zccache_download_client::DownloadClient::new(endpoint.map(ToOwned::to_owned));
208    client.exists(&request)
209}
210
211fn infer_download_file_name(source: &DownloadSource, archive_format: ArchiveFormat) -> String {
212    let base = infer_source_file_name(source);
213    let hash = blake3::hash(download_source_key(source).as_bytes())
214        .to_hex()
215        .to_string();
216    let suffix = archive_suffix(archive_format);
217
218    if base.contains('.') || suffix.is_empty() {
219        format!("{hash}-{base}")
220    } else {
221        format!("{hash}-{base}{suffix}")
222    }
223}
224
225fn infer_source_file_name(source: &DownloadSource) -> String {
226    match source {
227        DownloadSource::Url(url) => {
228            infer_url_file_name(url).unwrap_or_else(|| "download".to_string())
229        }
230        DownloadSource::MultipartUrls(urls) => infer_multipart_file_name(urls),
231    }
232}
233
234fn infer_url_file_name(url: &str) -> Option<String> {
235    url.split(['?', '#'])
236        .next()
237        .and_then(|value| value.rsplit('/').next())
238        .filter(|value| !value.is_empty())
239        .map(sanitize_download_file_name)
240        .filter(|value| !value.is_empty())
241}
242
243fn infer_multipart_file_name(urls: &[String]) -> String {
244    let base = urls
245        .first()
246        .and_then(|url| infer_url_file_name(url))
247        .map(|name| strip_part_suffix(&name).to_string())
248        .filter(|name| !name.is_empty())
249        .unwrap_or_else(|| "multipart-download".to_string());
250    if base.contains('.') {
251        base
252    } else {
253        "multipart-download".to_string()
254    }
255}
256
257fn strip_part_suffix(value: &str) -> &str {
258    if let Some((base, suffix)) = value.rsplit_once(".part-") {
259        if !base.is_empty() && !suffix.is_empty() {
260            return base;
261        }
262    }
263    if let Some((base, suffix)) = value.rsplit_once(".part_") {
264        if !base.is_empty() && !suffix.is_empty() {
265            return base;
266        }
267    }
268    if let Some(index) = value.rfind(".part") {
269        let suffix = &value[index + ".part".len()..];
270        if !suffix.is_empty()
271            && suffix
272                .chars()
273                .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
274        {
275            return &value[..index];
276        }
277    }
278    value
279}
280
281fn download_source_key(source: &DownloadSource) -> String {
282    match source {
283        DownloadSource::Url(url) => url.clone(),
284        DownloadSource::MultipartUrls(urls) => urls.join("\n"),
285    }
286}
287
288fn sanitize_download_file_name(value: &str) -> String {
289    value
290        .chars()
291        .map(|ch| match ch {
292            '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
293            c if c.is_control() => '_',
294            c => c,
295        })
296        .collect()
297}
298
299fn archive_suffix(format: ArchiveFormat) -> &'static str {
300    match format {
301        ArchiveFormat::Auto | ArchiveFormat::None => "",
302        ArchiveFormat::Zst => ".zst",
303        ArchiveFormat::Zip => ".zip",
304        ArchiveFormat::Xz => ".xz",
305        ArchiveFormat::TarGz => ".tar.gz",
306        ArchiveFormat::TarXz => ".tar.xz",
307        ArchiveFormat::TarZst => ".tar.zst",
308        ArchiveFormat::SevenZip => ".7z",
309    }
310}
311
312fn run_async<T>(future: impl std::future::Future<Output = Result<T, String>>) -> Result<T, String> {
313    tokio::runtime::Builder::new_current_thread()
314        .enable_all()
315        .build()
316        .map_err(|e| format!("failed to create tokio runtime: {e}"))?
317        .block_on(future)
318}
319
320#[derive(Debug)]
321enum VersionCheck {
322    Ok,
323    Unreachable,
324    DaemonOlder { daemon_ver: String },
325    DaemonNewer,
326    CommError,
327}
328
329#[cfg(unix)]
330async fn connect_client(
331    endpoint: &str,
332) -> Result<zccache_ipc::IpcConnection, zccache_ipc::IpcError> {
333    let mut conn = zccache_ipc::connect(endpoint).await?;
334    conn.set_recv_timeout(zccache_ipc::DEFAULT_CLIENT_RECV_TIMEOUT);
335    Ok(conn)
336}
337
338#[cfg(windows)]
339async fn connect_client(
340    endpoint: &str,
341) -> Result<zccache_ipc::IpcClientConnection, zccache_ipc::IpcError> {
342    let mut conn = zccache_ipc::connect(endpoint).await?;
343    conn.set_recv_timeout(zccache_ipc::DEFAULT_CLIENT_RECV_TIMEOUT);
344    Ok(conn)
345}
346
347async fn check_daemon_version(endpoint: &str) -> VersionCheck {
348    let mut conn = match connect_client(endpoint).await {
349        Ok(c) => c,
350        Err(_) => return VersionCheck::Unreachable,
351    };
352    if conn.send(&zccache_protocol::Request::Status).await.is_err() {
353        return VersionCheck::CommError;
354    }
355    match conn.recv::<zccache_protocol::Response>().await {
356        Ok(Some(zccache_protocol::Response::Status(s))) => {
357            if s.version == zccache_core::VERSION {
358                return VersionCheck::Ok;
359            }
360            let client_ver = zccache_core::version::current();
361            match zccache_core::version::Version::parse(&s.version) {
362                Some(daemon_ver) => match daemon_ver.cmp(&client_ver) {
363                    std::cmp::Ordering::Equal => VersionCheck::Ok,
364                    std::cmp::Ordering::Greater => VersionCheck::DaemonNewer,
365                    std::cmp::Ordering::Less => VersionCheck::DaemonOlder {
366                        daemon_ver: s.version,
367                    },
368                },
369                None => VersionCheck::DaemonOlder {
370                    daemon_ver: s.version,
371                },
372            }
373        }
374        _ => VersionCheck::CommError,
375    }
376}
377
378async fn spawn_and_wait(endpoint: &str) -> Result<(), String> {
379    let daemon_bin = find_daemon_binary().ok_or("cannot find zccache-daemon binary")?;
380    spawn_daemon(&daemon_bin, endpoint)?;
381
382    for _ in 0..100 {
383        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
384        if connect_client(endpoint).await.is_ok() {
385            return Ok(());
386        }
387    }
388    Err("daemon started but not accepting connections after 10s".to_string())
389}
390
391/// Stop a stale daemon that is unreachable or version-incompatible.
392async fn stop_stale_daemon(endpoint: &str) {
393    if let Ok(mut conn) = connect_client(endpoint).await {
394        let _ = conn.send(&zccache_protocol::Request::Shutdown).await;
395        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
396    }
397
398    if let Some(pid) = zccache_ipc::check_running_daemon() {
399        if zccache_ipc::force_kill_process(pid).is_ok() {
400            for _ in 0..50 {
401                if !zccache_ipc::is_process_alive(pid) {
402                    break;
403                }
404                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
405            }
406        }
407        zccache_ipc::remove_lock_file();
408    }
409
410    tokio::time::sleep(std::time::Duration::from_millis(200)).await;
411}
412
413async fn ensure_daemon(endpoint: &str) -> Result<(), String> {
414    match check_daemon_version(endpoint).await {
415        VersionCheck::Ok | VersionCheck::DaemonNewer => return Ok(()),
416        VersionCheck::DaemonOlder { daemon_ver } => {
417            tracing::info!(
418                daemon_ver,
419                client_ver = zccache_core::VERSION,
420                "daemon is older than client, auto-recovering"
421            );
422            stop_stale_daemon(endpoint).await;
423            return spawn_and_wait(endpoint).await;
424        }
425        VersionCheck::CommError => {
426            tracing::info!("cannot communicate with daemon, auto-recovering");
427            stop_stale_daemon(endpoint).await;
428            return spawn_and_wait(endpoint).await;
429        }
430        VersionCheck::Unreachable => {}
431    }
432
433    if let Some(pid) = zccache_ipc::check_running_daemon() {
434        let mut backoff = std::time::Duration::from_millis(100);
435        for _ in 0..20 {
436            tokio::time::sleep(backoff).await;
437            backoff = (backoff * 2).min(std::time::Duration::from_millis(500));
438            match check_daemon_version(endpoint).await {
439                VersionCheck::Ok | VersionCheck::DaemonNewer => return Ok(()),
440                VersionCheck::DaemonOlder { daemon_ver } => {
441                    tracing::info!(
442                        daemon_ver,
443                        client_ver = zccache_core::VERSION,
444                        "daemon is older than client during startup, auto-recovering"
445                    );
446                    stop_stale_daemon(endpoint).await;
447                    return spawn_and_wait(endpoint).await;
448                }
449                VersionCheck::CommError => {
450                    stop_stale_daemon(endpoint).await;
451                    return spawn_and_wait(endpoint).await;
452                }
453                VersionCheck::Unreachable => continue,
454            }
455        }
456        return Err(format!(
457            "daemon process {pid} exists but not accepting connections after retrying"
458        ));
459    }
460
461    spawn_and_wait(endpoint).await
462}
463
464fn find_daemon_binary() -> Option<NormalizedPath> {
465    let name = if cfg!(windows) {
466        "zccache-daemon.exe"
467    } else {
468        "zccache-daemon"
469    };
470
471    if let Ok(exe) = std::env::current_exe() {
472        if let Some(dir) = exe.parent() {
473            let candidate = dir.join(name);
474            if candidate.exists() {
475                return Some(candidate.into());
476            }
477        }
478    }
479
480    which_on_path(name)
481}
482
483fn which_on_path(name: &str) -> Option<NormalizedPath> {
484    let path_var = std::env::var_os("PATH")?;
485    for dir in std::env::split_paths(&path_var) {
486        let candidate = dir.join(name);
487        if candidate.is_file() {
488            return Some(candidate.into());
489        }
490        #[cfg(windows)]
491        if Path::new(name).extension().is_none() {
492            let with_exe = dir.join(format!("{name}.exe"));
493            if with_exe.is_file() {
494                return Some(with_exe.into());
495            }
496        }
497    }
498    None
499}
500
501/// Initialize spawn-lineage env vars on a command the CLI is about to spawn.
502///
503/// Mirrors the daemon-side propagation in `zccache_daemon::lineage` so that
504/// any process attribution (orphan tracking, running-process scanners) sees
505/// a consistent chain across CLI -> daemon -> compiler hops. The chain is
506/// initialized with the CLI's PID, and the originator marker (used by
507/// running-process for crash-resilient orphan discovery) is set to
508/// `zccache-cli:<pid>` unless an outer tool has already claimed it.
509#[cfg(not(windows))]
510fn apply_cli_spawn_lineage(cmd: &mut std::process::Command) {
511    for (k, v) in cli_spawn_lineage_env() {
512        cmd.env(k, v);
513    }
514}
515
516/// Compute the lineage env-var pairs the CLI sets on the daemon it
517/// spawns. Returns the same overrides `apply_cli_spawn_lineage` writes
518/// onto a `Command`, in a form usable by the Windows raw-spawn path
519/// (which needs to build its own merged environment block).
520fn cli_spawn_lineage_env() -> Vec<(String, String)> {
521    const ENV_ORIGINATOR: &str = "RUNNING_PROCESS_ORIGINATOR";
522    const ENV_LINEAGE: &str = "ZCCACHE_LINEAGE";
523    const ENV_PARENT_PID: &str = "ZCCACHE_PARENT_PID";
524    const ENV_CLIENT_PID: &str = "ZCCACHE_CLIENT_PID";
525
526    let cli_pid = std::process::id();
527    let mut out: Vec<(String, String)> = Vec::with_capacity(4);
528
529    // Preserve any outer originator (e.g. the build tool was already wrapped
530    // by running-process). Otherwise, claim the originator slot ourselves.
531    if std::env::var(ENV_ORIGINATOR).is_err() {
532        out.push((ENV_ORIGINATOR.to_string(), format!("zccache-cli:{cli_pid}")));
533    }
534
535    // Extend or initialize the chain with our PID.
536    let chain = match std::env::var(ENV_LINEAGE) {
537        Ok(existing)
538            if existing
539                .rsplit_once('>')
540                .map_or(existing.as_str(), |(_, last)| last)
541                != cli_pid.to_string() =>
542        {
543            format!("{existing}>{cli_pid}")
544        }
545        Ok(existing) => existing,
546        Err(_) => cli_pid.to_string(),
547    };
548    out.push((ENV_LINEAGE.to_string(), chain));
549    out.push((ENV_PARENT_PID.to_string(), cli_pid.to_string()));
550    out.push((ENV_CLIENT_PID.to_string(), cli_pid.to_string()));
551    out
552}
553
554/// Subdir of the zccache global cache directory where the CLI stores
555/// per-launch copies of the daemon binary. The daemon runs from one of
556/// these copies, never from the install path (e.g. `Scripts/zccache-daemon.exe`),
557/// so `pip install --upgrade zccache` can always overwrite the install
558/// path regardless of whether a daemon is alive. See issue #134.
559const RUNTIME_BINARIES_SUBDIR: &str = "runtime-binaries";
560
561/// Returns `<global_cache_dir>/runtime-binaries`.
562#[must_use]
563pub fn runtime_binaries_dir() -> NormalizedPath {
564    zccache_core::config::default_cache_dir().join(RUNTIME_BINARIES_SUBDIR)
565}
566
567/// Copy `canonical` (the daemon binary at its install location) to a unique
568/// path inside [`runtime_binaries_dir`] and return the new path. The caller
569/// then spawns from the returned path so the install location is never
570/// file-locked by a running daemon.
571///
572/// On copy failure the caller should fall back to spawning `canonical`
573/// directly; the in-place `unlock_exe()` in the daemon then handles the
574/// lock removal as a fallback.
575pub fn prepare_daemon_exe(canonical: &Path) -> Result<std::path::PathBuf, std::io::Error> {
576    prepare_daemon_exe_in(canonical, runtime_binaries_dir().as_path())
577}
578
579/// Test seam for [`prepare_daemon_exe`]: copies `canonical` into `dir`
580/// (which is created if missing) and returns the destination path.
581pub fn prepare_daemon_exe_in(
582    canonical: &Path,
583    dir: &Path,
584) -> Result<std::path::PathBuf, std::io::Error> {
585    std::fs::create_dir_all(dir)?;
586
587    // Per-launch unique name. PID alone is reused across reboots; xor with
588    // the current nanos timestamp to keep collisions rare even when several
589    // CLI processes spawn back-to-back.
590    let rand_id: u32 = std::process::id()
591        ^ std::time::UNIX_EPOCH
592            .elapsed()
593            .unwrap_or_default()
594            .subsec_nanos();
595    let extension = canonical.extension().and_then(|s| s.to_str()).unwrap_or("");
596    let file_name = if extension.is_empty() {
597        format!("zccache-daemon.{rand_id}")
598    } else {
599        format!("zccache-daemon.{rand_id}.{extension}")
600    };
601    let dest = dir.join(&file_name);
602    std::fs::copy(canonical, &dest)?;
603    Ok(dest)
604}
605
606/// Best-effort delete every entry in [`runtime_binaries_dir`]. On Windows
607/// the kernel refuses to delete a file with an open handle, so files
608/// belonging to a *currently running* daemon are silently skipped — no PID
609/// tracking, no sidecar files. Cheap enough to call before every spawn.
610pub fn gc_runtime_binaries() {
611    gc_runtime_binaries_in(runtime_binaries_dir().as_path());
612}
613
614/// Test seam for [`gc_runtime_binaries`].
615pub fn gc_runtime_binaries_in(dir: &Path) {
616    let entries = match std::fs::read_dir(dir) {
617        Ok(e) => e,
618        Err(_) => return,
619    };
620    for entry in entries.flatten() {
621        let _ = std::fs::remove_file(entry.path());
622    }
623}
624
625/// Subdir of the global cache directory where the daemon writes its own
626/// stdout + stderr on every spawn. Each spawn gets a fresh file named
627/// `daemon-spawn-{pid}-{nanos}.log` so concurrent CLI invocations don't
628/// stomp each other. Errors that hit the daemon before its panic hook or
629/// lifecycle log are alive land here — previously they went to `/dev/null`
630/// on Unix and caused silent failures (notably the macOS regression that
631/// motivated this change).
632const DAEMON_SPAWN_LOGS_SUBDIR: &str = "logs";
633
634/// Allocate a unique per-spawn log path under `{cache_dir}/logs/`.
635/// The directory is created lazily; if creation fails we still hand back a
636/// path — the daemon's own opener will see the error and fall back to
637/// `Stdio::null` after warning.
638fn allocate_daemon_spawn_log_path() -> std::path::PathBuf {
639    let dir = zccache_core::config::default_cache_dir().join(DAEMON_SPAWN_LOGS_SUBDIR);
640    let _ = std::fs::create_dir_all(dir.as_path());
641    let nanos = std::time::SystemTime::now()
642        .duration_since(std::time::UNIX_EPOCH)
643        .map(|d| d.as_nanos() as u64)
644        .unwrap_or(0);
645    dir.as_path()
646        .join(format!("daemon-spawn-{}-{nanos}.log", std::process::id()))
647}
648
649/// Best-effort sweep of `daemon-spawn-*.log` files older than 24h to keep
650/// the logs/ directory from accumulating forever. Cheap to call before each
651/// spawn — matches the existing `gc_runtime_binaries` pattern.
652pub fn gc_daemon_spawn_logs() {
653    let dir = zccache_core::config::default_cache_dir().join(DAEMON_SPAWN_LOGS_SUBDIR);
654    let entries = match std::fs::read_dir(dir.as_path()) {
655        Ok(e) => e,
656        Err(_) => return,
657    };
658    let now = std::time::SystemTime::now();
659    let cutoff = std::time::Duration::from_secs(60 * 60 * 24);
660    for entry in entries.flatten() {
661        let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
662            continue;
663        };
664        if !name.starts_with("daemon-spawn-") {
665            continue;
666        }
667        let modified = entry
668            .metadata()
669            .and_then(|m| m.modified())
670            .ok()
671            .and_then(|t| now.duration_since(t).ok());
672        if let Some(age) = modified {
673            if age > cutoff {
674                let _ = std::fs::remove_file(entry.path());
675            }
676        }
677    }
678}
679
680pub fn spawn_daemon(bin: &Path, endpoint: &str) -> Result<(), String> {
681    // GC before the new spawn so neither dir grows unbounded across
682    // crash-loop scenarios. Live daemons keep their open log file FDs;
683    // GC only touches files older than the 24h cutoff.
684    gc_runtime_binaries();
685    gc_daemon_spawn_logs();
686
687    // Prefer to spawn from a relocated copy in the zccache global dir.
688    // Fall back to the canonical install path if the copy fails — the
689    // daemon's own `unlock_exe()` then handles the in-place rename.
690    let bin_owned: std::path::PathBuf;
691    let spawn_bin: &Path = match prepare_daemon_exe(bin) {
692        Ok(p) => {
693            bin_owned = p;
694            &bin_owned
695        }
696        Err(_) => bin,
697    };
698
699    // Allocate a per-spawn log file path. Passed to the daemon via
700    // `--log-file`; the daemon reopens its own stdout + stderr onto that
701    // path early in startup. This replaces the previous Unix
702    // `Stdio::null()` daemon spawn which made macOS dyld/gatekeeper
703    // failures invisible (see PR #312 for full diagnosis).
704    let log_path = allocate_daemon_spawn_log_path();
705    let log_arg = log_path.to_string_lossy().into_owned();
706
707    // Delegate the actual spawn to `running_process_core::spawn_daemon`
708    // (renamed from `sanitized::spawn` in the 3.2 → 3.3 reshape — same
709    // semantics, lives in the `spawn` module now and is re-exported at
710    // the crate root). That helper handles both platform-specific quirks
711    // the daemon hits:
712    //  • Windows: STARTUPINFOEX + PROC_THREAD_ATTRIBUTE_HANDLE_LIST so
713    //    grandparent pipe handles (e.g. Python's
714    //    `subprocess.Popen(stdout=PIPE)` further up the chain) don't
715    //    leak into the daemon and prevent EOF on the parent's read.
716    //  • Unix: `setsid()` to detach from the controlling tty + close every
717    //    fd > 2 between fork and exec so the same orphan-handle issue
718    //    doesn't bite on macOS in particular.
719    //
720    // `DaemonChild` always opens NUL for its stdio at the spawn site;
721    // the daemon then redirects its own stdout + stderr to `--log-file`
722    // once it's running.
723    let mut cmd = std::process::Command::new(spawn_bin);
724    cmd.args([
725        "--foreground",
726        "--endpoint",
727        endpoint,
728        "--log-file",
729        &log_arg,
730    ]);
731    #[cfg(not(windows))]
732    apply_cli_spawn_lineage(&mut cmd);
733    #[cfg(windows)]
734    {
735        // On Windows the sanitized spawn rebuilds the environment block
736        // itself; pass our lineage overrides via `cmd.env(...)` so they
737        // land in the merged block.
738        for (k, v) in cli_spawn_lineage_env() {
739            cmd.env(k, v);
740        }
741    }
742    running_process_core::spawn_daemon(&mut cmd)
743        .map(|_child| ())
744        .map_err(|e| format!("failed to spawn daemon (sanitized): {e}"))
745}
746
747#[derive(Debug, Clone)]
748pub struct SessionStartResponse {
749    pub session_id: String,
750    pub journal_path: Option<String>,
751}
752
753pub fn client_start(endpoint: Option<&str>) -> Result<(), String> {
754    let endpoint = resolve_endpoint(endpoint);
755    run_async(async move { ensure_daemon(&endpoint).await })
756}
757
758pub fn client_stop(endpoint: Option<&str>) -> Result<bool, String> {
759    let endpoint = resolve_endpoint(endpoint);
760    run_async(async move {
761        let mut conn = match connect_client(&endpoint).await {
762            Ok(c) => c,
763            Err(_) => return Ok(false),
764        };
765        conn.send(&zccache_protocol::Request::Shutdown)
766            .await
767            .map_err(|e| format!("failed to send to daemon: {e}"))?;
768        match conn.recv::<zccache_protocol::Response>().await {
769            Ok(Some(zccache_protocol::Response::ShuttingDown)) => Ok(true),
770            Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
771            Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
772            Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
773            Err(e) => Err(format!("broken connection to daemon: {e}")),
774        }
775    })
776}
777
778pub fn client_status(endpoint: Option<&str>) -> Result<zccache_protocol::DaemonStatus, String> {
779    let endpoint = resolve_endpoint(endpoint);
780    run_async(async move {
781        let mut conn = connect_client(&endpoint)
782            .await
783            .map_err(|e| format!("daemon not running at {endpoint}: {e}"))?;
784        conn.send(&zccache_protocol::Request::Status)
785            .await
786            .map_err(|e| format!("failed to send to daemon: {e}"))?;
787        match conn.recv::<zccache_protocol::Response>().await {
788            Ok(Some(zccache_protocol::Response::Status(status))) => Ok(status),
789            Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
790            Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
791            Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
792            Err(e) => Err(format!("broken connection to daemon: {e}")),
793        }
794    })
795}
796
797pub fn client_session_start(
798    endpoint: Option<&str>,
799    cwd: &Path,
800    log_file: Option<&Path>,
801    track_stats: bool,
802    journal_path: Option<&Path>,
803) -> Result<SessionStartResponse, String> {
804    let endpoint = resolve_endpoint(endpoint);
805    let cwd = cwd.to_path_buf();
806    let log_file = log_file.map(NormalizedPath::from);
807    let journal_path = journal_path.map(NormalizedPath::from);
808
809    run_async(async move {
810        ensure_daemon(&endpoint).await?;
811        let mut conn = connect_client(&endpoint)
812            .await
813            .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
814        conn.send(&zccache_protocol::Request::SessionStart {
815            client_pid: std::process::id(),
816            working_dir: cwd.into(),
817            log_file,
818            track_stats,
819            journal_path,
820        })
821        .await
822        .map_err(|e| format!("failed to send to daemon: {e}"))?;
823
824        match conn.recv::<zccache_protocol::Response>().await {
825            Ok(Some(zccache_protocol::Response::SessionStarted {
826                session_id,
827                journal_path,
828            })) => Ok(SessionStartResponse {
829                session_id,
830                journal_path: journal_path.map(|p| p.display().to_string()),
831            }),
832            Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
833            Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
834            Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
835            Err(e) => Err(format!("broken connection to daemon: {e}")),
836        }
837    })
838}
839
840/// End a session — daemon-unreachable is treated as a successful no-op.
841///
842/// Thin `String`-error wrapper around [`session_end_idempotent`]. All in-process
843/// callers (Python bindings, soldr, future tools) route through here, so the
844/// idempotency contract that #151 / #159 established for the CLI subprocess
845/// path applies equally to library users. Without this, soldr's at-exit
846/// `zccache session-end` from `rust-plan save` fails Windows CI with
847/// "cannot connect to daemon at \\.\pipe\zccache-…" when the daemon already
848/// exited — every workspace test passed but teardown failed.
849pub fn client_session_end(
850    endpoint: Option<&str>,
851    session_id: &str,
852) -> Result<Option<zccache_protocol::SessionStats>, String> {
853    let endpoint = resolve_endpoint(endpoint);
854    session_end_idempotent(&endpoint, session_id).map_err(|e| e.to_string())
855}
856
857/// Is this connect-time error a "daemon process is gone entirely" error?
858///
859/// The conservative set: `NotFound` (Unix socket missing, Windows pipe
860/// missing), `ConnectionRefused` (Unix socket exists but no listener;
861/// Windows backoff helper synthesizes this when all pipe instances are
862/// permanently busy), and `BrokenPipe` (race: pipe vanished between
863/// open and use). Other errors (`TimedOut`, protocol mismatches, etc.)
864/// are NOT daemon-gone — they should still fail loudly.
865///
866/// `IpcError::Timeout` is explicitly **NOT** in the unreachable set. A
867/// timed-out recv means we connected successfully but the peer did not
868/// respond in the configured window — that's either a hung daemon (a
869/// real fault) or a per-call budget that was too tight (caller error).
870/// Either way: propagate, don't silently swallow.
871///
872/// Used by `session_end_idempotent` (issue #159) and the CLI's
873/// `cmd_session_end` (issue #150 / #151) to map "the daemon already
874/// died" connect-time failures onto a success no-op. Other request
875/// types keep their existing strict error semantics.
876#[must_use]
877pub fn is_daemon_unreachable_err(err: &zccache_ipc::IpcError) -> bool {
878    use std::io::ErrorKind;
879    match err {
880        zccache_ipc::IpcError::Io(io) => matches!(
881            io.kind(),
882            ErrorKind::NotFound | ErrorKind::ConnectionRefused | ErrorKind::BrokenPipe
883        ),
884        _ => false,
885    }
886}
887
888/// End a session, treating a vanished daemon as success.
889///
890/// This is the shared library entry point for ending a session. It is
891/// the contract used by the CLI's `zccache session-end <uuid>`
892/// subcommand AND by any in-process caller (e.g. soldr's at-exit
893/// `rust-plan save`) — both must agree on what "the daemon already
894/// died" means.
895///
896/// # Return shape
897///
898/// - `Ok(Some(stats))` — daemon was reached and returned stats for the
899///   session.
900/// - `Ok(None)` — daemon was reached but returned no stats (session
901///   was tracked without stats), OR the daemon was unreachable at
902///   connect time. Both are no-ops from the caller's perspective:
903///   the session is implicitly ended when the daemon dies (see #137
904///   for the daemon-side mirror), and a caller that just wants to
905///   "end the session, don't care if the daemon is still alive"
906///   should treat both as success.
907/// - `Err(IpcError)` — anything else: timeouts, protocol mismatches,
908///   send/recv mid-conversation failures, daemon error responses.
909///   These are real faults and must be surfaced.
910///
911/// # Why a separate function
912///
913/// Issue #159: soldr was failing Windows CI on every main commit
914/// because its in-process session-end (called from `rust-plan save`)
915/// did not share code with `cmd_session_end`, so #151's
916/// connect-failure idempotency only applied to the CLI subprocess
917/// path. Promoting this contract to the library lets all callers —
918/// current and future — share the same behavior.
919pub fn session_end_idempotent(
920    endpoint: &str,
921    session_id: &str,
922) -> Result<Option<zccache_protocol::SessionStats>, zccache_ipc::IpcError> {
923    let endpoint = endpoint.to_string();
924    let session_id = session_id.to_string();
925
926    // Build a dedicated current-thread runtime. Can't use the existing
927    // `run_async` helper because its `Output = Result<T, String>` shape
928    // doesn't compose with our `Result<_, IpcError>` return type.
929    let runtime = tokio::runtime::Builder::new_current_thread()
930        .enable_all()
931        .build()
932        .map_err(|e| {
933            zccache_ipc::IpcError::Endpoint(format!("failed to create tokio runtime: {e}"))
934        })?;
935
936    runtime.block_on(async move {
937        let mut conn = match connect_client(&endpoint).await {
938            Ok(c) => c,
939            Err(e) => {
940                if is_daemon_unreachable_err(&e) {
941                    eprintln!(
942                        "session-end: daemon unreachable at {endpoint}, treating session {session_id} as ended"
943                    );
944                    return Ok(None);
945                }
946                return Err(e);
947            }
948        };
949
950        conn.send(&zccache_protocol::Request::SessionEnd {
951            session_id: session_id.clone(),
952        })
953        .await?;
954
955        match conn.recv::<zccache_protocol::Response>().await? {
956            Some(zccache_protocol::Response::SessionEnded { stats }) => Ok(stats),
957            Some(zccache_protocol::Response::Error { message }) => Err(
958                zccache_ipc::IpcError::Endpoint(format!("session-end failed: {message}")),
959            ),
960            None => Err(zccache_ipc::IpcError::ConnectionClosed),
961            Some(other) => Err(zccache_ipc::IpcError::Endpoint(format!(
962                "unexpected response from daemon: {other:?}"
963            ))),
964        }
965    })
966}
967
968pub fn client_session_stats(
969    endpoint: Option<&str>,
970    session_id: &str,
971) -> Result<Option<zccache_protocol::SessionStats>, String> {
972    let endpoint = resolve_endpoint(endpoint);
973    let session_id = session_id.to_string();
974    run_async(async move {
975        let mut conn = connect_client(&endpoint)
976            .await
977            .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
978        conn.send(&zccache_protocol::Request::SessionStats {
979            session_id: session_id.clone(),
980        })
981        .await
982        .map_err(|e| format!("failed to send to daemon: {e}"))?;
983
984        match conn.recv::<zccache_protocol::Response>().await {
985            Ok(Some(zccache_protocol::Response::SessionStatsResult { stats })) => Ok(stats),
986            Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
987            Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
988            Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
989            Err(e) => Err(format!("broken connection to daemon: {e}")),
990        }
991    })
992}
993
994#[derive(Debug, Clone)]
995pub struct FingerprintCheckResponse {
996    pub decision: String,
997    pub reason: Option<String>,
998    pub changed_files: Vec<String>,
999}
1000
1001pub fn fingerprint_check(
1002    endpoint: Option<&str>,
1003    cache_file: &Path,
1004    cache_type: &str,
1005    root: &Path,
1006    extensions: &[String],
1007    include_globs: &[String],
1008    exclude: &[String],
1009) -> Result<FingerprintCheckResponse, String> {
1010    let endpoint = resolve_endpoint(endpoint);
1011    let cache_file = cache_file.to_path_buf();
1012    let cache_type = cache_type.to_string();
1013    let root = root.to_path_buf();
1014    let extensions = extensions.to_vec();
1015    let include_globs = include_globs.to_vec();
1016    let exclude = exclude.to_vec();
1017
1018    run_async(async move {
1019        ensure_daemon(&endpoint).await?;
1020        let mut conn = connect_client(&endpoint)
1021            .await
1022            .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
1023
1024        conn.send(&zccache_protocol::Request::FingerprintCheck {
1025            cache_file: cache_file.into(),
1026            cache_type,
1027            root: root.into(),
1028            extensions,
1029            include_globs,
1030            exclude,
1031        })
1032        .await
1033        .map_err(|e| format!("failed to send to daemon: {e}"))?;
1034
1035        match conn.recv::<zccache_protocol::Response>().await {
1036            Ok(Some(zccache_protocol::Response::FingerprintCheckResult {
1037                decision,
1038                reason,
1039                changed_files,
1040            })) => Ok(FingerprintCheckResponse {
1041                decision,
1042                reason,
1043                changed_files,
1044            }),
1045            Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
1046            Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
1047            Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
1048            Err(e) => Err(format!("broken connection to daemon: {e}")),
1049        }
1050    })
1051}
1052
1053pub fn fingerprint_mark_success(endpoint: Option<&str>, cache_file: &Path) -> Result<(), String> {
1054    fingerprint_mark(endpoint, cache_file, true)
1055}
1056
1057pub fn fingerprint_mark_failure(endpoint: Option<&str>, cache_file: &Path) -> Result<(), String> {
1058    fingerprint_mark(endpoint, cache_file, false)
1059}
1060
1061fn fingerprint_mark(
1062    endpoint: Option<&str>,
1063    cache_file: &Path,
1064    success: bool,
1065) -> Result<(), String> {
1066    let endpoint = resolve_endpoint(endpoint);
1067    let cache_file = cache_file.to_path_buf();
1068    run_async(async move {
1069        ensure_daemon(&endpoint).await?;
1070        let mut conn = connect_client(&endpoint)
1071            .await
1072            .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
1073        let request = if success {
1074            zccache_protocol::Request::FingerprintMarkSuccess {
1075                cache_file: cache_file.into(),
1076            }
1077        } else {
1078            zccache_protocol::Request::FingerprintMarkFailure {
1079                cache_file: cache_file.into(),
1080            }
1081        };
1082        conn.send(&request)
1083            .await
1084            .map_err(|e| format!("failed to send to daemon: {e}"))?;
1085        match conn.recv::<zccache_protocol::Response>().await {
1086            Ok(Some(zccache_protocol::Response::FingerprintAck)) => Ok(()),
1087            Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
1088            Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
1089            Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
1090            Err(e) => Err(format!("broken connection to daemon: {e}")),
1091        }
1092    })
1093}
1094
1095pub fn fingerprint_invalidate(endpoint: Option<&str>, cache_file: &Path) -> Result<(), String> {
1096    let endpoint = resolve_endpoint(endpoint);
1097    let cache_file = cache_file.to_path_buf();
1098    run_async(async move {
1099        ensure_daemon(&endpoint).await?;
1100        let mut conn = connect_client(&endpoint)
1101            .await
1102            .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
1103        conn.send(&zccache_protocol::Request::FingerprintInvalidate {
1104            cache_file: cache_file.into(),
1105        })
1106        .await
1107        .map_err(|e| format!("failed to send to daemon: {e}"))?;
1108        match conn.recv::<zccache_protocol::Response>().await {
1109            Ok(Some(zccache_protocol::Response::FingerprintAck)) => Ok(()),
1110            Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
1111            Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
1112            Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
1113            Err(e) => Err(format!("broken connection to daemon: {e}")),
1114        }
1115    })
1116}
1117
1118#[cfg(test)]
1119mod tests {
1120    use super::*;
1121
1122    #[test]
1123    fn infer_download_path_keeps_url_filename() {
1124        let path = infer_download_archive_path(
1125            &DownloadSource::Url("https://example.com/releases/toolchain.tar.gz?download=1".into()),
1126            ArchiveFormat::Auto,
1127        );
1128        let file_name = path.file_name().unwrap().to_string_lossy();
1129        assert!(file_name.ends_with("-toolchain.tar.gz"));
1130    }
1131
1132    #[test]
1133    fn infer_download_path_uses_archive_format_suffix_when_needed() {
1134        let path = infer_download_archive_path(
1135            &DownloadSource::Url("https://example.com/download".into()),
1136            ArchiveFormat::Zip,
1137        );
1138        let file_name = path.file_name().unwrap().to_string_lossy();
1139        assert!(file_name.ends_with(".zip"));
1140    }
1141
1142    #[test]
1143    fn build_download_request_derives_archive_path_when_missing() {
1144        let request = build_download_request(DownloadParams::new("https://example.com/file.zip"));
1145        let file_name = request
1146            .destination_path
1147            .file_name()
1148            .unwrap()
1149            .to_string_lossy();
1150        assert!(file_name.ends_with("-file.zip"));
1151    }
1152
1153    #[test]
1154    fn infer_download_path_strips_multipart_suffix_from_first_part() {
1155        let path = infer_download_archive_path(
1156            &DownloadSource::MultipartUrls(vec![
1157                "https://example.com/toolchain.tar.zst.part-aa".into(),
1158                "https://example.com/toolchain.tar.zst.part-ab".into(),
1159            ]),
1160            ArchiveFormat::Auto,
1161        );
1162        let file_name = path.file_name().unwrap().to_string_lossy();
1163        assert!(file_name.ends_with("-toolchain.tar.zst"));
1164    }
1165
1166    #[test]
1167    fn prepare_daemon_exe_in_copies_to_target_dir() {
1168        let tmp = tempfile::tempdir().expect("create tempdir");
1169        let src = tmp.path().join("zccache-daemon.exe");
1170        std::fs::write(&src, b"fake-daemon-bytes").expect("write source");
1171
1172        let dest_dir = tmp.path().join("runtime-binaries");
1173        let copied =
1174            prepare_daemon_exe_in(&src, &dest_dir).expect("prepare_daemon_exe_in succeeds");
1175
1176        assert!(
1177            copied.is_file(),
1178            "copy at {} should exist",
1179            copied.display()
1180        );
1181        assert_eq!(
1182            copied.parent().unwrap(),
1183            dest_dir,
1184            "copy should land inside dest_dir"
1185        );
1186        assert!(
1187            copied
1188                .file_name()
1189                .unwrap()
1190                .to_string_lossy()
1191                .starts_with("zccache-daemon."),
1192            "filename should start with zccache-daemon., got {}",
1193            copied.display()
1194        );
1195        assert!(
1196            copied.extension().and_then(|s| s.to_str()) == Some("exe"),
1197            "extension should be preserved"
1198        );
1199        assert_eq!(
1200            std::fs::read(&copied).unwrap(),
1201            b"fake-daemon-bytes",
1202            "copy contents should match source"
1203        );
1204    }
1205
1206    #[test]
1207    fn prepare_daemon_exe_in_creates_missing_dest_dir() {
1208        let tmp = tempfile::tempdir().expect("create tempdir");
1209        let src = tmp.path().join("zccache-daemon");
1210        std::fs::write(&src, b"x").expect("write source");
1211
1212        let dest_dir = tmp.path().join("nested").join("runtime-binaries");
1213        assert!(!dest_dir.exists(), "precondition: dest_dir does not exist");
1214
1215        let copied = prepare_daemon_exe_in(&src, &dest_dir).expect("create + copy");
1216        assert!(dest_dir.is_dir(), "dest_dir should now exist");
1217        assert!(copied.is_file());
1218    }
1219
1220    #[test]
1221    fn gc_runtime_binaries_in_removes_unlocked_entries() {
1222        let tmp = tempfile::tempdir().expect("create tempdir");
1223        let dir = tmp.path().join("runtime-binaries");
1224        std::fs::create_dir_all(&dir).expect("create dir");
1225
1226        let a = dir.join("zccache-daemon.111.exe");
1227        let b = dir.join("zccache-daemon.222.exe");
1228        std::fs::write(&a, b"a").unwrap();
1229        std::fs::write(&b, b"b").unwrap();
1230
1231        gc_runtime_binaries_in(&dir);
1232
1233        assert!(!a.exists(), "{} should be GC'd", a.display());
1234        assert!(!b.exists(), "{} should be GC'd", b.display());
1235        assert!(dir.is_dir(), "directory itself remains");
1236    }
1237
1238    #[test]
1239    fn gc_runtime_binaries_in_is_noop_for_missing_dir() {
1240        let tmp = tempfile::tempdir().expect("create tempdir");
1241        let dir = tmp.path().join("does-not-exist");
1242        gc_runtime_binaries_in(&dir);
1243    }
1244
1245    /// Issue #159: `session_end_idempotent` is the shared library entry
1246    /// point for ending a session — used by the CLI `session-end` command
1247    /// AND by tools like soldr that call into the library directly. When
1248    /// the daemon process is gone (pipe / socket missing), this function
1249    /// must return `Ok(None)` rather than propagating the connect-time
1250    /// I/O error. Soldr's at-exit `rust-plan save` previously failed
1251    /// Windows CI because its in-process session-end did NOT go through
1252    /// `cmd_session_end` (which is gated to the CLI subprocess path) and
1253    /// so the #151 idempotency fix didn't apply.
1254    #[test]
1255    fn session_end_idempotent_swallows_vanished_daemon() {
1256        // Construct an endpoint that is guaranteed to have no listener —
1257        // a unique pipe / socket name with no server bound to it.
1258        let endpoint = zccache_ipc::unique_test_endpoint();
1259        let session_id = "00000000-0000-0000-0000-000000000000";
1260
1261        let result = session_end_idempotent(&endpoint, session_id);
1262
1263        assert!(
1264            matches!(result, Ok(None)),
1265            "vanished daemon must produce Ok(None) (success no-op), got {result:?}"
1266        );
1267    }
1268
1269    /// Control: non-unreachable errors (the function shouldn't be a
1270    /// blanket "ignore everything"). We can't easily synthesize a live
1271    /// daemon error here, but we can at least assert the routing via the
1272    /// helper used inside the function: connect-time `TimedOut` must NOT
1273    /// be classified as unreachable, so the function would propagate it
1274    /// (rather than silently return Ok(None)). This guards against a
1275    /// regression where someone widens the unreachable set to "any I/O
1276    /// error".
1277    #[test]
1278    fn session_end_idempotent_treats_timeout_as_real_error() {
1279        let err = zccache_ipc::IpcError::Io(std::io::Error::from(std::io::ErrorKind::TimedOut));
1280        assert!(
1281            !is_daemon_unreachable_err(&err),
1282            "TimedOut must NOT be classified as daemon-unreachable; session_end_idempotent \
1283             would otherwise silently swallow real timeouts"
1284        );
1285    }
1286
1287    /// Control: protocol-layer errors (malformed framing, closed
1288    /// connection mid-response) must NOT be classified as unreachable.
1289    #[test]
1290    fn session_end_idempotent_treats_protocol_errors_as_real() {
1291        let err = zccache_ipc::IpcError::ConnectionClosed;
1292        assert!(!is_daemon_unreachable_err(&err));
1293        let err = zccache_ipc::IpcError::Endpoint("bogus".into());
1294        assert!(!is_daemon_unreachable_err(&err));
1295    }
1296
1297    /// Issue #150: connect-time errors that mean "daemon process is gone
1298    /// entirely" must be classified as unreachable so the idempotent
1299    /// session-end paths (`session_end_idempotent` + the CLI's
1300    /// `cmd_session_end` wrapper) can fall through to the success path.
1301    /// The set covers every shape `connect()` actually returns when the
1302    /// pipe / socket is missing or has no listener.
1303    #[test]
1304    fn is_daemon_unreachable_recognizes_not_found() {
1305        let err = zccache_ipc::IpcError::Io(std::io::Error::from(std::io::ErrorKind::NotFound));
1306        assert!(is_daemon_unreachable_err(&err));
1307    }
1308
1309    #[test]
1310    fn is_daemon_unreachable_recognizes_connection_refused() {
1311        let err =
1312            zccache_ipc::IpcError::Io(std::io::Error::from(std::io::ErrorKind::ConnectionRefused));
1313        assert!(is_daemon_unreachable_err(&err));
1314    }
1315
1316    #[test]
1317    fn is_daemon_unreachable_recognizes_broken_pipe() {
1318        let err = zccache_ipc::IpcError::Io(std::io::Error::from(std::io::ErrorKind::BrokenPipe));
1319        assert!(is_daemon_unreachable_err(&err));
1320    }
1321
1322    /// `IpcError::Timeout` is explicitly NOT daemon-unreachable. A
1323    /// timed-out recv means we connected successfully but the peer did
1324    /// not respond — that's a hung-daemon fault, not a vanished daemon.
1325    /// Soldr's at-exit `session_end` path classifies vanished-daemon as
1326    /// a no-op; if `Timeout` were misclassified here, a stuck daemon
1327    /// would be silently swallowed and the user would never see it.
1328    #[test]
1329    fn is_daemon_unreachable_timeout_is_not_unreachable() {
1330        let err = zccache_ipc::IpcError::Timeout(std::time::Duration::from_secs(5));
1331        assert!(
1332            !is_daemon_unreachable_err(&err),
1333            "Timeout must propagate as a real fault, not be swallowed as daemon-unreachable"
1334        );
1335    }
1336
1337    /// Mapping ENOENT through `from_raw_os_error` must yield the same
1338    /// classification as constructing from `ErrorKind::NotFound`. This
1339    /// guards against platform variance (macOS / Linux / Windows could
1340    /// in principle synthesize a different kind for the same errno).
1341    #[test]
1342    fn is_daemon_unreachable_recognizes_raw_enoent() {
1343        // ENOENT == 2 on every Unix; on Windows ERROR_FILE_NOT_FOUND == 2 too.
1344        let err = zccache_ipc::IpcError::Io(std::io::Error::from_raw_os_error(2));
1345        assert!(
1346            is_daemon_unreachable_err(&err),
1347            "errno 2 must map to a kind in the unreachable set; got kind={:?}",
1348            match &err {
1349                zccache_ipc::IpcError::Io(io) => io.kind(),
1350                _ => unreachable!(),
1351            }
1352        );
1353    }
1354
1355    /// Regression: `client_session_end` is the in-process library entry point
1356    /// used by Python bindings and external tools (soldr's `rust-plan save`).
1357    /// It must mirror `session_end_idempotent` — a vanished daemon is a no-op
1358    /// success, not a hard error. Before this fix, soldr called
1359    /// `client_session_end`, got `Err("cannot connect to daemon at …")`,
1360    /// surfaced it as "soldr: zccache session-end … failed: …", and Windows
1361    /// Test failed teardown even after every workspace test passed.
1362    #[test]
1363    fn client_session_end_swallows_vanished_daemon() {
1364        let endpoint = zccache_ipc::unique_test_endpoint();
1365        let session_id = "00000000-0000-0000-0000-000000000000";
1366
1367        let result = client_session_end(Some(&endpoint), session_id);
1368
1369        assert!(
1370            matches!(result, Ok(None)),
1371            "vanished daemon must produce Ok(None) (success no-op), got {result:?}"
1372        );
1373    }
1374}