flodl 0.7.0

floDl — a flow-graph deep learning framework built on libtorch
Documentation
//! Test-discovery for cluster topology.
//!
//! [`discover_test_cluster`] is the single entry point tests use to
//! find a cluster to exercise. Discovery proceeds in priority order:
//!
//! 1. **`FLODL_TESTING_CLUSTER_JSON` env var** — set by `fdl-cli` when
//!    an env overlay (`fdl.<env>.yml`) with a `cluster:` block is
//!    active. Carries the canonical-JSON encoding of the cluster
//!    topology (hex-encoded). Test invocations use this to point at a
//!    pre-defined cluster (e.g. a Pascal rig topology committed to
//!    `fdl.cluster-test.yml`).
//! 2. **Local CUDA autodetect** — when no env-driven topology is set
//!    but visible CUDA devices exist, synthesize a single-host
//!    loopback cluster with one rank per visible device. This lets
//!    cluster-mode tests run on whatever the developer has locally
//!    without a config file.
//! 3. **None** — no cluster available. Tests should fall back to
//!    CPU-only paths or skip with a clear message.
//!
//! ## Authoring tests
//!
//! ```ignore
//! #[test]
//! #[ignore = "cluster topology needed; run via `fdl @cluster-test <cmd>` or with N GPUs visible"]
//! fn my_cluster_test() {
//!     let cluster = match flodl::distributed::testing::discover_test_cluster() {
//!         Some(c) => c,
//!         None => {
//!             eprintln!("skip: no cluster topology (set FLODL_TESTING_CLUSTER_JSON or run on a CUDA host)");
//!             return;
//!         }
//!     };
//!     // ... use cluster.controller.host, cluster.controller.port, cluster.hosts ...
//! }
//! ```

use crate::distributed::launcher::{FullCluster, FullWorker};
use crate::distributed::wire::SESSION_SALT_BYTES;

/// Env var carrying the canonical-JSON cluster topology when `fdl-cli`
/// activates an env overlay with a `cluster:` block. Mirrors the
/// production `FLODL_INTERNAL_FULL_CLUSTER_JSON` shape but distinct so it never
/// triggers launcher mode in spawned binaries.
pub const ENV_TESTING_CLUSTER_JSON: &str = "FLODL_TESTING_CLUSTER_JSON";

/// Discover a cluster topology for tests.
///
/// See module docs for the discovery priority. Returns `None` only
/// when neither an env-driven topology nor visible CUDA devices are
/// available — callers should treat this as "skip, no cluster
/// available" with a clear message.
///
/// **Panics** on env-var parse errors (hex decode, JSON parse, schema
/// violation). Silent fallback to local-autodetect would hide
/// misconfigured `fdl.<env>.yml` files; a loud panic surfaces the bug.
pub fn discover_test_cluster() -> Option<FullCluster> {
    if let Ok(raw) = std::env::var(ENV_TESTING_CLUSTER_JSON) {
        return Some(parse_env_cluster(&raw));
    }
    if let Some(n) = autodetect_local_gpus() {
        return Some(synthesize_local_cluster(n));
    }
    None
}

fn parse_env_cluster(raw: &str) -> FullCluster {
    let bytes = crate::distributed::cluster::hex_decode(raw.trim())
        .unwrap_or_else(|e| {
            panic!(
                "{ENV_TESTING_CLUSTER_JSON} hex-decode failed: {e}. \
                 The value must be a hex-encoded canonical-JSON cluster \
                 envelope (as written by fdl-cli when --env activates \
                 an overlay with a cluster: block)."
            )
        });
    let val: serde_json::Value = serde_json::from_slice(&bytes)
        .unwrap_or_else(|e| {
            panic!("{ENV_TESTING_CLUSTER_JSON} JSON parse failed: {e}")
        });
    FullCluster::from_value(&val)
        .unwrap_or_else(|e| {
            panic!("{ENV_TESTING_CLUSTER_JSON} schema violation: {e}")
        })
}

fn autodetect_local_gpus() -> Option<usize> {
    let count = crate::tensor::cuda_device_count();
    if count > 0 {
        Some(count as usize)
    } else {
        None
    }
}

