Skip to main content

flodl_cli/
prebuild.rs

1//! Pre-flight build for cluster commands.
2//!
3//! Heterogeneous-rig pain: source lives on a shared mount (NFS /
4//! virtiofs / S3-FUSE) so editing on the controller is visible to
5//! remote hosts, but each host needs its own libtorch-linked binary
6//! (cu128 for a Blackwell host, cu126-pt27 for a Pascal host). Without
7//! pre-flight, the first `fdl @cluster <cmd>` after an edit hits stale
8//! remote binaries — confusing runtime errors or worse, silent wrong
9//! behaviour.
10//!
11//! This module runs `cargo build` LOCALLY on the controller, once per
12//! remote host, into per-`(host, arch)` `target/cluster/<host>/<arch>/`
13//! directories with the right libtorch bind-mounted. The shared mount
14//! delivers the resulting binary to the remote, which execs it directly
15//! (no cargo, no rust toolchain on remote). Per-`(host, arch)` target
16//! dirs isolate cargo's fingerprint cache so a libtorch swap on one host
17//! doesn't invalidate anyone else's incremental build -- and so changing
18//! a host's own `arch:` builds fresh rather than reusing a binary linked
19//! against the old libtorch (in docker mode the container's
20//! `LIBTORCH_PATH` is a fixed mount point, invisible to cargo's cache).
21//!
22//! Convention: command name == binary name. `fdl @cluster ddp-bench`
23//! builds `--bin ddp-bench`. Features derive from the host's libtorch
24//! `.arch` (cuda=12.x → `--features cuda`, cuda=none → no features).
25//!
26//! Builds run in parallel across hosts (per-host target dirs ⇒ zero
27//! contention). All builds run to completion — there is no early abort:
28//! every host is joined and every failure is collected, then surfaced
29//! together so the user sees every host's diagnostic in one place.
30
31use std::collections::BTreeMap;
32use std::path::Path;
33use std::process::{Command, Stdio};
34use std::sync::{Arc, Mutex};
35use std::thread;
36
37use serde::{Deserialize, Serialize};
38
39use crate::cluster::apply_worker_ssh_opts;
40use crate::config::{ClusterConfig, ClusterWorker};
41
42/// Env var carrying the per-host pre-flight build envelope (a JSON
43/// map) from fdl-cli's prebuild phase to flodl's launcher. The
44/// launcher reads it on the controller side just before fan-out and
45/// substitutes the direct-binary form for each host whose entry is
46/// present.
47///
48/// Map shape: `{ "<host-name>": { "bin": "<relative path under
49/// worker.path>", "ld_library_path": "<absolute LD_LIBRARY_PATH>" }, ...
50/// }`. Hosts absent from the map fall back to the launcher's existing
51/// `fdl <cmd>` re-entry on the remote.
52pub const ENV_PREBUILD_PER_HOST: &str = "FLODL_INTERNAL_PREBUILD_PER_HOST";
53
54/// Pre-fan-out readiness probe for every host, run BEFORE any build and
55/// in BOTH prebuild and `--no-prebuild` modes (mount-readiness is
56/// orthogonal to binary freshness). One ssh per remote host: always
57/// verifies the shared `data_path` is mounted + readable; when
58/// `prebuilding`, also runs the controller-vs-remote ABI gate (arch /
59/// libc / `pkill`). The controller is checked LOCALLY (no ssh — it is
60/// the build/dispatch host, so ABI is trivially satisfied).
61///
62/// A definitive failure aborts before fan-out with a per-host message:
63/// an ABI mismatch, or a missing EXPLICIT `data_path` (a declared shared
64/// mount that isn't there is a launch-breaking misconfig, matching
65/// `fdl probe`'s stance). Missing convention-default paths and
66/// unreachable hosts only warn. Running before the (multi-minute) builds
67/// means a bad host fails fast without wasting a build on a good one.
68///
69/// `controller_host` is the local hostname (its worker entry, if any, is
70/// covered by the local check and skipped from the ssh sweep).
71pub fn preflight_hosts(
72    cluster: &ClusterConfig,
73    controller_host: &str,
74    prebuilding: bool,
75) -> Result<(), String> {
76    // Controller: local shared-mount check (no ssh). Uses the controller
77    // block's `data_path`; a same-host worker entry is skipped below.
78    {
79        let dp = cluster.controller.effective_data_path().to_string();
80        let explicit = cluster.controller.data_path.is_some();
81        let dir_ok = Path::new(&dp).is_dir();
82        let read_ok = dir_ok && std::fs::read_dir(&dp).is_ok();
83        if let Some(w) =
84            check_remote_data_path(controller_host, &dp, explicit, dir_ok, read_ok)?
85        {
86            eprintln!("fdl: {w}");
87        }
88    }
89
90    // Remote workers: one ssh each, in parallel (probe latency stays flat
91    // regardless of host count).
92    let remotes: Vec<ClusterWorker> = cluster
93        .workers
94        .iter()
95        .filter(|w| w.host != controller_host)
96        .cloned()
97        .collect();
98    if remotes.is_empty() {
99        return Ok(());
100    }
101
102    let warnings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
103    let errors: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
104    let mut handles = Vec::with_capacity(remotes.len());
105    for worker in remotes {
106        let warnings = Arc::clone(&warnings);
107        let errors = Arc::clone(&errors);
108        handles.push(thread::spawn(move || {
109            match preflight_one_host(&worker, prebuilding) {
110                Ok(ws) => warnings.lock().unwrap().extend(ws),
111                Err(e) => errors.lock().unwrap().push(e),
112            }
113        }));
114    }
115    for h in handles {
116        let _ = h.join();
117    }
118
119    for w in warnings.lock().unwrap().iter() {
120        eprintln!("fdl: {w}");
121    }
122    let errs = Arc::try_unwrap(errors)
123        .map_err(|_| "internal: preflight error collector still referenced".to_string())?
124        .into_inner()
125        .map_err(|e| format!("internal: preflight errors mutex poisoned: {e}"))?;
126    if !errs.is_empty() {
127        return Err(format!(
128            "pre-flight host check failed on {} host(s):\n  {}",
129            errs.len(),
130            errs.join("\n  "),
131        ));
132    }
133    Ok(())
134}
135
136/// Run pre-flight builds for every remote host in `cluster`. The
137/// controller itself is skipped — its build is handled by the normal
138/// dispatch path (`cargo run` in Docker against the local `.active`).
139///
140/// `cmd_name` is both the fdl command and the cargo `--bin` target.
141/// `controller_host` is the local hostname (skipped from the remotes).
142///
143/// Each per-host build runs in a Docker compose service (`cuda` when
144/// the host's libtorch advertises a CUDA version in `.arch`, `dev`
145/// otherwise). The build's env is overridden so:
146///   - `LIBTORCH_HOST_PATH` points at the resolved host libtorch dir
147///   - `CARGO_TARGET_DIR` points at `target/cluster/<host>/<arch>/`
148///
149/// Returns `Ok(())` on universal success. Returns `Err(combined_msg)`
150/// listing every host that failed (with its stderr tail) on any
151/// failure. Builds running when a failure surfaces complete to natural
152/// stopping — cargo's per-crate granularity means cancelling mid-build
153/// would leave the per-host target dir in a half-baked state.
154pub fn prebuild_remotes(
155    project_root: &Path,
156    cmd_cwd: &Path,
157    cluster: &ClusterConfig,
158    cmd_name: &str,
159    controller_host: &str,
160) -> Result<(), String> {
161    // Skip only the worker whose `host` LABEL equals the controller's local
162    // hostname — that entry is the controller building locally and sharing its
163    // cargo target dir, so it needs no remote step. This compares the logical
164    // host label, NOT resolved IPs, deliberately: a container or VM on the same
165    // physical machine (e.g. an `ssh.target: 127.0.0.1:2222` worker) is a
166    // DISTINCT build target with its own arch/libtorch and must get its own
167    // per-host build. Canonicalizing by IP would wrongly fold such a worker
168    // into the controller and break heterogeneous same-box rigs.
169    let remotes: Vec<&ClusterWorker> = cluster
170        .workers
171        .iter()
172        .filter(|w| w.host != controller_host)
173        .collect();
174    if remotes.is_empty() {
175        return Ok(());
176    }
177
178    // Whether the controller runs builds inside Docker. Sourced from
179    // the `controller.docker:` field in cluster.yml; `None` when
180    // absent (native-Rust controllers get the bare cargo invocation).
181    let controller_docker_svc: Option<String> = cluster.controller.docker.clone();
182
183    eprintln!(
184        "fdl: pre-flight build for {} remote worker(s): {}",
185        remotes.len(),
186        remotes.iter().map(|w| w.host.as_str()).collect::<Vec<_>>().join(", "),
187    );
188
189    // Controller's view of the shared project root (required field
190    // per validator).
191    let controller_path: std::path::PathBuf =
192        std::path::PathBuf::from(&cluster.controller.path);
193
194    let project_root = Arc::new(project_root.to_path_buf());
195    let cmd_cwd = Arc::new(cmd_cwd.to_path_buf());
196    let cmd_name = Arc::new(cmd_name.to_string());
197    let controller_path = Arc::new(controller_path);
198    let controller_docker_svc = Arc::new(controller_docker_svc);
199    let errors: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
200    let envelope: Arc<Mutex<BTreeMap<String, PerHostEnvelope>>> =
201        Arc::new(Mutex::new(BTreeMap::new()));
202
203    // Cold shared cargo registry hazard: the per-(host,arch) target dirs are
204    // distinct, but every build shares one `~/.cargo/registry`. Running them
205    // all in parallel from cold races two `cargo build`s to unpack the same
206    // not-yet-cached crate into that registry — observed on the first build of
207    // a new cluster binary as `error: failed to unpack package serde_derive`.
208    // So build the FIRST remote serially: it fully populates the shared
209    // registry (download + unpack of every dep), after which the rest run in
210    // parallel finding all deps already unpacked (they still compile into
211    // their own target dirs). Steady-state (registry warm) this is ~free — the
212    // serial build is a cache hit; only a genuinely cold build pays for it.
213    {
214        let first: &ClusterWorker = remotes[0];
215        if remotes.len() > 1 {
216            eprintln!(
217                "fdl: pre-flight warming shared cargo registry via {} (serial) before parallel builds",
218                first.host,
219            );
220        }
221        match prebuild_one_worker(
222            &project_root, &cmd_cwd, &controller_path,
223            first, &cmd_name,
224            controller_docker_svc.as_deref(),
225        ) {
226            Ok(env_entry) => {
227                eprintln!("fdl: pre-flight OK ({})", first.host);
228                envelope.lock().unwrap().insert(first.host.clone(), env_entry);
229            }
230            Err(e) => {
231                eprintln!("fdl: pre-flight FAILED ({}): {}", first.host, e);
232                errors.lock().unwrap().push(format!("{}: {}", first.host, e));
233            }
234        }
235    }
236
237    // Remaining workers: parallel, registry now warm.
238    let mut handles = Vec::with_capacity(remotes.len().saturating_sub(1));
239    for worker in &remotes[1..] {
240        let worker = (*worker).clone();
241        let project_root = Arc::clone(&project_root);
242        let cmd_cwd = Arc::clone(&cmd_cwd);
243        let cmd_name = Arc::clone(&cmd_name);
244        let controller_path = Arc::clone(&controller_path);
245        let controller_docker_svc = Arc::clone(&controller_docker_svc);
246        let errors = Arc::clone(&errors);
247        let envelope = Arc::clone(&envelope);
248        handles.push(thread::spawn(move || {
249            match prebuild_one_worker(
250                &project_root, &cmd_cwd, &controller_path,
251                &worker, &cmd_name,
252                controller_docker_svc.as_deref(),
253            ) {
254                Ok(env_entry) => {
255                    eprintln!("fdl: pre-flight OK ({})", worker.host);
256                    envelope.lock().unwrap().insert(worker.host.clone(), env_entry);
257                }
258                Err(e) => {
259                    eprintln!("fdl: pre-flight FAILED ({}): {}", worker.host, e);
260                    errors.lock().unwrap().push(format!("{}: {}", worker.host, e));
261                }
262            }
263        }));
264    }
265    for h in handles {
266        let _ = h.join();
267    }
268
269    let errs = Arc::try_unwrap(errors)
270        .map_err(|_| "internal: error collector still has outstanding refs".to_string())?
271        .into_inner()
272        .map_err(|e| format!("internal: errors mutex poisoned: {e}"))?;
273    if !errs.is_empty() {
274        return Err(format!(
275            "pre-flight build failed on {} host(s):\n  {}",
276            errs.len(),
277            errs.join("\n  "),
278        ));
279    }
280
281    // Emit the per-host envelope so the flodl launcher's remote
282    // dispatch can substitute the direct-binary form for each host
283    // (skipping the `fdl <cmd>` re-entry — no cargo on remote).
284    let env_map = Arc::try_unwrap(envelope)
285        .map_err(|_| "internal: envelope still has outstanding refs".to_string())?
286        .into_inner()
287        .map_err(|e| format!("internal: envelope mutex poisoned: {e}"))?;
288    let json = serde_json::to_string(&env_map)
289        .map_err(|e| format!("internal: serialize prebuild envelope: {e}"))?;
290    // SAFETY: main has not spawned threads at this point in dispatch.
291    unsafe { std::env::set_var(ENV_PREBUILD_PER_HOST, json); }
292    Ok(())
293}
294
295/// Per-host pre-flight build artifact descriptor — exactly what the
296/// launcher needs to substitute the remote dispatch with a direct
297/// binary exec. Mirrors `flodl::distributed::launcher::PerHostPrebuild`
298/// on the consumer side; the two structs share an on-the-wire JSON
299/// schema but are independent types because the crates can't share
300/// declarations without a circular dep.
301#[derive(Clone, Debug, Serialize, Deserialize)]
302pub struct PerHostEnvelope {
303    /// Path to the compiled binary, relative to the host's project
304    /// checkout (`worker.path`). e.g.
305    /// `target/cluster/node-b/precompiled-cu128/release/train`.
306    pub bin: String,
307    /// Absolute path the launcher should set as `LD_LIBRARY_PATH` so
308    /// the binary finds its libtorch at runtime. e.g.
309    /// `/home/me/rdl/libtorch/builds/sm61-sm120/lib`. The launcher may
310    /// append host-specific extras (e.g. `:/usr/local/lib` for bare-
311    /// metal libnccl) via `worker.env: { LD_LIBRARY_PATH: ... }`.
312    pub ld_library_path: String,
313    /// Subdirectory under the host's project checkout to `cd` into
314    /// before exec — the relative offset of the command's filesystem
315    /// cwd from `project_root`. Mirrors the cwd the controller-side
316    /// build used (e.g. `ddp-bench` for `fdl ddp-bench`). Empty string
317    /// means execute from `worker.path` directly. Relative-path defaults
318    /// the binary expects (e.g. `--data-dir data`, `--output runs/`)
319    /// only resolve correctly when the remote cwd matches.
320    #[serde(default, skip_serializing_if = "String::is_empty")]
321    pub cwd_subpath: String,
322}
323
324/// Build `cmd_name` for one worker. Picks docker service + cargo
325/// features from the host's libtorch metadata. Returns a
326/// [`PerHostEnvelope`] describing where the resulting binary lives
327/// (so the launcher can substitute it on the remote-dispatch path)
328/// and what `LD_LIBRARY_PATH` the remote should set.
329///
330/// `controller_path` is the controller's view of the shared project
331/// root. The libtorch convention says the variant lives at
332/// `<controller_path>/libtorch/<worker.arch>` for the build (controller
333/// view) and `<worker.path>/libtorch/<worker.arch>` for the runtime
334/// (remote view). Both point at the same physical libtorch via the
335/// shared mount; the two paths differ only when controller and remote
336/// see the project at different filesystem locations.
337/// Outcome of the ABI-compatibility check between the controller's
338/// build environment and a remote host.
339#[derive(Debug, PartialEq)]
340enum AbiCheck {
341    /// Compatible (arch matches, remote is glibc). `warning` is `Some`
342    /// for a soft glibc-version skew note the caller should surface.
343    Ok { warning: Option<String> },
344    /// Definitively incompatible — the prebuilt binary cannot exec on
345    /// the remote. Hard error before fan-out.
346    Incompatible(String),
347}
348
349/// Compare the controller build environment against a remote host's
350/// `uname -m` + `ldd --version` output. Pure — no I/O — so it is
351/// directly unit-tested; [`preflight_one_host`] supplies the live inputs.
352///
353/// - **arch** must match exactly: an x86-64 binary is `Exec format
354///   error` on aarch64. Hard error.
355/// - **libc flavor**: the flodl build images are glibc-based, so a musl
356///   (Alpine) remote cannot run the glibc-linked binary. Hard error.
357/// - **glibc version**: a soft warning only. It often works, fails only
358///   when the remote glibc is OLDER than the build env's (`GLIBC_2.XX
359///   not found`), and that error at least names glibc — far less
360///   cryptic than the two hard cases. We do not parse/order versions
361///   here (that needs the build-container glibc too); we just flag when
362///   the remote's reported glibc line is worth the operator's eye.
363fn check_remote_abi(
364    host: &str,
365    controller_arch: &str,
366    remote_uname_m: &str,
367    remote_ldd: &str,
368) -> AbiCheck {
369    let remote_arch = remote_uname_m.trim();
370    if remote_arch.is_empty() {
371        // Couldn't read arch — treat as indeterminate, not a mismatch
372        // (see preflight_one_host's unreachable-is-a-warning discipline).
373        return AbiCheck::Ok {
374            warning: Some(format!(
375                "host {host:?}: could not read remote `uname -m`; skipping \
376                 ABI pre-check (a real mismatch would surface at fan-out)"
377            )),
378        };
379    }
380    if remote_arch != controller_arch {
381        return AbiCheck::Incompatible(format!(
382            "host {host:?}: CPU arch mismatch — the pre-built binary is \
383             {controller_arch} (controller build env) but the remote is \
384             {remote_arch}. A cross-arch binary cannot exec (`Exec format \
385             error`). Run same-arch hosts, or build per-arch."
386        ));
387    }
388    let ldd_lc = remote_ldd.to_ascii_lowercase();
389    if ldd_lc.contains("musl") {
390        return AbiCheck::Incompatible(format!(
391            "host {host:?}: remote uses musl libc, but the pre-built binary \
392             is glibc-linked (flodl build images are glibc-based) and cannot \
393             run there. Match the libc (glibc remote), or build on the remote."
394        ));
395    }
396    // Soft glibc note: surface the remote's reported version line when we
397    // could read one, so a later `GLIBC_… not found` is unsurprising. No
398    // hard gate — build on the OLDEST-glibc host to be safe.
399    let looks_glibc = ldd_lc.contains("glibc") || ldd_lc.contains("gnu libc");
400    let warning = remote_ldd
401        .lines()
402        .next()
403        .map(str::trim)
404        .filter(|l| !l.is_empty() && looks_glibc)
405        .map(|l| {
406            format!(
407                "host {host:?}: remote glibc reports `{l}`. If the run later \
408                 fails with `GLIBC_… not found`, the remote glibc is older \
409                 than the controller build env — build on the oldest-glibc \
410                 host."
411            )
412        });
413    AbiCheck::Ok { warning }
414}
415
416/// Evaluate a host's shared-data-path readiness from probe results.
417/// `dir_ok` = the path exists and is a directory; `read_ok` = it is
418/// readable/listable. `explicit` = the host (or cluster.yml) declared
419/// `data_path:` rather than falling back to the convention default.
420///
421/// Pure — no I/O — so it is directly unit-tested; the local (controller)
422/// and remote (ssh) paths in [`preflight_hosts`] / [`preflight_one_host`]
423/// supply `dir_ok` / `read_ok`. Mirrors `probe::check_data_path`'s
424/// policy: a missing EXPLICIT path is launch-breaking (`Err`); a missing
425/// CONVENTION-default is a warning (users without shared storage are
426/// fine); a present-but-unreadable path is always an error.
427fn check_remote_data_path(
428    host: &str,
429    path: &str,
430    explicit: bool,
431    dir_ok: bool,
432    read_ok: bool,
433) -> Result<Option<String>, String> {
434    if !dir_ok {
435        if explicit {
436            return Err(format!(
437                "host {host:?}: shared data path `{path}` does not exist (or is \
438                 not a directory). flodl assumes a shared filesystem (NAS / SMB \
439                 / virtiofs / SSHFS) mounted at the same logical path on every \
440                 node — training reads data and writes checkpoints there. Mount \
441                 it, or correct `data_path:` in cluster.yml."
442            ));
443        }
444        return Ok(Some(format!(
445            "host {host:?}: convention shared-data path `{path}` not present (no \
446             `data_path:` declared). Ignore if you don't use shared storage; \
447             otherwise set `data_path:` per host or mount `{path}`."
448        )));
449    }
450    if !read_ok {
451        return Err(format!(
452            "host {host:?}: shared data path `{path}` exists but is not readable \
453             by the remote user. Check mount permissions / uid mapping."
454        ));
455    }
456    Ok(None)
457}
458
459/// One remote host's pre-fan-out readiness probe over a single ssh.
460///
461/// ALWAYS checks the shared `data_path` is mounted + readable. When
462/// `prebuilding`, ALSO gathers `uname -m` + `ldd --version` + `pkill`
463/// availability and runs the [`check_remote_abi`] gate — the ABI check
464/// only matters when a controller-built binary is shipped to the remote
465/// (the prebuild path); under `--no-prebuild` the remote re-enters
466/// `fdl <cmd>` and builds/runs natively, so arch/libc can't mismatch.
467/// Returns collected warnings on success, `Err(msg)` on a definitive
468/// failure (ABI mismatch, or a missing EXPLICIT data_path).
469///
470/// UNREACHABLE-IS-A-WARNING: if the probe ssh itself fails (blip, host
471/// down), we return warnings and proceed — a transient probe failure
472/// must never make things worse than the status quo; the real fan-out
473/// ssh surfaces a genuine connectivity error anyway. The probe only ever
474/// ADDS loud errors for definitive mismatches / missing declared mounts.
475fn preflight_one_host(worker: &ClusterWorker, prebuilding: bool) -> Result<Vec<String>, String> {
476    let target = worker
477        .ssh
478        .as_ref()
479        .and_then(|s| s.target.as_deref())
480        .unwrap_or(&worker.host);
481    let dp = worker.effective_data_path().to_string();
482    let dp_explicit = worker.data_path.is_some();
483
484    // One round-trip. data_path tests always; the ABI block (uname / ldd
485    // banner — its version line lands on stderr for glibc, stdout for
486    // some, so capture both — plus a `pkill` availability probe) only
487    // when prebuilding. All sentinel-delimited.
488    let mut script = format!(
489        "if [ -d {q} ]; then echo __FLODL_DP_DIR__=1; else echo __FLODL_DP_DIR__=0; fi; \
490         if [ -r {q} ]; then echo __FLODL_DP_READ__=1; else echo __FLODL_DP_READ__=0; fi",
491        q = posix_quote(&dp),
492    );
493    if prebuilding {
494        script.push_str(
495            "; echo __FLODL_ABI__; uname -m; echo __FLODL_LDD__; \
496             ldd --version 2>&1 | head -1; echo __FLODL_PKILL__; \
497             command -v pkill >/dev/null 2>&1 && echo present || echo absent",
498        );
499    }
500
501    let mut cmd = Command::new("ssh");
502    // User ssh.options first (they win), then flodl's defaults (M17).
503    apply_worker_ssh_opts(&mut cmd, worker);
504    cmd.args(["-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5"]);
505    cmd.arg(target);
506    cmd.arg(&script);
507    let output = match cmd.output() {
508        Ok(o) => o,
509        Err(e) => {
510            return Ok(vec![format!(
511                "host {:?}: remote pre-check ssh spawn failed ({e}); skipping \
512                 (a real mismatch / missing mount would surface at fan-out)",
513                worker.host,
514            )]);
515        }
516    };
517    if !output.status.success() {
518        return Ok(vec![format!(
519            "host {:?}: remote pre-check ssh exited {}; skipping (a real \
520             mismatch / missing mount would surface at fan-out)",
521            worker.host, output.status,
522        )]);
523    }
524    let stdout = String::from_utf8_lossy(&output.stdout);
525
526    let mut warnings = Vec::new();
527    // data_path verdict (always). Exact-sentinel match: `=0` never
528    // matches `=1`, so a plain `contains` is unambiguous.
529    let dir_ok = stdout.contains("__FLODL_DP_DIR__=1");
530    let read_ok = stdout.contains("__FLODL_DP_READ__=1");
531    if let Some(w) = check_remote_data_path(&worker.host, &dp, dp_explicit, dir_ok, read_ok)? {
532        warnings.push(w);
533    }
534
535    // ABI verdict (prebuild path only).
536    if prebuilding {
537        let abi_part = stdout
538            .split_once("__FLODL_ABI__")
539            .map(|(_, r)| r)
540            .unwrap_or("");
541        let (uname_m, rest) = abi_part
542            .split_once("__FLODL_LDD__")
543            .unwrap_or((abi_part, ""));
544        let (ldd, pkill_field) = rest
545            .split_once("__FLODL_PKILL__")
546            .unwrap_or((rest, ""));
547        let controller_arch = std::env::consts::ARCH;
548        match check_remote_abi(&worker.host, controller_arch, uname_m, ldd) {
549            AbiCheck::Ok { warning } => warnings.extend(warning),
550            AbiCheck::Incompatible(msg) => return Err(msg),
551        }
552        // Only warn on an EXPLICIT "absent" — an empty/garbled field
553        // (older remote, parse hiccup) is indeterminate and must not
554        // false-warn, mirroring the empty-arch discipline in
555        // `check_remote_abi`.
556        if pkill_field.trim() == "absent" {
557            warnings.push(format!(
558                "host {:?}: `pkill` not found on the remote — belt-and-braces \
559                 orphan cleanup is disabled; teardown relies solely on the \
560                 per-host trap wrapper (kills by known pid, no external tool). \
561                 Install procps if you want pre-spawn stale-orphan clearing.",
562                worker.host,
563            ));
564        }
565    }
566    Ok(warnings)
567}
568
569/// Collapse a libtorch `arch:` subpath (`precompiled/cu128`,
570/// `builds/sm61-sm120`) into a single path segment for use in the
571/// per-`(host, arch)` cargo target dir. Only the path separators need
572/// folding (`/` and, defensively, `\`); everything else in a libtorch
573/// variant name is already a safe path atom.
574fn arch_slug(arch: &str) -> String {
575    arch.chars()
576        .map(|c| if c == '/' || c == '\\' { '-' } else { c })
577        .collect()
578}
579
580fn prebuild_one_worker(
581    project_root: &Path,
582    cmd_cwd: &Path,
583    controller_path: &Path,
584    worker: &ClusterWorker,
585    cmd_name: &str,
586    controller_docker_svc: Option<&str>,
587) -> Result<PerHostEnvelope, String> {
588    let arch = worker.arch.as_ref().ok_or_else(|| {
589        format!(
590            "host {:?} has no `arch:` set in cluster.yml — \
591             pre-flight build needs the libtorch variant subpath \
592             (e.g. `arch: precompiled/cu128` or `arch: builds/sm61-sm120`)",
593            worker.host,
594        )
595    })?;
596    // Controller-side libtorch variant dir, resolved via convention.
597    let controller_variant_dir = controller_path.join("libtorch").join(arch);
598    if !controller_variant_dir.join("lib").is_dir() {
599        return Err(format!(
600            "host {:?}: controller-side libtorch at `{}` (resolved from \
601             `<controller_path>/libtorch/<arch>`) does not look like a \
602             valid libtorch install (missing `lib/`?)",
603            worker.host,
604            controller_variant_dir.display(),
605        ));
606    }
607    let host_path = controller_variant_dir.display().to_string();
608    // ABI + shared-mount pre-checks ran earlier in `preflight_hosts`
609    // (one ssh per host, before any build), so by the time we reach the
610    // multi-minute build the host is already known compatible + mounted.
611    // Derive features + docker service from the YAML-declared `arch:`
612    // basename — single source of truth, no `.arch` metadata file
613    // required. `cpu` is the only non-CUDA variant by convention; every
614    // other basename (`cuNN`, `sm<NN>-sm<NN>`, etc.) is a GPU build.
615    // `arch:` basename → cargo --features (`cuda` for GPU variants, none
616    // for `cpu`). The compose SERVICE is `controller.docker` (above),
617    // not arch-derived — the controller's toolchain builds every host.
618    let (features_arg, _) = features_and_service_from_arch(arch);
619    let cuda_version_for_image = cuda_version_from_arch(arch);
620    // Key the target dir by host AND arch. Host alone is not enough: the
621    // `arch:` field selects the libtorch variant, but in docker mode that
622    // variant is bind-mounted onto the FIXED container path
623    // (`/usr/local/libtorch`, `Dockerfile` ENV LIBTORCH_PATH), so
624    // `LIBTORCH_PATH` inside the container never changes when `arch:`
625    // does. Change a host's `arch:` to a different torch/CUDA build in the
626    // same feature category (e.g. `precompiled/cu118` -> `precompiled/cu128`,
627    // both `--features cuda`) and nothing cargo fingerprints changes:
628    // cargo would reuse the stale binary linked against the OLD libtorch,
629    // which then execs at fan-out with the NEW `LD_LIBRARY_PATH` -> ABI
630    // mismatch (`undefined symbol`) or silently-wrong kernels. An env-only
631    // nudge can't fix it: the mount path is fixed, so build.rs re-emits the
632    // same `-L` string and cargo never relinks. A distinct target dir per
633    // (host, arch) is the only thing that forces the rebuild. `arch` is a
634    // libtorch subpath (`precompiled/cu128`, `builds/sm61-sm120`); slugify
635    // its `/` so it is a single path segment.
636    let target_dir_relative =
637        format!("target/cluster/{}/{}", worker.host, arch_slug(arch));
638
639    // Two execution modes — docker-backed (controller has `docker:`
640    // set in cluster.yml) or native cargo on the host filesystem.
641    //
642    // Docker mode: the project root mounts at `/workspace`; cwd +
643    // CARGO_TARGET_DIR are in the `/workspace/...` namespace. Docker
644    // mode is gated on `controller.docker` being set, and that value
645    // IS the build service — the controller owns one build toolchain
646    // and compiles every host's binary in it (per-host libtorch comes
647    // from LIBTORCH_HOST_PATH, per-host features from `arch:`). The
648    // service must be a superset toolchain (`cuda` builds both CUDA and
649    // CPU binaries); a service that lacks the CUDA toolkit fails loudly
650    // at cargo build for a CUDA-arch worker, naming the chosen service.
651    //
652    // Native mode: cwd is the cmd's filesystem cwd, CARGO_TARGET_DIR
653    // is the same project-root-relative path on the host, and
654    // LIBTORCH_PATH is set directly on the cargo process (no Docker
655    // bind-mount indirection).
656    let (sh_cmd, cwd_for_spawn, extra_envs): (String, &Path, Vec<(&str, String)>) =
657        if let Some(docker_svc) = controller_docker_svc {
658            // Docker-backed build.
659            let target_dir_in_container = format!("/workspace/{target_dir_relative}");
660            let sub_path = cmd_cwd
661                .strip_prefix(project_root)
662                .map(|p| p.to_string_lossy().into_owned())
663                .unwrap_or_default();
664            let cwd_in_container = if sub_path.is_empty() {
665                "/workspace".to_string()
666            } else {
667                format!("/workspace/{sub_path}")
668            };
669            let build_cmd = if features_arg.is_empty() {
670                format!(
671                    "cd {cwd} && CARGO_TARGET_DIR={tgt} cargo build --release --bin {bin}",
672                    cwd = posix_quote(&cwd_in_container),
673                    tgt = posix_quote(&target_dir_in_container),
674                    bin = posix_quote(cmd_name),
675                )
676            } else {
677                format!(
678                    "cd {cwd} && CARGO_TARGET_DIR={tgt} cargo build --release --features {feat} --bin {bin}",
679                    cwd = posix_quote(&cwd_in_container),
680                    tgt = posix_quote(&target_dir_in_container),
681                    feat = posix_quote(features_arg),
682                    bin = posix_quote(cmd_name),
683                )
684            };
685            let docker_cmd = format!(
686                "docker compose run --rm {svc} bash -c {inner}",
687                svc = docker_svc,
688                inner = posix_quote(&build_cmd),
689            );
690            (docker_cmd, project_root, vec![
691                ("LIBTORCH_HOST_PATH", host_path.clone()),
692                ("LIBTORCH_CPU_PATH", "./libtorch/precompiled/cpu".into()),
693            ])
694        } else {
695            // Native build (no docker on controller).
696            let target_dir_abs = project_root.join(&target_dir_relative);
697            let bash_cmd = if features_arg.is_empty() {
698                format!(
699                    "cargo build --release --bin {bin}",
700                    bin = posix_quote(cmd_name),
701                )
702            } else {
703                format!(
704                    "cargo build --release --features {feat} --bin {bin}",
705                    feat = posix_quote(features_arg),
706                    bin = posix_quote(cmd_name),
707                )
708            };
709            (bash_cmd, cmd_cwd, vec![
710                ("LIBTORCH_PATH", host_path.clone()),
711                (
712                    "CARGO_TARGET_DIR",
713                    target_dir_abs.to_string_lossy().into_owned(),
714                ),
715            ])
716        };
717
718    let mut cmd = Command::new("sh");
719    cmd.args(["-c", &sh_cmd])
720        .current_dir(cwd_for_spawn)
721        .stdout(Stdio::inherit())
722        .stderr(Stdio::inherit())
723        .stdin(Stdio::null());
724    for (k, v) in &extra_envs {
725        cmd.env(k, v);
726    }
727
728    if let Some(cuda_version) = &cuda_version_for_image {
729        let normalised = if cuda_version.matches('.').count() < 2 {
730            format!("{cuda_version}.0")
731        } else {
732            cuda_version.clone()
733        };
734        let cuda_tag = normalised
735            .splitn(3, '.')
736            .take(2)
737            .collect::<Vec<_>>()
738            .join(".");
739        cmd.env("CUDA_VERSION", &normalised);
740        cmd.env("CUDA_TAG", &cuda_tag);
741    }
742
743    let status = cmd
744        .status()
745        .map_err(|e| format!("spawn `{sh_cmd}`: {e}"))?;
746    if !status.success() {
747        return Err(format!(
748            "cargo build exited {} (libtorch={host_path}, target={target_dir_relative}, \
749             features={feat})",
750            status.code().unwrap_or(-1),
751            feat = if features_arg.is_empty() { "(none)" } else { features_arg },
752        ));
753    }
754    // Runtime LD_LIBRARY_PATH uses the REMOTE-side view: the rank
755    // exec's the binary on the remote, where libtorch is at
756    // `<worker.path>/libtorch/<arch>/lib` per the convention.
757    let runtime_lib = format!(
758        "{path}/libtorch/{arch}/lib",
759        path = worker.path.trim_end_matches('/'),
760    );
761    let _ = host_path; // controller-side path used only for the build above
762    // cwd_subpath: the cmd's filesystem cwd relative to project_root.
763    // For `fdl ddp-bench` invoked from the repo, cmd_cwd is
764    // `<repo>/ddp-bench`, so subpath is `ddp-bench`. The remote
765    // launcher uses this to cd into the matching subdir before exec.
766    let cwd_subpath = cmd_cwd
767        .strip_prefix(project_root)
768        .map(|p| p.to_string_lossy().into_owned())
769        .unwrap_or_default();
770    Ok(PerHostEnvelope {
771        bin: format!("{target_dir_relative}/release/{cmd_name}"),
772        ld_library_path: runtime_lib,
773        cwd_subpath,
774    })
775}
776
777/// Pick cargo features + docker compose service from the host's
778/// libtorch `.arch` metadata. `cuda=12.x` → (`cuda`, `cuda`); anything
779/// else → (`""`, `dev`).
780/// Derive `(cargo --features arg, docker-compose service name)` from
781/// the YAML `arch:` path basename. The yml `arch:` IS the single
782/// source of truth (no `.arch` metadata file required) — `cpu` is the
783/// only non-CUDA convention; everything else is a GPU variant.
784fn features_and_service_from_arch(arch: &str) -> (&'static str, &'static str) {
785    let basename = std::path::Path::new(arch)
786        .file_name()
787        .and_then(|n| n.to_str())
788        .unwrap_or("");
789    if basename == "cpu" {
790        ("", "dev")
791    } else {
792        ("cuda", "cuda")
793    }
794}
795
796/// Extract a CUDA major.minor string from a `precompiled/cuNN` arch
797/// path basename (e.g. `cu128` → `"12.8"`). Returns `None` for source
798/// builds (`builds/sm…`) where the arch alone does not encode a CUDA
799/// version — the caller falls back to the `CUDA_VERSION` env var (or
800/// docker-compose's own default) for the toolkit image tag.
801fn cuda_version_from_arch(arch: &str) -> Option<String> {
802    let basename = std::path::Path::new(arch)
803        .file_name()
804        .and_then(|n| n.to_str())
805        .unwrap_or("");
806    let rest = basename.strip_prefix("cu")?;
807    if rest.len() < 2 || !rest.chars().all(|c| c.is_ascii_digit()) {
808        return None;
809    }
810    let major = &rest[..rest.len() - 1];
811    let minor = &rest[rest.len() - 1..];
812    Some(format!("{major}.{minor}"))
813}
814
815use crate::util::shell::posix_quote;
816
817#[cfg(test)]
818mod tests {
819    use super::*;
820
821    #[test]
822    fn abi_arch_mismatch_is_hard_incompatible() {
823        let r = check_remote_abi("gv", "x86_64", "aarch64", "ldd (GNU libc) 2.31");
824        assert!(matches!(r, AbiCheck::Incompatible(m) if m.contains("arch mismatch")));
825    }
826
827    #[test]
828    fn data_path_present_and_readable_is_clean() {
829        assert!(check_remote_data_path("h", "/flodl/data", true, true, true)
830            .expect("ok")
831            .is_none());
832    }
833
834    #[test]
835    fn data_path_missing_explicit_is_hard_error() {
836        let err = check_remote_data_path("h", "/mnt/nas", true, false, false)
837            .expect_err("explicit missing must be a hard error");
838        assert!(err.contains("does not exist"), "err: {err}");
839        assert!(err.contains("/mnt/nas"), "err: {err}");
840    }
841
842    #[test]
843    fn data_path_missing_convention_default_is_warning() {
844        // explicit=false -> convention default /flodl/data not present ->
845        // warning (Ok), not a launch-breaking error.
846        let w = check_remote_data_path("h", "/flodl/data", false, false, false)
847            .expect("convention-default missing must be Ok(warning)")
848            .expect("should carry a warning");
849        assert!(w.contains("convention"), "warn: {w}");
850    }
851
852    #[test]
853    fn data_path_present_but_unreadable_is_hard_error() {
854        let err = check_remote_data_path("h", "/flodl/data", false, true, false)
855            .expect_err("present-but-unreadable must be a hard error regardless of explicit");
856        assert!(err.contains("not readable"), "err: {err}");
857    }
858
859    #[test]
860    fn abi_musl_is_hard_incompatible_on_matching_arch() {
861        let r = check_remote_abi("alp", "x86_64", "x86_64", "musl libc (x86_64)\nVersion 1.2.4");
862        assert!(matches!(r, AbiCheck::Incompatible(m) if m.contains("musl")));
863    }
864
865    #[test]
866    fn abi_matching_arch_glibc_ok_with_version_note() {
867        let r = check_remote_abi(
868            "w", "x86_64", "x86_64",
869            "ldd (Ubuntu GLIBC 2.35-0ubuntu3.4) 2.35",
870        );
871        match r {
872            AbiCheck::Ok { warning: Some(w) } => {
873                assert!(w.contains("2.35"), "warning should quote the reported line: {w}");
874            }
875            other => panic!("expected Ok+warning, got {other:?}"),
876        }
877    }
878
879    #[test]
880    fn abi_empty_uname_is_indeterminate_not_mismatch() {
881        let r = check_remote_abi("w", "x86_64", "", "");
882        assert!(matches!(r, AbiCheck::Ok { warning: Some(_) }));
883    }
884
885    #[test]
886    fn abi_arch_checked_before_musl() {
887        let r = check_remote_abi("x", "x86_64", "aarch64", "musl libc");
888        assert!(matches!(r, AbiCheck::Incompatible(m) if m.contains("arch mismatch")));
889    }
890
891    #[test]
892    fn abi_matching_arch_no_ldd_ok_no_warning() {
893        let r = check_remote_abi("w", "x86_64", "x86_64", "");
894        assert_eq!(r, AbiCheck::Ok { warning: None });
895    }
896
897    #[test]
898    fn features_and_service_precompiled_cuda_picks_cuda() {
899        assert_eq!(
900            features_and_service_from_arch("precompiled/cu128"),
901            ("cuda", "cuda")
902        );
903    }
904
905    #[test]
906    fn features_and_service_precompiled_cpu_picks_dev() {
907        assert_eq!(
908            features_and_service_from_arch("precompiled/cpu"),
909            ("", "dev")
910        );
911    }
912
913    #[test]
914    fn features_and_service_source_build_picks_cuda() {
915        // Source builds under `builds/<gpu-arch>` are CUDA by
916        // convention; only `cpu` basename is non-CUDA.
917        assert_eq!(
918            features_and_service_from_arch("builds/sm61-sm120"),
919            ("cuda", "cuda")
920        );
921        assert_eq!(
922            features_and_service_from_arch("builds/sm80"),
923            ("cuda", "cuda")
924        );
925    }
926
927    #[test]
928    fn cuda_version_from_arch_extracts_precompiled_version() {
929        assert_eq!(cuda_version_from_arch("precompiled/cu128"), Some("12.8".into()));
930        assert_eq!(cuda_version_from_arch("precompiled/cu126"), Some("12.6".into()));
931        assert_eq!(cuda_version_from_arch("precompiled/cu118"), Some("11.8".into()));
932    }
933
934    #[test]
935    fn cuda_version_from_arch_none_for_source_builds_and_cpu() {
936        assert_eq!(cuda_version_from_arch("builds/sm61-sm120"), None);
937        assert_eq!(cuda_version_from_arch("builds/sm80"), None);
938        assert_eq!(cuda_version_from_arch("precompiled/cpu"), None);
939    }
940
941    #[test]
942    fn arch_slug_folds_path_separators_only() {
943        // A change of `arch:` must yield a DISTINCT slug so the per-host
944        // target dir keys on it — otherwise a docker-mode arch swap reuses
945        // a stale binary (M24). Different variants -> different slugs.
946        assert_eq!(arch_slug("precompiled/cu128"), "precompiled-cu128");
947        assert_eq!(arch_slug("precompiled/cu118"), "precompiled-cu118");
948        assert_ne!(arch_slug("precompiled/cu128"), arch_slug("precompiled/cu118"));
949        assert_eq!(arch_slug("builds/sm61-sm120"), "builds-sm61-sm120");
950        // Single-segment archs pass through unchanged.
951        assert_eq!(arch_slug("cpu"), "cpu");
952    }
953
954    #[test]
955    fn posix_quote_round_trips_safe_strings() {
956        assert_eq!(posix_quote("ddp-bench"), "ddp-bench");
957        assert_eq!(posix_quote("target/cluster/exa"), "target/cluster/exa");
958        assert_eq!(posix_quote(""), "''");
959    }
960
961    #[test]
962    fn posix_quote_wraps_unsafe_strings() {
963        assert_eq!(posix_quote("a b"), "'a b'");
964        assert_eq!(posix_quote("it's"), "'it'\\''s'");
965    }
966
967    #[test]
968    fn envelope_serializes_to_stable_json() {
969        let mut env = BTreeMap::new();
970        env.insert(
971            "host-b".to_string(),
972            PerHostEnvelope {
973                bin: "target/cluster/host-b/release/bench".into(),
974                ld_library_path: "/opt/lt-b/lib".into(),
975                cwd_subpath: String::new(),
976            },
977        );
978        env.insert(
979            "host-a".to_string(),
980            PerHostEnvelope {
981                bin: "target/cluster/host-a/release/bench".into(),
982                ld_library_path: "/opt/lt-a/lib".into(),
983                cwd_subpath: String::new(),
984            },
985        );
986        let json = serde_json::to_string(&env).unwrap();
987        // BTreeMap iterates in sorted key order ⇒ stable JSON output
988        // regardless of insertion order.
989        assert_eq!(
990            json,
991            r#"{"host-a":{"bin":"target/cluster/host-a/release/bench","ld_library_path":"/opt/lt-a/lib"},"host-b":{"bin":"target/cluster/host-b/release/bench","ld_library_path":"/opt/lt-b/lib"}}"#,
992        );
993    }
994
995    #[test]
996    fn envelope_round_trips_through_serde() {
997        let mut env = BTreeMap::new();
998        env.insert(
999            "h1".to_string(),
1000            PerHostEnvelope {
1001                bin: "t/c/h1/release/x".into(),
1002                ld_library_path: "/opt/lt/lib".into(),
1003                cwd_subpath: "ddp-bench".into(),
1004            },
1005        );
1006        let json = serde_json::to_string(&env).unwrap();
1007        let back: BTreeMap<String, PerHostEnvelope> =
1008            serde_json::from_str(&json).unwrap();
1009        assert_eq!(back.len(), 1);
1010        let e = back.get("h1").unwrap();
1011        assert_eq!(e.bin, "t/c/h1/release/x");
1012        assert_eq!(e.ld_library_path, "/opt/lt/lib");
1013    }
1014}