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}