/// Build a single-host loopback cluster with `n_gpus` ranks, one rank
/// per visible CUDA device. `controller_port = 0` signals
/// "kernel-assigned" — tests bind a listener first and read the
/// assigned port; production launcher invocations would set a fixed
/// port via the cluster envelope.
///
/// `salt` defaults to all-zeros for predictable test behavior. Tests
/// that need fresh per-run salts should override after discovery via
/// [`FullCluster::with_session_salt`].
fn synthesize_local_cluster(n_gpus: usize) -> FullCluster {
    let ranks: Vec<usize> = (0..n_gpus).collect();
    let local_devices: Vec<u8> = (0..n_gpus as u8).collect();
    FullCluster {
        controller: super::launcher::FullController {
            host: "127.0.0.1".to_string(),
            port: 0,
            path: String::new(),
            docker: None,
            arch: None,
            data_path: None,
            join: None,
        },
        workers: vec![FullWorker {
            host: "localhost".to_string(),
            ranks,
            local_devices: Some(local_devices),
            nccl_socket_ifname: "lo".to_string(),
            path: String::new(),
            arch: None,
            ssh: None,
            tunnel: false,
            env: std::collections::BTreeMap::new(),
        }],
        salt: [0u8; SESSION_SALT_BYTES],
        env: std::collections::BTreeMap::new(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Mutex;

    // Tests mutate ENV_TESTING_CLUSTER_JSON; serialize them under a
    // module-level mutex (rather than relying on cargo's per-test
    // isolation) so concurrent runs don't observe each other's env
    // mutations. Per `feedback_env_mutating_tests_mutex`.
    static ENV_LOCK: Mutex<()> = Mutex::new(());

    fn unset() {
        unsafe { std::env::remove_var(ENV_TESTING_CLUSTER_JSON) };
    }

    #[test]
    fn discover_returns_none_without_env_or_cuda() {
        let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
        unset();
        // On CPU-only test runs (no visible CUDA devices) this branch
        // covers both falsy paths. On CUDA-enabled runs it covers the
        // env-var-absent branch (CUDA autodetect fires next, which we
        // can't assert here without conditional compilation; the
        // `autodetect_local_gpus` direct test below handles that).
        let result = discover_test_cluster();
        // Truthiness depends on the host; assert the function is at
        // least callable + doesn't panic when env is unset.
        let _ = result;
    }

    #[test]
    fn discover_reads_env_var_when_set() {
        let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());

        // Build a minimal canonical cluster JSON and inject it.
        let canonical = serde_json::json!({
            "controller": { "host": "127.0.0.1", "port": 8888, "path": "/tmp" },
            "workers": [{
                "host": "test-host",
                "ranks": [0, 1],
                "local_devices": [0, 1],
                "nccl_socket_ifname": "lo",
                "path": "/tmp",
                "arch": null,
                "ssh": null,
            }],
            "session_salt": "0".repeat(SESSION_SALT_BYTES * 2),
        });
        let bytes = serde_json::to_vec(&canonical).unwrap();
        let hex = bytes
            .iter()
            .fold(String::with_capacity(bytes.len() * 2), |mut s, b| {
                s.push_str(&format!("{b:02x}"));
                s
            });
        unsafe { std::env::set_var(ENV_TESTING_CLUSTER_JSON, &hex) };

        let cluster = discover_test_cluster().expect("env-driven topology");
        assert_eq!(cluster.controller.host, "127.0.0.1");
        assert_eq!(cluster.controller.port, 8888);
        assert_eq!(cluster.workers.len(), 1);
        assert_eq!(cluster.workers[0].host, "test-host");
        assert_eq!(cluster.workers[0].ranks, vec![0, 1]);

        unset();
    }

    #[test]
    fn synthesize_local_cluster_shape() {
        let c = synthesize_local_cluster(3);
        assert_eq!(c.controller.host, "127.0.0.1");
        assert_eq!(c.controller.port, 0);
        assert_eq!(c.workers.len(), 1);
        assert_eq!(c.workers[0].host, "localhost");
        assert_eq!(c.workers[0].ranks, vec![0, 1, 2]);
        assert_eq!(
            c.workers[0].local_devices.as_deref().unwrap(),
            &[0u8, 1, 2][..]
        );
        assert!(c.workers[0].ssh.is_none());
        assert_eq!(c.salt, [0u8; SESSION_SALT_BYTES]);
    }

    #[test]
    #[should_panic(expected = "hex-decode failed")]
    fn discover_panics_on_malformed_env_hex() {
        let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
        unsafe { std::env::set_var(ENV_TESTING_CLUSTER_JSON, "not-valid-hex-zz") };
        let _ = discover_test_cluster();
        // unreachable in normal flow; cleanup happens via cargo's
        // unwinding panic, but defensive unset is still useful:
        unset();
    }
}