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