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    zccache_ipc::connect(endpoint).await
337}
338
339#[cfg(windows)]
340async fn connect_client(
341    endpoint: &str,
342) -> Result<zccache_ipc::IpcClientConnection, zccache_ipc::IpcError> {
343    zccache_ipc::connect(endpoint).await
344}
345
346async fn check_daemon_version(endpoint: &str) -> VersionCheck {
347    let mut conn = match connect_client(endpoint).await {
348        Ok(c) => c,
349        Err(_) => return VersionCheck::Unreachable,
350    };
351    if conn.send(&zccache_protocol::Request::Status).await.is_err() {
352        return VersionCheck::CommError;
353    }
354    match conn.recv::<zccache_protocol::Response>().await {
355        Ok(Some(zccache_protocol::Response::Status(s))) => {
356            if s.version == zccache_core::VERSION {
357                return VersionCheck::Ok;
358            }
359            let client_ver = zccache_core::version::current();
360            match zccache_core::version::Version::parse(&s.version) {
361                Some(daemon_ver) => match daemon_ver.cmp(&client_ver) {
362                    std::cmp::Ordering::Equal => VersionCheck::Ok,
363                    std::cmp::Ordering::Greater => VersionCheck::DaemonNewer,
364                    std::cmp::Ordering::Less => VersionCheck::DaemonOlder {
365                        daemon_ver: s.version,
366                    },
367                },
368                None => VersionCheck::DaemonOlder {
369                    daemon_ver: s.version,
370                },
371            }
372        }
373        _ => VersionCheck::CommError,
374    }
375}
376
377async fn spawn_and_wait(endpoint: &str) -> Result<(), String> {
378    let daemon_bin = find_daemon_binary().ok_or("cannot find zccache-daemon binary")?;
379    spawn_daemon(&daemon_bin, endpoint)?;
380
381    for _ in 0..100 {
382        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
383        if connect_client(endpoint).await.is_ok() {
384            return Ok(());
385        }
386    }
387    Err("daemon started but not accepting connections after 10s".to_string())
388}
389
390/// Stop a stale daemon that is unreachable or version-incompatible.
391async fn stop_stale_daemon(endpoint: &str) {
392    if let Ok(mut conn) = connect_client(endpoint).await {
393        let _ = conn.send(&zccache_protocol::Request::Shutdown).await;
394        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
395    }
396
397    if let Some(pid) = zccache_ipc::check_running_daemon() {
398        if zccache_ipc::force_kill_process(pid).is_ok() {
399            for _ in 0..50 {
400                if !zccache_ipc::is_process_alive(pid) {
401                    break;
402                }
403                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
404            }
405        }
406        zccache_ipc::remove_lock_file();
407    }
408
409    tokio::time::sleep(std::time::Duration::from_millis(200)).await;
410}
411
412async fn ensure_daemon(endpoint: &str) -> Result<(), String> {
413    match check_daemon_version(endpoint).await {
414        VersionCheck::Ok | VersionCheck::DaemonNewer => return Ok(()),
415        VersionCheck::DaemonOlder { daemon_ver } => {
416            tracing::info!(
417                daemon_ver,
418                client_ver = zccache_core::VERSION,
419                "daemon is older than client, auto-recovering"
420            );
421            stop_stale_daemon(endpoint).await;
422            return spawn_and_wait(endpoint).await;
423        }
424        VersionCheck::CommError => {
425            tracing::info!("cannot communicate with daemon, auto-recovering");
426            stop_stale_daemon(endpoint).await;
427            return spawn_and_wait(endpoint).await;
428        }
429        VersionCheck::Unreachable => {}
430    }
431
432    if let Some(pid) = zccache_ipc::check_running_daemon() {
433        let mut backoff = std::time::Duration::from_millis(100);
434        for _ in 0..20 {
435            tokio::time::sleep(backoff).await;
436            backoff = (backoff * 2).min(std::time::Duration::from_millis(500));
437            match check_daemon_version(endpoint).await {
438                VersionCheck::Ok | VersionCheck::DaemonNewer => return Ok(()),
439                VersionCheck::DaemonOlder { daemon_ver } => {
440                    tracing::info!(
441                        daemon_ver,
442                        client_ver = zccache_core::VERSION,
443                        "daemon is older than client during startup, auto-recovering"
444                    );
445                    stop_stale_daemon(endpoint).await;
446                    return spawn_and_wait(endpoint).await;
447                }
448                VersionCheck::CommError => {
449                    stop_stale_daemon(endpoint).await;
450                    return spawn_and_wait(endpoint).await;
451                }
452                VersionCheck::Unreachable => continue,
453            }
454        }
455        return Err(format!(
456            "daemon process {pid} exists but not accepting connections after retrying"
457        ));
458    }
459
460    spawn_and_wait(endpoint).await
461}
462
463fn find_daemon_binary() -> Option<NormalizedPath> {
464    let name = if cfg!(windows) {
465        "zccache-daemon.exe"
466    } else {
467        "zccache-daemon"
468    };
469
470    if let Ok(exe) = std::env::current_exe() {
471        if let Some(dir) = exe.parent() {
472            let candidate = dir.join(name);
473            if candidate.exists() {
474                return Some(candidate.into());
475            }
476        }
477    }
478
479    which_on_path(name)
480}
481
482fn which_on_path(name: &str) -> Option<NormalizedPath> {
483    let path_var = std::env::var_os("PATH")?;
484    for dir in std::env::split_paths(&path_var) {
485        let candidate = dir.join(name);
486        if candidate.is_file() {
487            return Some(candidate.into());
488        }
489        #[cfg(windows)]
490        if Path::new(name).extension().is_none() {
491            let with_exe = dir.join(format!("{name}.exe"));
492            if with_exe.is_file() {
493                return Some(with_exe.into());
494            }
495        }
496    }
497    None
498}
499
500/// Initialize spawn-lineage env vars on a command the CLI is about to spawn.
501///
502/// Mirrors the daemon-side propagation in `zccache_daemon::lineage` so that
503/// any process attribution (orphan tracking, running-process scanners) sees
504/// a consistent chain across CLI -> daemon -> compiler hops. The chain is
505/// initialized with the CLI's PID, and the originator marker (used by
506/// running-process for crash-resilient orphan discovery) is set to
507/// `zccache-cli:<pid>` unless an outer tool has already claimed it.
508#[cfg(not(windows))]
509fn apply_cli_spawn_lineage(cmd: &mut std::process::Command) {
510    for (k, v) in cli_spawn_lineage_env() {
511        cmd.env(k, v);
512    }
513}
514
515/// Compute the lineage env-var pairs the CLI sets on the daemon it
516/// spawns. Returns the same overrides `apply_cli_spawn_lineage` writes
517/// onto a `Command`, in a form usable by the Windows raw-spawn path
518/// (which needs to build its own merged environment block).
519fn cli_spawn_lineage_env() -> Vec<(String, String)> {
520    const ENV_ORIGINATOR: &str = "RUNNING_PROCESS_ORIGINATOR";
521    const ENV_LINEAGE: &str = "ZCCACHE_LINEAGE";
522    const ENV_PARENT_PID: &str = "ZCCACHE_PARENT_PID";
523    const ENV_CLIENT_PID: &str = "ZCCACHE_CLIENT_PID";
524
525    let cli_pid = std::process::id();
526    let mut out: Vec<(String, String)> = Vec::with_capacity(4);
527
528    // Preserve any outer originator (e.g. the build tool was already wrapped
529    // by running-process). Otherwise, claim the originator slot ourselves.
530    if std::env::var(ENV_ORIGINATOR).is_err() {
531        out.push((ENV_ORIGINATOR.to_string(), format!("zccache-cli:{cli_pid}")));
532    }
533
534    // Extend or initialize the chain with our PID.
535    let chain = match std::env::var(ENV_LINEAGE) {
536        Ok(existing)
537            if existing
538                .rsplit_once('>')
539                .map_or(existing.as_str(), |(_, last)| last)
540                != cli_pid.to_string() =>
541        {
542            format!("{existing}>{cli_pid}")
543        }
544        Ok(existing) => existing,
545        Err(_) => cli_pid.to_string(),
546    };
547    out.push((ENV_LINEAGE.to_string(), chain));
548    out.push((ENV_PARENT_PID.to_string(), cli_pid.to_string()));
549    out.push((ENV_CLIENT_PID.to_string(), cli_pid.to_string()));
550    out
551}
552
553/// Subdir of the zccache global cache directory where the CLI stores
554/// per-launch copies of the daemon binary. The daemon runs from one of
555/// these copies, never from the install path (e.g. `Scripts/zccache-daemon.exe`),
556/// so `pip install --upgrade zccache` can always overwrite the install
557/// path regardless of whether a daemon is alive. See issue #134.
558const RUNTIME_BINARIES_SUBDIR: &str = "runtime-binaries";
559
560/// Returns `<global_cache_dir>/runtime-binaries`.
561#[must_use]
562pub fn runtime_binaries_dir() -> NormalizedPath {
563    zccache_core::config::default_cache_dir().join(RUNTIME_BINARIES_SUBDIR)
564}
565
566/// Copy `canonical` (the daemon binary at its install location) to a unique
567/// path inside [`runtime_binaries_dir`] and return the new path. The caller
568/// then spawns from the returned path so the install location is never
569/// file-locked by a running daemon.
570///
571/// On copy failure the caller should fall back to spawning `canonical`
572/// directly; the in-place `unlock_exe()` in the daemon then handles the
573/// lock removal as a fallback.
574pub fn prepare_daemon_exe(canonical: &Path) -> Result<std::path::PathBuf, std::io::Error> {
575    prepare_daemon_exe_in(canonical, runtime_binaries_dir().as_path())
576}
577
578/// Test seam for [`prepare_daemon_exe`]: copies `canonical` into `dir`
579/// (which is created if missing) and returns the destination path.
580pub fn prepare_daemon_exe_in(
581    canonical: &Path,
582    dir: &Path,
583) -> Result<std::path::PathBuf, std::io::Error> {
584    std::fs::create_dir_all(dir)?;
585
586    // Per-launch unique name. PID alone is reused across reboots; xor with
587    // the current nanos timestamp to keep collisions rare even when several
588    // CLI processes spawn back-to-back.
589    let rand_id: u32 = std::process::id()
590        ^ std::time::UNIX_EPOCH
591            .elapsed()
592            .unwrap_or_default()
593            .subsec_nanos();
594    let extension = canonical.extension().and_then(|s| s.to_str()).unwrap_or("");
595    let file_name = if extension.is_empty() {
596        format!("zccache-daemon.{rand_id}")
597    } else {
598        format!("zccache-daemon.{rand_id}.{extension}")
599    };
600    let dest = dir.join(&file_name);
601    std::fs::copy(canonical, &dest)?;
602    Ok(dest)
603}
604
605/// Best-effort delete every entry in [`runtime_binaries_dir`]. On Windows
606/// the kernel refuses to delete a file with an open handle, so files
607/// belonging to a *currently running* daemon are silently skipped — no PID
608/// tracking, no sidecar files. Cheap enough to call before every spawn.
609pub fn gc_runtime_binaries() {
610    gc_runtime_binaries_in(runtime_binaries_dir().as_path());
611}
612
613/// Test seam for [`gc_runtime_binaries`].
614pub fn gc_runtime_binaries_in(dir: &Path) {
615    let entries = match std::fs::read_dir(dir) {
616        Ok(e) => e,
617        Err(_) => return,
618    };
619    for entry in entries.flatten() {
620        let _ = std::fs::remove_file(entry.path());
621    }
622}
623
624pub fn spawn_daemon(bin: &Path, endpoint: &str) -> Result<(), String> {
625    // GC before the new spawn so the dir doesn't grow unbounded across
626    // crash-loop scenarios. Live daemons are skipped (locked files).
627    gc_runtime_binaries();
628
629    // Prefer to spawn from a relocated copy in the zccache global dir.
630    // Fall back to the canonical install path if the copy fails — the
631    // daemon's own `unlock_exe()` then handles the in-place rename.
632    let bin_owned: std::path::PathBuf;
633    let spawn_bin: &Path = match prepare_daemon_exe(bin) {
634        Ok(p) => {
635            bin_owned = p;
636            &bin_owned
637        }
638        Err(_) => bin,
639    };
640
641    // On Windows we cannot use std::process::Command::spawn for the
642    // daemon: it calls CreateProcessW with bInheritHandles = TRUE and
643    // the kernel duplicates every inheritable handle in our table —
644    // including orphaned pipe handles inherited from a grandparent
645    // (e.g. Python's `subprocess.Popen(stdout=PIPE)`). The daemon would
646    // then hold that pipe open and the grandparent's read would never
647    // EOF. See `spawn_daemon_windows` and issue #289 for the full
648    // root-cause analysis.
649    #[cfg(windows)]
650    {
651        return spawn_daemon_windows::spawn_daemon_sanitized(
652            spawn_bin,
653            &["--foreground", "--endpoint", endpoint],
654            &cli_spawn_lineage_env(),
655        );
656    }
657
658    #[cfg(not(windows))]
659    {
660        let mut cmd = std::process::Command::new(spawn_bin);
661        cmd.args(["--foreground", "--endpoint", endpoint]);
662        cmd.stdin(std::process::Stdio::null());
663        cmd.stdout(std::process::Stdio::null());
664        cmd.stderr(std::process::Stdio::null());
665        apply_cli_spawn_lineage(&mut cmd);
666        cmd.spawn()
667            .map_err(|e| format!("failed to spawn daemon: {e}"))?;
668        Ok(())
669    }
670}
671
672#[derive(Debug, Clone)]
673pub struct SessionStartResponse {
674    pub session_id: String,
675    pub journal_path: Option<String>,
676}
677
678pub fn client_start(endpoint: Option<&str>) -> Result<(), String> {
679    let endpoint = resolve_endpoint(endpoint);
680    run_async(async move { ensure_daemon(&endpoint).await })
681}
682
683pub fn client_stop(endpoint: Option<&str>) -> Result<bool, String> {
684    let endpoint = resolve_endpoint(endpoint);
685    run_async(async move {
686        let mut conn = match connect_client(&endpoint).await {
687            Ok(c) => c,
688            Err(_) => return Ok(false),
689        };
690        conn.send(&zccache_protocol::Request::Shutdown)
691            .await
692            .map_err(|e| format!("failed to send to daemon: {e}"))?;
693        match conn.recv::<zccache_protocol::Response>().await {
694            Ok(Some(zccache_protocol::Response::ShuttingDown)) => Ok(true),
695            Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
696            Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
697            Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
698            Err(e) => Err(format!("broken connection to daemon: {e}")),
699        }
700    })
701}
702
703pub fn client_status(endpoint: Option<&str>) -> Result<zccache_protocol::DaemonStatus, String> {
704    let endpoint = resolve_endpoint(endpoint);
705    run_async(async move {
706        let mut conn = connect_client(&endpoint)
707            .await
708            .map_err(|e| format!("daemon not running at {endpoint}: {e}"))?;
709        conn.send(&zccache_protocol::Request::Status)
710            .await
711            .map_err(|e| format!("failed to send to daemon: {e}"))?;
712        match conn.recv::<zccache_protocol::Response>().await {
713            Ok(Some(zccache_protocol::Response::Status(status))) => Ok(status),
714            Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
715            Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
716            Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
717            Err(e) => Err(format!("broken connection to daemon: {e}")),
718        }
719    })
720}
721
722pub fn client_session_start(
723    endpoint: Option<&str>,
724    cwd: &Path,
725    log_file: Option<&Path>,
726    track_stats: bool,
727    journal_path: Option<&Path>,
728) -> Result<SessionStartResponse, String> {
729    let endpoint = resolve_endpoint(endpoint);
730    let cwd = cwd.to_path_buf();
731    let log_file = log_file.map(NormalizedPath::from);
732    let journal_path = journal_path.map(NormalizedPath::from);
733
734    run_async(async move {
735        ensure_daemon(&endpoint).await?;
736        let mut conn = connect_client(&endpoint)
737            .await
738            .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
739        conn.send(&zccache_protocol::Request::SessionStart {
740            client_pid: std::process::id(),
741            working_dir: cwd.into(),
742            log_file,
743            track_stats,
744            journal_path,
745        })
746        .await
747        .map_err(|e| format!("failed to send to daemon: {e}"))?;
748
749        match conn.recv::<zccache_protocol::Response>().await {
750            Ok(Some(zccache_protocol::Response::SessionStarted {
751                session_id,
752                journal_path,
753            })) => Ok(SessionStartResponse {
754                session_id,
755                journal_path: journal_path.map(|p| p.display().to_string()),
756            }),
757            Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
758            Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
759            Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
760            Err(e) => Err(format!("broken connection to daemon: {e}")),
761        }
762    })
763}
764
765/// End a session — daemon-unreachable is treated as a successful no-op.
766///
767/// Thin `String`-error wrapper around [`session_end_idempotent`]. All in-process
768/// callers (Python bindings, soldr, future tools) route through here, so the
769/// idempotency contract that #151 / #159 established for the CLI subprocess
770/// path applies equally to library users. Without this, soldr's at-exit
771/// `zccache session-end` from `rust-plan save` fails Windows CI with
772/// "cannot connect to daemon at \\.\pipe\zccache-…" when the daemon already
773/// exited — every workspace test passed but teardown failed.
774pub fn client_session_end(
775    endpoint: Option<&str>,
776    session_id: &str,
777) -> Result<Option<zccache_protocol::SessionStats>, String> {
778    let endpoint = resolve_endpoint(endpoint);
779    session_end_idempotent(&endpoint, session_id).map_err(|e| e.to_string())
780}
781
782/// Is this connect-time error a "daemon process is gone entirely" error?
783///
784/// The conservative set: `NotFound` (Unix socket missing, Windows pipe
785/// missing), `ConnectionRefused` (Unix socket exists but no listener;
786/// Windows backoff helper synthesizes this when all pipe instances are
787/// permanently busy), and `BrokenPipe` (race: pipe vanished between
788/// open and use). Other errors (`TimedOut`, protocol mismatches, etc.)
789/// are NOT daemon-gone — they should still fail loudly.
790///
791/// Used by `session_end_idempotent` (issue #159) and the CLI's
792/// `cmd_session_end` (issue #150 / #151) to map "the daemon already
793/// died" connect-time failures onto a success no-op. Other request
794/// types keep their existing strict error semantics.
795#[must_use]
796pub fn is_daemon_unreachable_err(err: &zccache_ipc::IpcError) -> bool {
797    use std::io::ErrorKind;
798    match err {
799        zccache_ipc::IpcError::Io(io) => matches!(
800            io.kind(),
801            ErrorKind::NotFound | ErrorKind::ConnectionRefused | ErrorKind::BrokenPipe
802        ),
803        _ => false,
804    }
805}
806
807/// End a session, treating a vanished daemon as success.
808///
809/// This is the shared library entry point for ending a session. It is
810/// the contract used by the CLI's `zccache session-end <uuid>`
811/// subcommand AND by any in-process caller (e.g. soldr's at-exit
812/// `rust-plan save`) — both must agree on what "the daemon already
813/// died" means.
814///
815/// # Return shape
816///
817/// - `Ok(Some(stats))` — daemon was reached and returned stats for the
818///   session.
819/// - `Ok(None)` — daemon was reached but returned no stats (session
820///   was tracked without stats), OR the daemon was unreachable at
821///   connect time. Both are no-ops from the caller's perspective:
822///   the session is implicitly ended when the daemon dies (see #137
823///   for the daemon-side mirror), and a caller that just wants to
824///   "end the session, don't care if the daemon is still alive"
825///   should treat both as success.
826/// - `Err(IpcError)` — anything else: timeouts, protocol mismatches,
827///   send/recv mid-conversation failures, daemon error responses.
828///   These are real faults and must be surfaced.
829///
830/// # Why a separate function
831///
832/// Issue #159: soldr was failing Windows CI on every main commit
833/// because its in-process session-end (called from `rust-plan save`)
834/// did not share code with `cmd_session_end`, so #151's
835/// connect-failure idempotency only applied to the CLI subprocess
836/// path. Promoting this contract to the library lets all callers —
837/// current and future — share the same behavior.
838pub fn session_end_idempotent(
839    endpoint: &str,
840    session_id: &str,
841) -> Result<Option<zccache_protocol::SessionStats>, zccache_ipc::IpcError> {
842    let endpoint = endpoint.to_string();
843    let session_id = session_id.to_string();
844
845    // Build a dedicated current-thread runtime. Can't use the existing
846    // `run_async` helper because its `Output = Result<T, String>` shape
847    // doesn't compose with our `Result<_, IpcError>` return type.
848    let runtime = tokio::runtime::Builder::new_current_thread()
849        .enable_all()
850        .build()
851        .map_err(|e| {
852            zccache_ipc::IpcError::Endpoint(format!("failed to create tokio runtime: {e}"))
853        })?;
854
855    runtime.block_on(async move {
856        let mut conn = match connect_client(&endpoint).await {
857            Ok(c) => c,
858            Err(e) => {
859                if is_daemon_unreachable_err(&e) {
860                    eprintln!(
861                        "session-end: daemon unreachable at {endpoint}, treating session {session_id} as ended"
862                    );
863                    return Ok(None);
864                }
865                return Err(e);
866            }
867        };
868
869        conn.send(&zccache_protocol::Request::SessionEnd {
870            session_id: session_id.clone(),
871        })
872        .await?;
873
874        match conn.recv::<zccache_protocol::Response>().await? {
875            Some(zccache_protocol::Response::SessionEnded { stats }) => Ok(stats),
876            Some(zccache_protocol::Response::Error { message }) => Err(
877                zccache_ipc::IpcError::Endpoint(format!("session-end failed: {message}")),
878            ),
879            None => Err(zccache_ipc::IpcError::ConnectionClosed),
880            Some(other) => Err(zccache_ipc::IpcError::Endpoint(format!(
881                "unexpected response from daemon: {other:?}"
882            ))),
883        }
884    })
885}
886
887pub fn client_session_stats(
888    endpoint: Option<&str>,
889    session_id: &str,
890) -> Result<Option<zccache_protocol::SessionStats>, String> {
891    let endpoint = resolve_endpoint(endpoint);
892    let session_id = session_id.to_string();
893    run_async(async move {
894        let mut conn = connect_client(&endpoint)
895            .await
896            .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
897        conn.send(&zccache_protocol::Request::SessionStats {
898            session_id: session_id.clone(),
899        })
900        .await
901        .map_err(|e| format!("failed to send to daemon: {e}"))?;
902
903        match conn.recv::<zccache_protocol::Response>().await {
904            Ok(Some(zccache_protocol::Response::SessionStatsResult { stats })) => Ok(stats),
905            Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
906            Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
907            Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
908            Err(e) => Err(format!("broken connection to daemon: {e}")),
909        }
910    })
911}
912
913#[derive(Debug, Clone)]
914pub struct FingerprintCheckResponse {
915    pub decision: String,
916    pub reason: Option<String>,
917    pub changed_files: Vec<String>,
918}
919
920pub fn fingerprint_check(
921    endpoint: Option<&str>,
922    cache_file: &Path,
923    cache_type: &str,
924    root: &Path,
925    extensions: &[String],
926    include_globs: &[String],
927    exclude: &[String],
928) -> Result<FingerprintCheckResponse, String> {
929    let endpoint = resolve_endpoint(endpoint);
930    let cache_file = cache_file.to_path_buf();
931    let cache_type = cache_type.to_string();
932    let root = root.to_path_buf();
933    let extensions = extensions.to_vec();
934    let include_globs = include_globs.to_vec();
935    let exclude = exclude.to_vec();
936
937    run_async(async move {
938        ensure_daemon(&endpoint).await?;
939        let mut conn = connect_client(&endpoint)
940            .await
941            .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
942
943        conn.send(&zccache_protocol::Request::FingerprintCheck {
944            cache_file: cache_file.into(),
945            cache_type,
946            root: root.into(),
947            extensions,
948            include_globs,
949            exclude,
950        })
951        .await
952        .map_err(|e| format!("failed to send to daemon: {e}"))?;
953
954        match conn.recv::<zccache_protocol::Response>().await {
955            Ok(Some(zccache_protocol::Response::FingerprintCheckResult {
956                decision,
957                reason,
958                changed_files,
959            })) => Ok(FingerprintCheckResponse {
960                decision,
961                reason,
962                changed_files,
963            }),
964            Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
965            Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
966            Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
967            Err(e) => Err(format!("broken connection to daemon: {e}")),
968        }
969    })
970}
971
972pub fn fingerprint_mark_success(endpoint: Option<&str>, cache_file: &Path) -> Result<(), String> {
973    fingerprint_mark(endpoint, cache_file, true)
974}
975
976pub fn fingerprint_mark_failure(endpoint: Option<&str>, cache_file: &Path) -> Result<(), String> {
977    fingerprint_mark(endpoint, cache_file, false)
978}
979
980fn fingerprint_mark(
981    endpoint: Option<&str>,
982    cache_file: &Path,
983    success: bool,
984) -> Result<(), String> {
985    let endpoint = resolve_endpoint(endpoint);
986    let cache_file = cache_file.to_path_buf();
987    run_async(async move {
988        ensure_daemon(&endpoint).await?;
989        let mut conn = connect_client(&endpoint)
990            .await
991            .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
992        let request = if success {
993            zccache_protocol::Request::FingerprintMarkSuccess {
994                cache_file: cache_file.into(),
995            }
996        } else {
997            zccache_protocol::Request::FingerprintMarkFailure {
998                cache_file: cache_file.into(),
999            }
1000        };
1001        conn.send(&request)
1002            .await
1003            .map_err(|e| format!("failed to send to daemon: {e}"))?;
1004        match conn.recv::<zccache_protocol::Response>().await {
1005            Ok(Some(zccache_protocol::Response::FingerprintAck)) => Ok(()),
1006            Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
1007            Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
1008            Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
1009            Err(e) => Err(format!("broken connection to daemon: {e}")),
1010        }
1011    })
1012}
1013
1014pub fn fingerprint_invalidate(endpoint: Option<&str>, cache_file: &Path) -> Result<(), String> {
1015    let endpoint = resolve_endpoint(endpoint);
1016    let cache_file = cache_file.to_path_buf();
1017    run_async(async move {
1018        ensure_daemon(&endpoint).await?;
1019        let mut conn = connect_client(&endpoint)
1020            .await
1021            .map_err(|e| format!("cannot connect to daemon at {endpoint}: {e}"))?;
1022        conn.send(&zccache_protocol::Request::FingerprintInvalidate {
1023            cache_file: cache_file.into(),
1024        })
1025        .await
1026        .map_err(|e| format!("failed to send to daemon: {e}"))?;
1027        match conn.recv::<zccache_protocol::Response>().await {
1028            Ok(Some(zccache_protocol::Response::FingerprintAck)) => Ok(()),
1029            Ok(Some(zccache_protocol::Response::Error { message })) => Err(message),
1030            Ok(None) => Err("lost connection to daemon (no response received)".to_string()),
1031            Ok(Some(other)) => Err(format!("unexpected response from daemon: {other:?}")),
1032            Err(e) => Err(format!("broken connection to daemon: {e}")),
1033        }
1034    })
1035}
1036
1037#[cfg(test)]
1038mod tests {
1039    use super::*;
1040
1041    #[test]
1042    fn infer_download_path_keeps_url_filename() {
1043        let path = infer_download_archive_path(
1044            &DownloadSource::Url("https://example.com/releases/toolchain.tar.gz?download=1".into()),
1045            ArchiveFormat::Auto,
1046        );
1047        let file_name = path.file_name().unwrap().to_string_lossy();
1048        assert!(file_name.ends_with("-toolchain.tar.gz"));
1049    }
1050
1051    #[test]
1052    fn infer_download_path_uses_archive_format_suffix_when_needed() {
1053        let path = infer_download_archive_path(
1054            &DownloadSource::Url("https://example.com/download".into()),
1055            ArchiveFormat::Zip,
1056        );
1057        let file_name = path.file_name().unwrap().to_string_lossy();
1058        assert!(file_name.ends_with(".zip"));
1059    }
1060
1061    #[test]
1062    fn build_download_request_derives_archive_path_when_missing() {
1063        let request = build_download_request(DownloadParams::new("https://example.com/file.zip"));
1064        let file_name = request
1065            .destination_path
1066            .file_name()
1067            .unwrap()
1068            .to_string_lossy();
1069        assert!(file_name.ends_with("-file.zip"));
1070    }
1071
1072    #[test]
1073    fn infer_download_path_strips_multipart_suffix_from_first_part() {
1074        let path = infer_download_archive_path(
1075            &DownloadSource::MultipartUrls(vec![
1076                "https://example.com/toolchain.tar.zst.part-aa".into(),
1077                "https://example.com/toolchain.tar.zst.part-ab".into(),
1078            ]),
1079            ArchiveFormat::Auto,
1080        );
1081        let file_name = path.file_name().unwrap().to_string_lossy();
1082        assert!(file_name.ends_with("-toolchain.tar.zst"));
1083    }
1084
1085    #[test]
1086    fn prepare_daemon_exe_in_copies_to_target_dir() {
1087        let tmp = tempfile::tempdir().expect("create tempdir");
1088        let src = tmp.path().join("zccache-daemon.exe");
1089        std::fs::write(&src, b"fake-daemon-bytes").expect("write source");
1090
1091        let dest_dir = tmp.path().join("runtime-binaries");
1092        let copied =
1093            prepare_daemon_exe_in(&src, &dest_dir).expect("prepare_daemon_exe_in succeeds");
1094
1095        assert!(
1096            copied.is_file(),
1097            "copy at {} should exist",
1098            copied.display()
1099        );
1100        assert_eq!(
1101            copied.parent().unwrap(),
1102            dest_dir,
1103            "copy should land inside dest_dir"
1104        );
1105        assert!(
1106            copied
1107                .file_name()
1108                .unwrap()
1109                .to_string_lossy()
1110                .starts_with("zccache-daemon."),
1111            "filename should start with zccache-daemon., got {}",
1112            copied.display()
1113        );
1114        assert!(
1115            copied.extension().and_then(|s| s.to_str()) == Some("exe"),
1116            "extension should be preserved"
1117        );
1118        assert_eq!(
1119            std::fs::read(&copied).unwrap(),
1120            b"fake-daemon-bytes",
1121            "copy contents should match source"
1122        );
1123    }
1124
1125    #[test]
1126    fn prepare_daemon_exe_in_creates_missing_dest_dir() {
1127        let tmp = tempfile::tempdir().expect("create tempdir");
1128        let src = tmp.path().join("zccache-daemon");
1129        std::fs::write(&src, b"x").expect("write source");
1130
1131        let dest_dir = tmp.path().join("nested").join("runtime-binaries");
1132        assert!(!dest_dir.exists(), "precondition: dest_dir does not exist");
1133
1134        let copied = prepare_daemon_exe_in(&src, &dest_dir).expect("create + copy");
1135        assert!(dest_dir.is_dir(), "dest_dir should now exist");
1136        assert!(copied.is_file());
1137    }
1138
1139    #[test]
1140    fn gc_runtime_binaries_in_removes_unlocked_entries() {
1141        let tmp = tempfile::tempdir().expect("create tempdir");
1142        let dir = tmp.path().join("runtime-binaries");
1143        std::fs::create_dir_all(&dir).expect("create dir");
1144
1145        let a = dir.join("zccache-daemon.111.exe");
1146        let b = dir.join("zccache-daemon.222.exe");
1147        std::fs::write(&a, b"a").unwrap();
1148        std::fs::write(&b, b"b").unwrap();
1149
1150        gc_runtime_binaries_in(&dir);
1151
1152        assert!(!a.exists(), "{} should be GC'd", a.display());
1153        assert!(!b.exists(), "{} should be GC'd", b.display());
1154        assert!(dir.is_dir(), "directory itself remains");
1155    }
1156
1157    #[test]
1158    fn gc_runtime_binaries_in_is_noop_for_missing_dir() {
1159        let tmp = tempfile::tempdir().expect("create tempdir");
1160        let dir = tmp.path().join("does-not-exist");
1161        gc_runtime_binaries_in(&dir);
1162    }
1163
1164    /// Issue #159: `session_end_idempotent` is the shared library entry
1165    /// point for ending a session — used by the CLI `session-end` command
1166    /// AND by tools like soldr that call into the library directly. When
1167    /// the daemon process is gone (pipe / socket missing), this function
1168    /// must return `Ok(None)` rather than propagating the connect-time
1169    /// I/O error. Soldr's at-exit `rust-plan save` previously failed
1170    /// Windows CI because its in-process session-end did NOT go through
1171    /// `cmd_session_end` (which is gated to the CLI subprocess path) and
1172    /// so the #151 idempotency fix didn't apply.
1173    #[test]
1174    fn session_end_idempotent_swallows_vanished_daemon() {
1175        // Construct an endpoint that is guaranteed to have no listener —
1176        // a unique pipe / socket name with no server bound to it.
1177        let endpoint = zccache_ipc::unique_test_endpoint();
1178        let session_id = "00000000-0000-0000-0000-000000000000";
1179
1180        let result = session_end_idempotent(&endpoint, session_id);
1181
1182        assert!(
1183            matches!(result, Ok(None)),
1184            "vanished daemon must produce Ok(None) (success no-op), got {result:?}"
1185        );
1186    }
1187
1188    /// Control: non-unreachable errors (the function shouldn't be a
1189    /// blanket "ignore everything"). We can't easily synthesize a live
1190    /// daemon error here, but we can at least assert the routing via the
1191    /// helper used inside the function: connect-time `TimedOut` must NOT
1192    /// be classified as unreachable, so the function would propagate it
1193    /// (rather than silently return Ok(None)). This guards against a
1194    /// regression where someone widens the unreachable set to "any I/O
1195    /// error".
1196    #[test]
1197    fn session_end_idempotent_treats_timeout_as_real_error() {
1198        let err = zccache_ipc::IpcError::Io(std::io::Error::from(std::io::ErrorKind::TimedOut));
1199        assert!(
1200            !is_daemon_unreachable_err(&err),
1201            "TimedOut must NOT be classified as daemon-unreachable; session_end_idempotent \
1202             would otherwise silently swallow real timeouts"
1203        );
1204    }
1205
1206    /// Control: protocol-layer errors (malformed framing, closed
1207    /// connection mid-response) must NOT be classified as unreachable.
1208    #[test]
1209    fn session_end_idempotent_treats_protocol_errors_as_real() {
1210        let err = zccache_ipc::IpcError::ConnectionClosed;
1211        assert!(!is_daemon_unreachable_err(&err));
1212        let err = zccache_ipc::IpcError::Endpoint("bogus".into());
1213        assert!(!is_daemon_unreachable_err(&err));
1214    }
1215
1216    /// Issue #150: connect-time errors that mean "daemon process is gone
1217    /// entirely" must be classified as unreachable so the idempotent
1218    /// session-end paths (`session_end_idempotent` + the CLI's
1219    /// `cmd_session_end` wrapper) can fall through to the success path.
1220    /// The set covers every shape `connect()` actually returns when the
1221    /// pipe / socket is missing or has no listener.
1222    #[test]
1223    fn is_daemon_unreachable_recognizes_not_found() {
1224        let err = zccache_ipc::IpcError::Io(std::io::Error::from(std::io::ErrorKind::NotFound));
1225        assert!(is_daemon_unreachable_err(&err));
1226    }
1227
1228    #[test]
1229    fn is_daemon_unreachable_recognizes_connection_refused() {
1230        let err =
1231            zccache_ipc::IpcError::Io(std::io::Error::from(std::io::ErrorKind::ConnectionRefused));
1232        assert!(is_daemon_unreachable_err(&err));
1233    }
1234
1235    #[test]
1236    fn is_daemon_unreachable_recognizes_broken_pipe() {
1237        let err = zccache_ipc::IpcError::Io(std::io::Error::from(std::io::ErrorKind::BrokenPipe));
1238        assert!(is_daemon_unreachable_err(&err));
1239    }
1240
1241    /// Mapping ENOENT through `from_raw_os_error` must yield the same
1242    /// classification as constructing from `ErrorKind::NotFound`. This
1243    /// guards against platform variance (macOS / Linux / Windows could
1244    /// in principle synthesize a different kind for the same errno).
1245    #[test]
1246    fn is_daemon_unreachable_recognizes_raw_enoent() {
1247        // ENOENT == 2 on every Unix; on Windows ERROR_FILE_NOT_FOUND == 2 too.
1248        let err = zccache_ipc::IpcError::Io(std::io::Error::from_raw_os_error(2));
1249        assert!(
1250            is_daemon_unreachable_err(&err),
1251            "errno 2 must map to a kind in the unreachable set; got kind={:?}",
1252            match &err {
1253                zccache_ipc::IpcError::Io(io) => io.kind(),
1254                _ => unreachable!(),
1255            }
1256        );
1257    }
1258
1259    /// Regression: `client_session_end` is the in-process library entry point
1260    /// used by Python bindings and external tools (soldr's `rust-plan save`).
1261    /// It must mirror `session_end_idempotent` — a vanished daemon is a no-op
1262    /// success, not a hard error. Before this fix, soldr called
1263    /// `client_session_end`, got `Err("cannot connect to daemon at …")`,
1264    /// surfaced it as "soldr: zccache session-end … failed: …", and Windows
1265    /// Test failed teardown even after every workspace test passed.
1266    #[test]
1267    fn client_session_end_swallows_vanished_daemon() {
1268        let endpoint = zccache_ipc::unique_test_endpoint();
1269        let session_id = "00000000-0000-0000-0000-000000000000";
1270
1271        let result = client_session_end(Some(&endpoint), session_id);
1272
1273        assert!(
1274            matches!(result, Ok(None)),
1275            "vanished daemon must produce Ok(None) (success no-op), got {result:?}"
1276        );
1277    }
1278}