Skip to main content

flodl_cli/libtorch/
detect.rs

1//! libtorch installation detection and .arch metadata parsing.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use crate::util::system::GpuInfo;
7use flodl_hw::GpuVendor;
8
9// ---------------------------------------------------------------------------
10// Types
11// ---------------------------------------------------------------------------
12
13/// Metadata about an installed libtorch variant (from `.arch` file).
14#[derive(Debug)]
15pub struct LibtorchInfo {
16    /// Relative path from project root (e.g. "precompiled/cu128", "builds/sm61-sm120").
17    pub path: String,
18    pub torch_version: Option<String>,
19    pub cuda_version: Option<String>,
20    pub archs: Option<String>,
21    pub source: Option<String>,
22}
23
24// ---------------------------------------------------------------------------
25// Detection
26// ---------------------------------------------------------------------------
27
28/// Read the active libtorch variant from `<root>/libtorch/.active` and
29/// parse its `.arch` metadata.
30///
31/// On heterogeneous rigs (multiple hosts sharing the same checkout via
32/// NFS / virtiofs / S3-FUSE) a single `.active` file can't represent
33/// "PT 2.10 on the Blackwell host AND PT 2.7 on the Pascal host" at
34/// the same time. The `FDL_LIBTORCH_CASE` env var selects an
35/// alternative pointer file `libtorch/.active.<case>`; the file's
36/// content is read identically to `.active`.
37///
38/// Setting `FDL_LIBTORCH_CASE=<case>` with no corresponding pointer
39/// file is a hard misconfiguration: this function logs the missing
40/// file to stderr and returns `None` (callers surface this as "libtorch
41/// not configured") rather than silently falling back to `.active`,
42/// which would otherwise mask the user's explicit selection.
43pub fn read_active(root: &Path) -> Option<LibtorchInfo> {
44    let lt_dir = root.join("libtorch");
45    let pointer = match std::env::var("FDL_LIBTORCH_CASE") {
46        Ok(case) if !case.trim().is_empty() => {
47            let case = case.trim();
48            let case_file = lt_dir.join(format!(".active.{case}"));
49            if !case_file.exists() {
50                eprintln!(
51                    "fdl: FDL_LIBTORCH_CASE={case} but `{}` does not exist. \
52                     Create it with `fdl libtorch use <variant> --as {case}` \
53                     or hand-write the variant path (e.g. \
54                     `precompiled/cu128`).",
55                    case_file.display(),
56                );
57                return None;
58            }
59            case_file
60        }
61        _ => lt_dir.join(".active"),
62    };
63    read_active_from(&pointer, &lt_dir)
64}
65
66/// Read any `.active*` pointer file and resolve its content (a
67/// relative path like `precompiled/cu128` or `builds/sm61-sm120`)
68/// against `libtorch_root` to produce a [`LibtorchInfo`].
69///
70/// Used by [`read_active`] (default `.active`), by callers that have
71/// the pointer file path directly (e.g. cluster.yml's per-host
72/// `arch:` naming a case pointer, resolving to
73/// `…/libtorch/.active.<case>`), and by tests
74/// that need to validate a pointer-file shape without setting the
75/// `FDL_LIBTORCH_CASE` env var.
76pub fn read_active_from(pointer: &Path, libtorch_root: &Path) -> Option<LibtorchInfo> {
77    let active = fs::read_to_string(pointer).ok()?;
78    let path = active.trim().to_string();
79    if path.is_empty() {
80        return None;
81    }
82    let arch_dir = libtorch_root.join(&path);
83    Some(libtorch_info_from_dir(path, &arch_dir))
84}
85
86/// Build a [`LibtorchInfo`] for a variant directory: `path` is the string
87/// recorded in the info (a relative variant path like `precompiled/cu128`
88/// or an absolute directory), `arch_dir` is the directory whose `.arch`
89/// file supplies the metadata. The four metadata fields stay `None` when
90/// the `.arch` file is absent or unreadable. One home for the parse that
91/// `read_active_from`, `run::resolve_libtorch_at`, and probe's
92/// `check_libtorch*` all used to copy inline.
93pub(crate) fn libtorch_info_from_dir(path: String, arch_dir: &Path) -> LibtorchInfo {
94    let mut info = LibtorchInfo {
95        path,
96        torch_version: None,
97        cuda_version: None,
98        archs: None,
99        source: None,
100    };
101    if let Ok(content) = fs::read_to_string(arch_dir.join(".arch")) {
102        parse_arch_into(&content, &mut info);
103    }
104    info
105}
106
107/// Fill a [`LibtorchInfo`]'s metadata fields from `.arch` file content
108/// (`torch=` / `cuda=` / `archs=` / `source=` lines; unknown lines ignored).
109fn parse_arch_into(content: &str, info: &mut LibtorchInfo) {
110    for line in content.lines() {
111        if let Some(val) = line.strip_prefix("torch=") {
112            info.torch_version = Some(val.to_string());
113        } else if let Some(val) = line.strip_prefix("cuda=") {
114            info.cuda_version = Some(val.to_string());
115        } else if let Some(val) = line.strip_prefix("archs=") {
116            info.archs = Some(val.to_string());
117        } else if let Some(val) = line.strip_prefix("source=") {
118            info.source = Some(val.to_string());
119        }
120    }
121}
122
123/// Record per-GPU arch coverage for a resolved variant and push a loud,
124/// actionable issue for every GPU the libtorch build does not cover — or a
125/// single issue when the variant carries no `.arch` metadata. Returns
126/// `(gpu_index, covered)` pairs in GPU order. One home for the coverage
127/// loop probe's three `check_libtorch*` paths used to copy inline (with
128/// drifted wording).
129pub(crate) fn arch_coverage(
130    info: &LibtorchInfo,
131    gpus: &[GpuInfo],
132    issues: &mut Vec<String>,
133) -> Vec<(u8, bool)> {
134    let mut archs_match = Vec::new();
135    if let Some(archs) = &info.archs {
136        for g in gpus {
137            let ok = g.covered_by(archs);
138            archs_match.push((g.index, ok));
139            if !ok {
140                issues.push(format!(
141                    "GPU {} ({}, {}) not covered by libtorch archs `{}`. \
142                     Rebuild libtorch with this arch or activate a \
143                     compatible variant.",
144                    g.index,
145                    g.short_name(),
146                    g.arch_label(),
147                    archs
148                ));
149            }
150        }
151    } else {
152        issues.push(
153            "libtorch is present but `.arch` metadata is missing — cannot \
154             verify GPU compatibility. Place an `.arch` file in the variant \
155             directory (cuda=, torch=, archs=, source=)."
156                .into(),
157        );
158    }
159    archs_match
160}
161
162/// Which GPU stack a libtorch variant path targets, from its basename.
163///
164/// `None` means a CPU-only variant. The variant path (`precompiled/cu128`,
165/// `builds/sm61-sm120`, `precompiled/cpu`) is the single source of truth
166/// here -- no `.arch` metadata file is required -- because the cluster
167/// `arch:` field names exactly this path and must resolve without
168/// reading the remote host's filesystem.
169///
170/// | Basename starts with | Target |
171/// |---|---|
172/// | `cpu` | CPU-only |
173/// | `cu<digit>` (`cu128`, `cu126-pt27`) or `sm<digit>` (`sm61-sm120`) | NVIDIA |
174/// | `rocm<digit>` or `gfx<digit>` (`gfx1030-gfx1100`) | AMD |
175///
176/// An unrecognised basename **warns and is treated as NVIDIA**. That
177/// preserves the pre-multi-vendor behaviour exactly, which matters
178/// because a user may well have a hand-named CUDA variant
179/// (`builds/mybuild`) that works today; hard-erroring would break a
180/// running setup for the sake of a naming convention. The warning is
181/// the point: the old code made the same assumption in silence, and an
182/// unrecognised basename on an AMD box would otherwise be cross-built
183/// for NVIDIA without a word.
184pub fn variant_vendor(variant: &str) -> Option<GpuVendor> {
185    // The naming convention has ONE home (flodl-hw, where the join
186    // admission gate also reads it); the warn-and-assume-NVIDIA
187    // fallback is this router's policy, not the convention's.
188    match flodl_hw::classify_variant_label(variant) {
189        flodl_hw::VariantClass::Cpu => None,
190        flodl_hw::VariantClass::Vendor(v) => Some(v),
191        flodl_hw::VariantClass::Unknown => {
192            eprintln!(
193                "fdl: libtorch variant {variant:?} does not match a known naming \
194                 convention (cpu / cu<N> / sm<N> / rocm<N> / gfx<N>); assuming it is \
195                 an NVIDIA build. Rename it to match, or pass the feature explicitly."
196            );
197            Some(GpuVendor::Nvidia)
198        }
199    }
200}
201
202/// The `export` lines a native-build recipe prints for a variant's
203/// vendor, in order.
204///
205/// **On ROCm the system runtime goes FIRST**, ahead of libtorch's own
206/// `lib/`. Same D1a ordering `Dockerfile.rocm` and the cluster pre-build
207/// carry, and for the same reason: libtorch-rocm bundles the entire
208/// userspace ROCm stack (libamdhip64, libhsa-runtime64, libamd_comgr,
209/// and the kernel-interface-coupled libdrm / libnuma), so with libtorch
210/// first that bundle wins over the host's, and when it disagrees with
211/// the host's amdkfd driver the process segfaults at its FIRST GPU op.
212/// A recipe printed the other way round IS that configuration, handed
213/// to the user to paste.
214///
215/// `$ROCM_PATH` is honored (these recipes run on the LOCAL host, so its
216/// env is the right authority) with `/opt/rocm` as the convention
217/// default. A path that does not exist is skipped by the loader, so the
218/// prefix costs nothing where there is no system ROCm. The cluster
219/// pre-build deliberately does NOT do this: the path it builds names a
220/// REMOTE host, where the controller's `$ROCM_PATH` would be the wrong
221/// machine's answer.
222///
223/// `libtorch_lib` is how the recipe spells the libtorch lib directory:
224/// an absolute path for the standalone installer, `$LIBTORCH_PATH/lib`
225/// where the recipe just exported that variable.
226///
227/// One home on purpose. Three sites print this recipe (`fdl setup`,
228/// `fdl libtorch download`, `fdl libtorch build`) and each grew its own
229/// copy; two of them had the order backwards, which is not a cosmetic
230/// drift but the segfault configuration.
231pub fn ld_library_path_lines(vendor: Option<GpuVendor>, libtorch_lib: &str) -> Vec<String> {
232    let tail = "${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}";
233    match vendor {
234        Some(GpuVendor::Amd) => {
235            // `lib` vs `lib64` is a distro property (RHEL/SUSE use
236            // lib64), and a recipe naming the wrong one is a path the
237            // loader silently skips — the segfault configuration again.
238            // The recipe runs on THIS host, so probe the actual layout
239            // and keep the `$ROCM_PATH` indirection for the root.
240            let libdir = flodl_hw::rocm_runtime_lib_dir()
241                .and_then(|d| d.file_name().map(|n| n.to_string_lossy().into_owned()))
242                .unwrap_or_else(|| "lib".to_string());
243            vec![
244                "export ROCM_PATH=\"${ROCM_PATH:-/opt/rocm}\"".to_string(),
245                format!("export LD_LIBRARY_PATH=\"$ROCM_PATH/{libdir}:{libtorch_lib}{tail}\""),
246            ]
247        }
248        _ => vec![format!("export LD_LIBRARY_PATH=\"{libtorch_lib}{tail}\"")],
249    }
250}
251
252/// This box's active libtorch as `(variant directory, variant label)`,
253/// anchored on `root` (a project root, or the global one).
254///
255/// The directory rather than its `lib/`: a build wants `LIBTORCH_PATH`
256/// (headers included) and a child process wants `lib/`, so one value
257/// serves both and neither caller has to guess which it was handed.
258pub fn active_variant(root: &Path) -> Option<(PathBuf, String)> {
259    let info = read_active(root)?;
260    let dir = root.join("libtorch").join(&info.path);
261    dir.join("lib").is_dir().then_some((dir, info.path))
262}
263
264/// `LD_LIBRARY_PATH` VALUE for running against `libtorch_lib`, in the
265/// order the loader must see it. The sibling of
266/// [`ld_library_path_lines`], which prints the same ordering as a shell
267/// recipe; this one is for setting on a child process.
268///
269/// `rocm_lib` is the system runtime's LIBRARY directory, passed rather
270/// than resolved here because the two callers describe different
271/// filesystems: a locally spawned child gets this box's resolved
272/// directory ([`local_rocm_lib_dir`]), while a path composed for a
273/// REMOTE host must use the convention (`/opt/rocm/lib`) since our own
274/// environment says nothing about theirs.
275///
276/// **On ROCm the system runtime must come FIRST.** libtorch-rocm bundles
277/// the entire userspace ROCm stack, so with libtorch first that bundle
278/// wins over the host's, and when it disagrees with the host's amdkfd
279/// driver the process segfaults at its FIRST GPU OP — a failure that
280/// looks nothing like a library-path problem. A path that does not exist
281/// is skipped by the loader, so prefixing costs nothing on a box without
282/// ROCm.
283pub fn ld_library_path_value(
284    vendor: Option<GpuVendor>,
285    libtorch_lib: &str,
286    rocm_lib: &str,
287) -> String {
288    match vendor {
289        Some(GpuVendor::Amd) => {
290            format!("{}:{libtorch_lib}", rocm_lib.trim_end_matches('/'))
291        }
292        _ => libtorch_lib.to_string(),
293    }
294}
295
296/// The system ROCm runtime's library directory on THIS box, for
297/// [`ld_library_path_value`]'s local callers.
298///
299/// `flodl-hw` resolves it properly (`$ROCM_PATH` / `$HIP_PATH` /
300/// `$HSA_PATH` / `/opt/rocm`, probing `lib` and `lib64` for the actual
301/// runtime): detection and the loader path MUST agree, or a box passes
302/// the GPU gate on the runtime detection found and then segfaults on
303/// the path a weaker resolution composed. Falls back to the
304/// `$ROCM_PATH`-or-convention spelling when no runtime is found — the
305/// loader skips a missing path, so the prefix stays harmless.
306pub fn local_rocm_lib_dir() -> String {
307    match flodl_hw::rocm_runtime_lib_dir() {
308        Some(dir) => dir.display().to_string(),
309        None => format!(
310            "{}/lib",
311            std::env::var("ROCM_PATH")
312                .ok()
313                .filter(|v| !v.trim().is_empty())
314                .unwrap_or_else(|| "/opt/rocm".to_string())
315                .trim_end_matches('/'),
316        ),
317    }
318}
319
320/// The cargo feature a variant needs, or `""` for a CPU-only variant.
321pub fn variant_feature(variant: &str) -> &'static str {
322    match variant_vendor(variant) {
323        None => "",
324        Some(v) => v.cargo_feature(),
325    }
326}
327
328/// List all installed libtorch variants under `<root>/libtorch/`.
329///
330/// Scans `precompiled/` and `builds/` subdirectories.
331pub fn list_variants(root: &Path) -> Vec<String> {
332    let mut variants = Vec::new();
333    let lt_dir = root.join("libtorch");
334
335    for subdir in ["precompiled", "builds"] {
336        let dir = lt_dir.join(subdir);
337        if let Ok(entries) = fs::read_dir(&dir) {
338            for entry in entries.flatten() {
339                if entry.path().join("lib").is_dir()
340                    && let Some(name) = entry.file_name().to_str()
341                {
342                    variants.push(format!("{}/{}", subdir, name));
343                }
344            }
345        }
346    }
347
348    variants.sort();
349    variants
350}
351
352/// Check whether a libtorch variant directory looks valid (has lib/).
353pub fn is_valid_variant(root: &Path, variant: &str) -> bool {
354    root.join(format!("libtorch/{}/lib", variant)).is_dir()
355}
356
357/// Set the active libtorch variant by writing `<root>/libtorch/.active`.
358pub fn set_active(root: &Path, variant: &str) -> Result<(), String> {
359    let lt_dir = root.join("libtorch");
360    fs::create_dir_all(&lt_dir).map_err(|e| format!("cannot create libtorch/: {}", e))?;
361    fs::write(lt_dir.join(".active"), format!("{}\n", variant))
362        .map_err(|e| format!("cannot write libtorch/.active: {}", e))
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use crate::util::test_env::env_lock;
369    use std::path::PathBuf;
370    use std::sync::atomic::{AtomicU64, Ordering};
371    use std::time::{SystemTime, UNIX_EPOCH};
372
373    // Per-process counter so concurrent test binaries don't collide
374    // on the unique-suffix algorithm.
375    static SCRATCH_SEQ: AtomicU64 = AtomicU64::new(0);
376
377    /// Hand-rolled scratch dir under the system temp dir + RAII
378    /// cleanup. flodl-cli keeps external deps minimal (the serde
379    /// ecosystem only — no utility crates like `tempfile`) so we
380    /// cannot pull in `tempfile`.
381    struct Scratch(PathBuf);
382    impl Scratch {
383        fn new() -> Self {
384            let nanos = SystemTime::now()
385                .duration_since(UNIX_EPOCH)
386                .map(|d| d.as_nanos())
387                .unwrap_or(0);
388            let seq = SCRATCH_SEQ.fetch_add(1, Ordering::Relaxed);
389            let dir = std::env::temp_dir().join(format!("fdl-libtorch-resolver-{}-{}", nanos, seq));
390            fs::create_dir_all(&dir).expect("create scratch");
391            Self(dir)
392        }
393        fn path(&self) -> &std::path::Path {
394            &self.0
395        }
396    }
397    impl Drop for Scratch {
398        fn drop(&mut self) {
399            let _ = fs::remove_dir_all(&self.0);
400        }
401    }
402
403    /// Build a fake project root with two synthetic libtorch variants
404    /// (`precompiled/v1` and `builds/v2`), each with a `lib/` dir and
405    /// `.arch` metadata. The names are deliberately abstract — the
406    /// resolver doesn't care about variant naming, just the
407    /// `<kind>/<name>` shape it reads from the pointer file.
408    fn make_root() -> Scratch {
409        let s = Scratch::new();
410        let v1 = s.path().join("libtorch/precompiled/v1");
411        fs::create_dir_all(v1.join("lib")).unwrap();
412        fs::write(
413            v1.join(".arch"),
414            "torch=1.0\ncuda=1.0\narchs=0.0\nsource=precompiled\n",
415        )
416        .unwrap();
417        let v2 = s.path().join("libtorch/builds/v2");
418        fs::create_dir_all(v2.join("lib")).unwrap();
419        fs::write(
420            v2.join(".arch"),
421            "torch=2.0\ncuda=2.0\narchs=1.0\nsource=build\n",
422        )
423        .unwrap();
424        s
425    }
426
427    #[test]
428    fn variant_vendor_reads_the_naming_convention() {
429        for (path, want) in [
430            ("precompiled/cpu", None),
431            ("precompiled/cu128", Some(GpuVendor::Nvidia)),
432            ("precompiled/cu126-pt27", Some(GpuVendor::Nvidia)),
433            ("builds/sm61-sm120", Some(GpuVendor::Nvidia)),
434            ("builds/sm80", Some(GpuVendor::Nvidia)),
435            ("precompiled/rocm63", Some(GpuVendor::Amd)),
436            ("builds/gfx1030-gfx1100", Some(GpuVendor::Amd)),
437            ("builds/gfx942", Some(GpuVendor::Amd)),
438        ] {
439            assert_eq!(variant_vendor(path), want, "{path}");
440        }
441    }
442
443    #[test]
444    fn variant_vendor_requires_a_digit_after_the_prefix() {
445        // `cpu` must not read as a `cu`-something, and a bare `gfx`
446        // directory is not an arch.
447        assert_eq!(variant_vendor("precompiled/cpu"), None);
448        assert_eq!(variant_vendor("x/cpu-static"), None);
449        // Unrecognised names warn and fall back to NVIDIA rather than
450        // breaking a hand-named CUDA build that works today.
451        assert_eq!(variant_vendor("builds/mybuild"), Some(GpuVendor::Nvidia));
452        assert_eq!(variant_vendor("builds/gfx"), Some(GpuVendor::Nvidia));
453    }
454
455    #[test]
456    fn ld_recipe_puts_system_rocm_before_libtorch() {
457        // D1a. The ORDER is the whole point: the other way round is the
458        // configuration that segfaults at the first GPU op, and these
459        // lines are pasted verbatim by whoever ran the command.
460        for lib in ["$LIBTORCH_PATH/lib", "/opt/lt/rocm70/lib"] {
461            let lines = ld_library_path_lines(Some(GpuVendor::Amd), lib);
462            let ld = lines
463                .iter()
464                .find(|l| l.contains("LD_LIBRARY_PATH="))
465                .expect("recipe must set LD_LIBRARY_PATH");
466            let rocm = ld
467                .find("$ROCM_PATH/lib")
468                .expect("system ROCm must be on the path");
469            let libtorch = ld.find(lib).expect("libtorch must be on the path");
470            assert!(rocm < libtorch, "system ROCm must come first, got {ld}");
471            assert!(
472                lines.iter().any(|l| l.contains("ROCM_PATH:-/opt/rocm")),
473                "an unset ROCM_PATH must fall back to the convention: {lines:?}"
474            );
475        }
476    }
477
478    #[test]
479    fn ld_recipe_is_libtorch_only_for_nvidia_and_cpu() {
480        for vendor in [Some(GpuVendor::Nvidia), None] {
481            let lines = ld_library_path_lines(vendor, "$LIBTORCH_PATH/lib");
482            assert_eq!(lines.len(), 1, "{vendor:?}");
483            assert!(!lines[0].contains("rocm"), "{vendor:?}: {}", lines[0]);
484            assert!(lines[0].contains("$LIBTORCH_PATH/lib"), "{}", lines[0]);
485        }
486    }
487
488    #[test]
489    fn ld_recipe_preserves_an_existing_ld_library_path() {
490        // The `:+` guard keeps a user's existing value and avoids the
491        // trailing colon that would otherwise put CWD on the loader path.
492        for vendor in [Some(GpuVendor::Amd), Some(GpuVendor::Nvidia), None] {
493            let lines = ld_library_path_lines(vendor, "/opt/lt/lib");
494            let ld = lines
495                .iter()
496                .find(|l| l.contains("LD_LIBRARY_PATH="))
497                .unwrap();
498            assert!(
499                ld.contains("${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"),
500                "{vendor:?}: {ld}"
501            );
502        }
503    }
504
505    #[test]
506    fn variant_feature_maps_to_the_cargo_feature() {
507        assert_eq!(variant_feature("precompiled/cpu"), "");
508        assert_eq!(variant_feature("precompiled/cu128"), "cuda");
509        assert_eq!(variant_feature("builds/gfx1030"), "rocm");
510    }
511
512    #[test]
513    fn read_active_default_pointer() {
514        let _guard = env_lock();
515        // SAFETY: serialized via env_lock().
516        unsafe {
517            std::env::remove_var("FDL_LIBTORCH_CASE");
518        }
519        let root = make_root();
520        fs::write(root.path().join("libtorch/.active"), "precompiled/v1\n").unwrap();
521        let info = read_active(root.path()).expect("read_active");
522        assert_eq!(info.path, "precompiled/v1");
523        assert_eq!(info.torch_version.as_deref(), Some("1.0"));
524    }
525
526    #[test]
527    fn fdl_libtorch_case_selects_alternate_pointer() {
528        let _guard = env_lock();
529        let root = make_root();
530        fs::write(root.path().join("libtorch/.active"), "builds/v2\n").unwrap();
531        fs::write(root.path().join("libtorch/.active.alt"), "precompiled/v1\n").unwrap();
532        // SAFETY: serialized via env_lock().
533        unsafe {
534            std::env::set_var("FDL_LIBTORCH_CASE", "alt");
535        }
536        let info = read_active(root.path()).expect("read_active");
537        // SAFETY: serialized via env_lock().
538        unsafe {
539            std::env::remove_var("FDL_LIBTORCH_CASE");
540        }
541        assert_eq!(info.path, "precompiled/v1");
542        assert_eq!(info.torch_version.as_deref(), Some("1.0"));
543    }
544
545    #[test]
546    fn fdl_libtorch_case_missing_file_returns_none_loudly() {
547        let _guard = env_lock();
548        let root = make_root();
549        fs::write(root.path().join("libtorch/.active"), "builds/v2\n").unwrap();
550        // No `.active.nonexistent` file.
551        // SAFETY: serialized via env_lock().
552        unsafe {
553            std::env::set_var("FDL_LIBTORCH_CASE", "nonexistent");
554        }
555        let info = read_active(root.path());
556        // SAFETY: serialized via env_lock().
557        unsafe {
558            std::env::remove_var("FDL_LIBTORCH_CASE");
559        }
560        assert!(
561            info.is_none(),
562            "explicit case with missing file must not silently fall back to .active"
563        );
564    }
565
566    #[test]
567    fn read_active_from_resolves_pointer_directly() {
568        let _guard = env_lock();
569        let root = make_root();
570        let pointer = root.path().join("libtorch/.active.alt");
571        fs::write(&pointer, "builds/v2\n").unwrap();
572        let info =
573            read_active_from(&pointer, &root.path().join("libtorch")).expect("read_active_from");
574        assert_eq!(info.path, "builds/v2");
575        assert_eq!(info.archs.as_deref(), Some("1.0"));
576    }
577}
578
579/// Unmet dynamic-linker requirements of a libtorch variant on THIS host,
580/// as the loader itself reports them.
581///
582/// A libtorch archive is built against some baseline C library, and the
583/// baseline is not the same across variants: measured on 2.10.0, the cpu
584/// and cu128 trees need `GLIBC_2.29` / `GLIBCXX_3.4.26` while the rocm7.0
585/// tree needs `GLIBC_2.35` / `GLIBCXX_3.4.30`. RHEL 9 ships glibc 2.34
586/// and cannot be upgraded past it, so that last combination cannot run
587/// there at all — and without this check the operator finds out after a
588/// download, a compile and a link, from a loader error naming symbol
589/// versions rather than the actual problem.
590///
591/// Asks `ldd`, so it answers by the same rules the real load obeys
592/// instead of a table of baselines that would rot at the next release.
593/// An empty vector means "nothing unmet", which is also what a missing
594/// `ldd` returns: this reports a problem it can prove, never a doubt.
595pub fn unmet_loader_requirements(variant_dir: &Path) -> Vec<String> {
596    let core = variant_dir.join("lib/libtorch_cpu.so");
597    if !core.is_file() {
598        return Vec::new();
599    }
600    let Ok(out) = std::process::Command::new("ldd").arg(&core).output() else {
601        return Vec::new();
602    };
603    let text =
604        String::from_utf8_lossy(&out.stdout).into_owned() + &String::from_utf8_lossy(&out.stderr);
605    parse_unmet_versions(&text)
606}
607
608/// The symbol versions an `ldd` run reported as missing, de-duplicated
609/// in first-seen order. Pure so the parse is testable against real
610/// loader output rather than only on a host that happens to fail.
611pub(crate) fn parse_unmet_versions(ldd_output: &str) -> Vec<String> {
612    let mut seen: Vec<String> = Vec::new();
613    for line in ldd_output.lines() {
614        // `... version `GLIBC_2.35' not found (required by ...)`
615        if !line.contains("not found") {
616            continue;
617        }
618        let Some(rest) = line.split("version `").nth(1) else {
619            continue;
620        };
621        let Some(sym) = rest.split('\'').next() else {
622            continue;
623        };
624        if !seen.iter().any(|s| s == sym) {
625            seen.push(sym.to_string());
626        }
627    }
628    seen
629}
630
631#[cfg(test)]
632mod loader_tests {
633    use super::parse_unmet_versions;
634
635    /// Real `ldd` output, captured 2026-08-07 from the rocm7.0 variant
636    /// on rockylinux:9 — the pair CI hit.
637    #[test]
638    fn it_reads_the_versions_the_loader_could_not_satisfy() {
639        let real = "\
640/lt/libtorch_cpu.so: /lib64/libm.so.6: version `GLIBC_2.35' not found (required by /lt/libtorch_cpu.so)
641/lt/libtorch_cpu.so: /lib64/libstdc++.so.6: version `GLIBCXX_3.4.30' not found (required by /lt/libtorch_cpu.so)
642/lt/libtorch_cpu.so: /lib64/libstdc++.so.6: version `GLIBCXX_3.4.30' not found (required by /lt/libc10.so)
643\tlinux-vdso.so.1 (0x00007ffd0d7f9000)
644\tlibm.so.6 => /lib64/libm.so.6 (0x00007f0e8a000000)
645";
646        assert_eq!(
647            parse_unmet_versions(real),
648            vec!["GLIBC_2.35".to_string(), "GLIBCXX_3.4.30".to_string()],
649            "de-duplicated, in first-seen order",
650        );
651    }
652
653    /// A host that CAN load it says nothing, and neither do we: this
654    /// reports a problem it can prove, never a doubt.
655    #[test]
656    fn a_satisfied_load_reports_nothing() {
657        let ok = "\
658\tlinux-vdso.so.1 (0x00007ffd0d7f9000)
659\tlibtorch_cpu.so => /lt/libtorch_cpu.so (0x00007f0e88000000)
660\tlibm.so.6 => /lib64/libm.so.6 (0x00007f0e8a000000)
661";
662        assert!(parse_unmet_versions(ok).is_empty());
663        assert!(parse_unmet_versions("").is_empty());
664    }
665}