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