use std::collections::BTreeMap;
use std::env;
use std::io::{BufRead, BufReader};
use std::process::{Command, ExitStatus, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use serde::Deserialize;
use crate::tensor::{Result, TensorError};
use super::{ENV_FDL_ENV, ENV_FULL_CLUSTER_JSON, ENV_PREBUILD_PER_HOST};
use super::{FullCluster, FullWorker};
#[derive(Clone, Debug, Deserialize)]
pub(super) struct PerHostPrebuild {
pub(super) bin: String,
pub(super) ld_library_path: String,
#[serde(default)]
pub(super) cwd_subpath: String,
}
pub(super) fn load_prebuild_envelope() -> Result<BTreeMap<String, PerHostPrebuild>> {
let raw = match env::var(ENV_PREBUILD_PER_HOST) {
Ok(v) if !v.trim().is_empty() => v,
_ => return Ok(BTreeMap::new()),
};
serde_json::from_str(&raw).map_err(|e| {
TensorError::new(&format!(
"cluster launcher: parse {ENV_PREBUILD_PER_HOST} JSON: {e}",
))
})
}
pub(super) const SSH_OPTS: &[&str] = &[
"-T",
"-o",
"ServerAliveInterval=10",
"-o",
"ServerAliveCountMax=3",
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=accept-new",
];
pub(super) type SupervisedChild = (
String,
usize,
Vec<usize>,
std::process::Child,
Vec<thread::JoinHandle<()>>,
);
const WATCH_POLL_INTERVAL: Duration = Duration::from_millis(100);
pub(super) const RELAY_RANK_SENTINEL: usize = usize::MAX;
pub(super) const AGENT_RANK_SENTINEL: usize = usize::MAX - 1;
fn child_label(lr: usize, host: &str) -> String {
if lr == RELAY_RANK_SENTINEL {
format!("relay of {host}")
} else if lr == AGENT_RANK_SENTINEL {
format!("agent of {host}")
} else {
format!("rank {lr} of {host}")
}
}
fn exit_status_desc(st: &ExitStatus) -> String {
match st.code() {
Some(c) => format!("status {c}"),
None => {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
match st.signal() {
Some(sig) => format!("signal {sig}"),
None => "unknown status".to_string(),
}
}
#[cfg(not(unix))]
{
"unknown status".to_string()
}
}
}
}
pub(super) fn supervise_children(
children: Vec<SupervisedChild>,
elastic: Option<ElasticSupervision>,
run_abort: Option<Arc<AtomicBool>>,
) -> Option<TensorError> {
if children.is_empty() {
return None;
}
let kill_all = Arc::new(AtomicBool::new(false));
let (tx, rx) =
mpsc::channel::<(String, usize, Vec<usize>, std::io::Result<ExitStatus>)>();
let mut watchers: Vec<thread::JoinHandle<()>> = Vec::with_capacity(children.len());
let mut all_forwarders: Vec<thread::JoinHandle<()>> = Vec::new();
for (host, lr, granks, mut child, fwd) in children {
all_forwarders.extend(fwd);
let txc = tx.clone();
let kill_flag = Arc::clone(&kill_all);
let abort_flag = run_abort.clone();
watchers.push(thread::spawn(move || {
let mut killed = false;
let st = loop {
match child.try_wait() {
Ok(Some(status)) => break Ok(status),
Ok(None) => {
let abort_hit = abort_flag
.as_ref()
.is_some_and(|f| f.load(Ordering::SeqCst));
if !killed && (kill_flag.load(Ordering::SeqCst) || abort_hit) {
let _ = child.kill();
killed = true;
}
thread::sleep(WATCH_POLL_INTERVAL);
}
Err(e) => break Err(e),
}
};
let _ = txc.send((host, lr, granks, st));
}));
}
drop(tx);
let mut any_failure: Option<TensorError> = None;
let mut finished: std::collections::HashSet<(String, usize)> =
std::collections::HashSet::new();
let mut terminated_peers = false;
let mut tolerated_deaths: usize = 0;
while let Ok((host, lr, granks, st)) = rx.recv() {
finished.insert((host.clone(), lr));
let failure_msg: Option<String> = match st {
Ok(s) if s.success() => None,
Ok(s) => Some(format!(
"cluster launcher: {} exited with {}",
child_label(lr, &host),
exit_status_desc(&s),
)),
Err(e) => Some(format!(
"cluster launcher: wait on {} failed: {e}",
child_label(lr, &host),
)),
};
if let Some(msg) = failure_msg {
let elastic_active = elastic
.as_ref()
.map(|e| e.cohort_formed.load(std::sync::atomic::Ordering::SeqCst))
.unwrap_or(false);
if elastic_active {
let e = elastic.as_ref().expect("elastic_active implies Some");
eprintln!(
"{msg} — tolerating (elastic membership): reporting rank(s) \
{granks:?} dead; the coordinator redistributes their work"
);
tolerated_deaths += 1;
if let Ok(mut q) = e.reported_deaths.lock() {
q.extend(granks.iter().copied());
}
} else if any_failure.is_none() {
any_failure = Some(TensorError::new(&msg));
if !terminated_peers {
terminated_peers = true;
eprintln!(
"cluster launcher: peer failure — terminating all \
still-running ranks"
);
kill_all.store(true, Ordering::SeqCst);
}
} else {
eprintln!("{msg}");
}
}
}
for w in watchers {
let _ = w.join();
}
for f in all_forwarders {
let _ = f.join();
}
if any_failure.is_none() {
if let Some(e) = elastic.as_ref() {
let dead = e.dead_ranks.dead_count();
let limit = e.max_failure.map(|t| t.limit_for(e.world_size));
if dead >= e.world_size {
any_failure = Some(TensorError::new(
"cluster launcher: every rank was lost; consensus checkpoint \
saved if a save path was armed",
));
} else if let Some(l) = limit {
if dead >= l {
any_failure = Some(TensorError::new(&format!(
"cluster launcher: max_failure exceeded ({dead}/{} ranks \
dead, threshold {l}); coordinator dispatched \
save-and-shutdown — consensus checkpoint saved if a \
save path was armed",
e.world_size,
)));
}
}
if any_failure.is_none() {
if dead > 0 {
eprintln!(
"cluster launcher: run completed DEGRADED — {dead} of {} \
ranks lost along the way (tolerated by elastic \
membership); survivors carried the full workload",
e.world_size,
);
} else if tolerated_deaths > 0 {
eprintln!(
"cluster launcher: run completed; {tolerated_deaths} \
child exit(s) in the teardown window were tolerated \
(never registered as rank deaths) — full workload \
delivered, nothing redistributed",
);
}
}
}
}
any_failure
}
pub(super) struct ElasticSupervision {
pub reported_deaths: crate::distributed::cluster_coordinator::ReportedDeaths,
pub dead_ranks: std::sync::Arc<crate::distributed::controller::DeadRanks>,
pub max_failure: Option<crate::distributed::max_failure::MaxFailureThreshold>,
pub world_size: usize,
pub cohort_formed: std::sync::Arc<std::sync::atomic::AtomicBool>,
}
pub(super) fn build_local_spawn_command(
exe: &std::path::Path,
user_args: &[String],
envelope_hex: &str,
local_rank: usize,
local_phys_device: Option<u8>,
) -> Command {
let mut cmd = Command::new(exe);
cmd.args(user_args)
.env(
crate::distributed::cluster::ENV_CLUSTER_JSON,
envelope_hex,
)
.env(
crate::distributed::cluster::ENV_LOCAL_RANK,
local_rank.to_string(),
)
.env_remove(ENV_FULL_CLUSTER_JSON)
.env_remove(super::ENV_AGENT_JSON);
if let Some(phys) = local_phys_device {
cmd.env("CUDA_DEVICE_ORDER", "PCI_BUS_ID");
cmd.env("CUDA_VISIBLE_DEVICES", phys.to_string());
}
cmd
}
pub(super) fn build_local_relay_command(
exe: &std::path::Path,
user_args: &[String],
relay_spec_hex: &str,
) -> Command {
let mut cmd = Command::new(exe);
cmd.args(user_args)
.env(super::ENV_RELAY_JSON, relay_spec_hex)
.env_remove(ENV_FULL_CLUSTER_JSON)
.env_remove(crate::distributed::cluster::ENV_CLUSTER_JSON)
.env_remove(crate::distributed::cluster::ENV_LOCAL_RANK)
.env_remove(super::ENV_AGENT_JSON);
cmd
}
pub(super) fn build_ssh_spawn_command(
host: &FullWorker,
remote_cmd: &str,
tunnel_port: Option<u16>,
) -> Command {
let ssh_target = host.ssh_target();
let mut c = Command::new("ssh");
if let Some(port) = tunnel_port {
c.arg("-R").arg(format!("127.0.0.1:{port}:127.0.0.1:{port}"));
c.arg("-o").arg("ExitOnForwardFailure=yes");
}
if let Some(opts) = host.ssh.as_ref().map(|s| &s.options) {
if let Some(warning) = batchmode_override_warning(opts, &host.host) {
eprintln!("{warning}");
}
for opt in opts {
c.arg("-o").arg(opt);
}
}
c.args(SSH_OPTS);
if let Some(p) = host.ssh.as_ref().and_then(|s| s.port) {
c.arg("-p").arg(p.to_string());
}
if let Some(u) = host.ssh.as_ref().and_then(|s| s.user.as_deref()) {
c.arg("-l").arg(u);
} else if let Ok(host_user) = std::env::var("FLODL_INTERNAL_HOST_USER") {
let trimmed = host_user.trim();
if !trimmed.is_empty() {
c.arg("-l").arg(trimmed);
}
}
if let Some(i) = host.ssh.as_ref().and_then(|s| s.identity_file.as_deref()) {
c.arg("-i").arg(i);
}
c.arg(ssh_target).arg(remote_cmd);
c
}
fn batchmode_override_warning(opts: &[String], host: &str) -> Option<String> {
opts.iter().find_map(|opt| {
let (k, v) = opt.split_once('=')?;
(k.trim().eq_ignore_ascii_case("BatchMode")
&& !v.trim().eq_ignore_ascii_case("yes"))
.then(|| {
format!(
"flodl: host {host:?} ssh.options set `{}` — flodl's ssh \
dispatch is non-interactive and will hang on any prompt \
(passphrase, host-key). Proceeding as requested.",
opt.trim()
)
})
})
}
pub(super) fn pipe_envelope_to_child(child: &mut std::process::Child, envelope_hex: &str) {
use std::io::Write;
if let Some(mut sin) = child.stdin.take() {
let _ = writeln!(sin, "{envelope_hex}");
}
}
pub(super) fn cleanup_remote_host(host: &FullWorker, abs_bin: &str) {
let q = shell_quote(abs_bin);
let payload = format!(
"pkill -TERM -f {q} >/dev/null 2>&1; sleep 1; \
pkill -KILL -f {q} >/dev/null 2>&1; true",
);
let _ = build_ssh_spawn_command(host, &payload, None)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
pub(super) fn cleanup_remote_hosts_parallel(remotes: Vec<(FullWorker, String)>) {
let handles: Vec<thread::JoinHandle<()>> = remotes
.into_iter()
.map(|(host, abs_bin)| {
thread::spawn(move || {
cleanup_remote_host(&host, &abs_bin);
})
})
.collect();
for h in handles {
let _ = h.join();
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn build_remote_agent_bash_command(
path: &str,
host_name: &str,
overlay_env: Option<&str>,
fdl_cmd: &str,
user_args: &[String],
cluster_env: &std::collections::BTreeMap<String, String>,
host_env: &std::collections::BTreeMap<String, String>,
prebuild: Option<&PerHostPrebuild>,
) -> String {
use crate::distributed::cluster::ENV_HOST_OVERRIDE;
let host_env_has_ld_path = host_env.contains_key("LD_LIBRARY_PATH");
let mut s = String::with_capacity(
256 + user_args.iter().map(|a| a.len() + 4).sum::<usize>(),
);
s.push_str("IFS= read -r __FLODL_ENVELOPE\n");
s.push_str("cd ");
let remote_cwd: String = match prebuild {
Some(pb) if !pb.cwd_subpath.is_empty() => {
format!("{}/{}", path.trim_end_matches('/'), pb.cwd_subpath)
}
_ => path.to_string(),
};
s.push_str(&shell_quote(&remote_cwd));
s.push_str(" && ");
s.push_str(super::ENV_AGENT_JSON);
s.push_str("=\"$__FLODL_ENVELOPE\" ");
s.push_str(ENV_HOST_OVERRIDE);
s.push('=');
s.push_str(&shell_quote(host_name));
s.push(' ');
if let Ok(v) = std::env::var(crate::log::ENV_VAR) {
s.push(' ');
s.push_str(crate::log::ENV_VAR);
s.push('=');
s.push_str(&shell_quote(&v));
}
if let Ok(v) = std::env::var(crate::distributed::wire::ENV_NET_TIMEOUT_SCALE) {
s.push(' ');
s.push_str(crate::distributed::wire::ENV_NET_TIMEOUT_SCALE);
s.push('=');
s.push_str(&shell_quote(&v));
}
if let Some(pb) = prebuild {
if !host_env_has_ld_path && !cluster_env.contains_key("LD_LIBRARY_PATH") {
s.push(' ');
s.push_str("LD_LIBRARY_PATH=");
s.push_str(&shell_quote(&pb.ld_library_path));
}
}
for (k, v) in cluster_env {
s.push(' ');
s.push_str(k);
s.push('=');
s.push_str(&shell_quote(v));
}
for (k, v) in host_env {
s.push(' ');
s.push_str(k);
s.push('=');
s.push_str(&shell_quote(v));
}
if let Some(env) = overlay_env {
s.push(' ');
s.push_str(ENV_FDL_ENV);
s.push('=');
s.push_str(&shell_quote(env));
}
if let Some(pb) = prebuild {
s.push(' ');
let abs_bin = format!("{}/{}", path.trim_end_matches('/'), pb.bin);
s.push_str(&shell_quote(&abs_bin));
} else {
s.push_str(" fdl ");
s.push_str(&shell_quote(fdl_cmd));
}
for a in user_args {
s.push(' ');
s.push_str(&shell_quote(a));
}
s.push_str(" &\n");
s.push_str("__flodl_pid=$!\n");
s.push_str(
"trap 'kill -TERM \"$__flodl_pid\" 2>/dev/null; \
( sleep 10; kill -KILL \"$__flodl_pid\" 2>/dev/null ) &' HUP TERM INT\n",
);
s.push_str("wait \"$__flodl_pid\"\n");
s.push_str("exit $?\n");
s
}
pub(super) fn shell_quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('\'');
for c in s.chars() {
if c == '\'' {
out.push_str("'\\''");
} else {
out.push(c);
}
}
out.push('\'');
out
}
pub(super) fn build_slim_envelope_for(
full: &FullCluster,
worker: &FullWorker,
controller_dial_host: &str,
rank_resources: bool,
) -> serde_json::Value {
use serde_json::Value;
let mut host_obj = serde_json::Map::new();
host_obj.insert("host".into(), Value::String(worker.host.clone()));
host_obj.insert(
"ranks".into(),
Value::Array(worker.ranks.iter().map(|r| Value::from(*r)).collect()),
);
host_obj.insert(
"local_devices".into(),
match &worker.local_devices {
None => Value::String("all".into()),
Some(v) => Value::Array(v.iter().map(|d| Value::from(*d)).collect()),
},
);
host_obj.insert(
"nccl_socket_ifname".into(),
Value::String(worker.nccl_socket_ifname.clone()),
);
host_obj.insert("path".into(), Value::String(worker.path.clone()));
if let Some(a) = &worker.arch {
host_obj.insert("arch".into(), Value::String(a.clone()));
}
let mut controller_obj = serde_json::Map::new();
controller_obj.insert(
"host".into(),
Value::String(controller_dial_host.to_string()),
);
controller_obj.insert("port".into(), Value::from(full.controller.port));
let mut envelope = serde_json::Map::new();
envelope.insert("controller".into(), Value::Object(controller_obj));
envelope.insert("world_size".into(), Value::from(full.world_size()));
envelope.insert("num_workers".into(), Value::from(full.workers.len()));
envelope.insert("worker".into(), Value::Object(host_obj));
envelope.insert(
"salt".into(),
Value::String(crate::distributed::wire::salt_to_hex(&full.salt)),
);
if rank_resources {
envelope.insert("rank_resources".into(), Value::Bool(true));
}
Value::Object(envelope)
}
pub(super) fn forward_lines<R: std::io::Read>(stream: R, prefix: String, to_stderr: bool) {
let reader = BufReader::new(stream);
for line in reader.lines() {
match line {
Ok(l) => {
if to_stderr {
eprintln!("{prefix}{l}");
} else {
println!("{prefix}{l}");
}
}
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => continue,
Err(_) => break,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn child_label_relay_sentinel_reads_as_relay() {
assert_eq!(child_label(RELAY_RANK_SENTINEL, "hostA"), "relay of hostA");
assert_eq!(child_label(3, "hostB"), "rank 3 of hostB");
}
#[test]
fn exit_status_desc_reports_code_and_signal() {
let ok = Command::new("sh")
.args(["-c", "exit 7"])
.status()
.expect("spawn sh");
assert_eq!(exit_status_desc(&ok), "status 7");
let mut child = Command::new("sleep")
.arg("30")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn sleep");
child.kill().expect("kill");
let st = child.wait().expect("wait");
assert_eq!(exit_status_desc(&st), "signal 9");
}
#[test]
fn batchmode_override_warning_flags_non_yes_only() {
let h = "host-a";
assert!(batchmode_override_warning(&["BatchMode=no".into()], h).is_some());
assert!(batchmode_override_warning(&["batchmode=No".into()], h).is_some());
assert!(batchmode_override_warning(&["BatchMode = ask".into()], h).is_some());
assert!(batchmode_override_warning(&["BatchMode=yes".into()], h).is_none());
assert!(batchmode_override_warning(&["BatchMode = yes".into()], h).is_none());
assert!(batchmode_override_warning(&["StrictHostKeyChecking=no".into()], h).is_none());
assert!(batchmode_override_warning(&[], h).is_none());
}
#[test]
fn build_ssh_spawn_command_user_options_precede_defaults() {
let v = serde_json::json!({
"controller": { "host": "ctl", "port": 1337, "path": "/p" },
"workers": [{
"host": "w1", "ranks": [0], "local_devices": [0],
"nccl_socket_ifname": "lo", "path": "/p", "arch": "precompiled/cu128",
"ssh": { "options": ["StrictHostKeyChecking=no"] }
}]
});
let full = FullCluster::from_value(&v).expect("valid cluster");
let cmd = build_ssh_spawn_command(&full.workers[0], "echo hi", None);
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
let user = args
.iter()
.position(|a| a == "StrictHostKeyChecking=no")
.expect("user ssh.option present");
let default = args
.iter()
.position(|a| a == "StrictHostKeyChecking=accept-new")
.expect("flodl default present");
assert!(
user < default,
"user ssh.option must precede flodl's default: {args:?}"
);
}
#[test]
fn build_ssh_spawn_command_tunnel_adds_remote_forward() {
let v = serde_json::json!({
"controller": { "host": "ctl", "port": 1337, "path": "/p" },
"workers": [{
"host": "w1", "ranks": [0], "local_devices": [0],
"nccl_socket_ifname": "lo", "path": "/p",
"ssh": { "options": ["StrictHostKeyChecking=no"] },
"tunnel": true
}]
});
let full = FullCluster::from_value(&v).expect("valid cluster");
let cmd = build_ssh_spawn_command(&full.workers[0], "echo hi", None);
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(!args.iter().any(|a| a == "-R"), "unexpected -R: {args:?}");
assert!(
!args.iter().any(|a| a == "ExitOnForwardFailure=yes"),
"unexpected ExitOnForwardFailure: {args:?}"
);
let cmd = build_ssh_spawn_command(&full.workers[0], "echo hi", Some(1337));
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
let r_flag = args.iter().position(|a| a == "-R").expect("-R present");
assert_eq!(args[r_flag + 1], "127.0.0.1:1337:127.0.0.1:1337");
let forward_fail = args
.iter()
.position(|a| a == "ExitOnForwardFailure=yes")
.expect("ExitOnForwardFailure present");
let user = args
.iter()
.position(|a| a == "StrictHostKeyChecking=no")
.expect("user ssh.option present");
assert!(
forward_fail < user,
"tunnel-critical option must precede user options: {args:?}"
);
}
}