|
|
@@ -0,0 +1,1038 @@
|
|
|
+use anyhow::{Context, Result};
|
|
|
+use std::time::Duration;
|
|
|
+use tokio::time::sleep;
|
|
|
+use tracing::{info, warn};
|
|
|
+
|
|
|
+use crate::ssh::*;
|
|
|
+use crate::{Cluster, Config, NcclConfig, Node, Recipe, TmuxCommands};
|
|
|
+
|
|
|
+/// Manages tmux sessions for model inference across a DGX Spark cluster.
|
|
|
+#[derive(Clone)]
|
|
|
+pub struct TmuxManager {
|
|
|
+ #[allow(dead_code)]
|
|
|
+ pub session_prefix: String,
|
|
|
+}
|
|
|
+
|
|
|
+impl TmuxManager {
|
|
|
+ /// Generates a namespaced session name for a recipe and node rank.
|
|
|
+ /// Format: `{recipe_name}-{engine}-r{rank}`
|
|
|
+ pub fn session_name(&self, recipe: &Recipe, rank: u32) -> String {
|
|
|
+ let name = recipe.name.replace(' ', "_");
|
|
|
+ format!("{}-{}-r{}", name, recipe.engine, rank)
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Builds the list of `export` commands for NCCL environment variables.
|
|
|
+ pub fn build_nccl_setup(&self, nccl: &NcclConfig) -> Vec<String> {
|
|
|
+ vec![
|
|
|
+ format!("export NCCL_IB_HCA=\"{}\"", nccl.ib_hca),
|
|
|
+ format!("export NCCL_SOCKET_IFNAME=\"{}\"", nccl.socket_ifname),
|
|
|
+ format!("export NCCL_IB_GID_INDEX={}", nccl.ib_gid_index),
|
|
|
+ format!("export NCCL_IB_DISABLE={}", nccl.ib_disable),
|
|
|
+ ]
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Builds engine-specific host IP environment variable.
|
|
|
+ pub fn build_host_ip_export(&self, engine: &str, node: &Node) -> Vec<String> {
|
|
|
+ match engine {
|
|
|
+ "vllm" => vec![format!("export VLLM_HOST_IP={}", node.ib_ip)],
|
|
|
+ "sglang" => vec![format!("export SGLANG_WORKER_IP={}", node.ib_ip)],
|
|
|
+ _ => vec![
|
|
|
+ format!("export VLLM_HOST_IP={}", node.ib_ip),
|
|
|
+ format!("export SGLANG_WORKER_IP={}", node.ib_ip),
|
|
|
+ ],
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Builds the full model launch command for a given node.
|
|
|
+ pub fn build_launch_command(
|
|
|
+ &self,
|
|
|
+ recipe: &Recipe,
|
|
|
+ node: &Node,
|
|
|
+ cluster: &Cluster,
|
|
|
+ ) -> String {
|
|
|
+ let nnodes = cluster.nodes.len() as u32;
|
|
|
+ let pp_size = recipe.pipeline_parallel_size.unwrap_or(nnodes);
|
|
|
+
|
|
|
+ let mut parts: Vec<String> = Vec::new();
|
|
|
+
|
|
|
+ // Engine entrypoint
|
|
|
+ match recipe.engine.as_str() {
|
|
|
+ "vllm" => {
|
|
|
+ parts.push("python -m vllm.entrypoints.openai.api_server".to_string());
|
|
|
+ }
|
|
|
+ "sglang" => {
|
|
|
+ parts.push("python -m sglang.launch_server".to_string());
|
|
|
+ }
|
|
|
+ other => {
|
|
|
+ parts.push(format!("python -m {}", other));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Model
|
|
|
+ parts.push(recipe.model.clone());
|
|
|
+
|
|
|
+ // Recipe-specific args
|
|
|
+ parts.extend(recipe.args.clone());
|
|
|
+
|
|
|
+ // Distributed flags
|
|
|
+ match recipe.distributed_backend.as_str() {
|
|
|
+ "ray" => {
|
|
|
+ // Ray mode: workers connect via RAY_ADDRESS
|
|
|
+ parts.extend(vec![
|
|
|
+ "--tensor-parallel-size".to_string(),
|
|
|
+ nnodes.to_string(),
|
|
|
+ "--node-rank".to_string(),
|
|
|
+ node.rank.to_string(),
|
|
|
+ "--master-addr".to_string(),
|
|
|
+ cluster.master_ib_ip.clone(),
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+ _ => {
|
|
|
+ // MP mode (default)
|
|
|
+ parts.extend(vec![
|
|
|
+ "--tensor-parallel-size".to_string(),
|
|
|
+ "1".to_string(),
|
|
|
+ "--pipeline-parallel-size".to_string(),
|
|
|
+ pp_size.to_string(),
|
|
|
+ "--nnodes".to_string(),
|
|
|
+ nnodes.to_string(),
|
|
|
+ "--node-rank".to_string(),
|
|
|
+ node.rank.to_string(),
|
|
|
+ "--master-addr".to_string(),
|
|
|
+ cluster.master_ib_ip.clone(),
|
|
|
+ "--distributed-executor-backend".to_string(),
|
|
|
+ recipe.distributed_backend.clone(),
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Port (only on head node / rank 0)
|
|
|
+ if node.rank == 0 {
|
|
|
+ parts.extend(vec!["--port".to_string(), recipe.port.to_string()]);
|
|
|
+ }
|
|
|
+
|
|
|
+ parts.join(" ")
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Builds the full sequence of tmux commands for a session, with multi-window layout.
|
|
|
+ ///
|
|
|
+ /// Layout:
|
|
|
+ /// Window 0: model process (main vLLM/SGLang server)
|
|
|
+ /// Window 1: GPU monitor (nvidia-smi -l 5)
|
|
|
+ /// Window 2: shell (debug access)
|
|
|
+ pub fn build_session_commands(
|
|
|
+ &self,
|
|
|
+ node: &Node,
|
|
|
+ recipe: &Recipe,
|
|
|
+ nccl: &NcclConfig,
|
|
|
+ cluster: &Cluster,
|
|
|
+ ) -> Vec<String> {
|
|
|
+ let sess = self.session_name(recipe, node.rank);
|
|
|
+ let mut cmds: Vec<String> = Vec::new();
|
|
|
+
|
|
|
+ // Kill existing session if it exists
|
|
|
+ cmds.push(format!("tmux kill-session -t {} 2>/dev/null || true", sess));
|
|
|
+
|
|
|
+ // Create new detached session (window 0)
|
|
|
+ cmds.push(format!("tmux new-session -d -s {} -n model", sess));
|
|
|
+
|
|
|
+ // --- Window 0: model process ---
|
|
|
+
|
|
|
+ // Send NCCL environment variables
|
|
|
+ for export in self.build_nccl_setup(nccl) {
|
|
|
+ cmds.push(format!("tmux send-keys -t {}:0 '{}' Enter", sess, export));
|
|
|
+ }
|
|
|
+
|
|
|
+ // Send engine-specific host IP
|
|
|
+ for export in self.build_host_ip_export(&recipe.engine, node) {
|
|
|
+ cmds.push(format!("tmux send-keys -t {}:0 '{}' Enter", sess, export));
|
|
|
+ }
|
|
|
+
|
|
|
+ // Send RAY_ADDRESS for ray backend
|
|
|
+ if recipe.distributed_backend == "ray" && node.rank > 0 {
|
|
|
+ // Workers get RAY_ADDRESS pointing to head node
|
|
|
+ cmds.push(format!(
|
|
|
+ "tmux send-keys -t {}:0 'export RAY_ADDRESS={}:6379' Enter",
|
|
|
+ sess, cluster.master_ib_ip
|
|
|
+ ));
|
|
|
+ }
|
|
|
+
|
|
|
+ // Send recipe env vars
|
|
|
+ for (k, v) in &recipe.env {
|
|
|
+ cmds.push(format!(
|
|
|
+ "tmux send-keys -t {}:0 'export {}=\"{}\"' Enter",
|
|
|
+ sess, k, v
|
|
|
+ ));
|
|
|
+ }
|
|
|
+
|
|
|
+ // Send the launch command
|
|
|
+ let launch_cmd = self.build_launch_command(recipe, node, cluster);
|
|
|
+ cmds.push(format!("tmux send-keys -t {}:0 '{}' Enter", sess, launch_cmd));
|
|
|
+
|
|
|
+ // --- Window 1: GPU monitor ---
|
|
|
+ cmds.push(format!("tmux new-window -t {}:1 -n gpu", sess));
|
|
|
+ cmds.push(format!(
|
|
|
+ "tmux send-keys -t {}:1 'nvidia-smi -l 5' Enter",
|
|
|
+ sess
|
|
|
+ ));
|
|
|
+
|
|
|
+ // --- Window 2: shell (debug) ---
|
|
|
+ cmds.push(format!("tmux new-window -t {}:2 -n shell", sess));
|
|
|
+ cmds.push(format!(
|
|
|
+ "tmux send-keys -t {}:2 'echo \"ready for debugging\"' Enter",
|
|
|
+ sess
|
|
|
+ ));
|
|
|
+
|
|
|
+ // Focus back to window 0 (model process)
|
|
|
+ cmds.push(format!("tmux select-window -t {}:0", sess));
|
|
|
+
|
|
|
+ cmds
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Executes a list of commands via SSH on a node to create a tmux session.
|
|
|
+ pub async fn create_session(
|
|
|
+ &self,
|
|
|
+ config: &Config,
|
|
|
+ node: &Node,
|
|
|
+ commands: &[String],
|
|
|
+ dry_run: bool,
|
|
|
+ ) -> Result<()> {
|
|
|
+ if dry_run {
|
|
|
+ info!(" ssh {}@{}:", config.ssh_user, node.ssh_ip);
|
|
|
+ for cmd in commands {
|
|
|
+ info!(" {}", cmd);
|
|
|
+ }
|
|
|
+ return Ok(());
|
|
|
+ }
|
|
|
+
|
|
|
+ let script = commands.join(" && ");
|
|
|
+ let output = run_ssh(config, node, script, dry_run).await?;
|
|
|
+
|
|
|
+ if output.trim().is_empty() {
|
|
|
+ info!(" ✅ tmux session created on {}", node.name);
|
|
|
+ } else {
|
|
|
+ info!(" tmux session output on {}: {}", node.name, output.trim());
|
|
|
+ }
|
|
|
+
|
|
|
+ Ok(())
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Checks whether a tmux session exists and is running on the given node.
|
|
|
+ pub async fn check_session(
|
|
|
+ &self,
|
|
|
+ config: &Config,
|
|
|
+ node: &Node,
|
|
|
+ session_name: &str,
|
|
|
+ dry_run: bool,
|
|
|
+ ) -> Result<bool> {
|
|
|
+ if dry_run {
|
|
|
+ info!(
|
|
|
+ " ssh {}@{}: tmux list-sessions 2>/dev/null | grep '{}'",
|
|
|
+ config.ssh_user, node.ssh_ip, session_name
|
|
|
+ );
|
|
|
+ return Ok(true);
|
|
|
+ }
|
|
|
+
|
|
|
+ let cmd = format!(
|
|
|
+ "tmux list-sessions 2>/dev/null | grep -c '{}'",
|
|
|
+ session_name
|
|
|
+ );
|
|
|
+ let output = run_ssh(config, node, cmd, dry_run).await?;
|
|
|
+
|
|
|
+ let found = output.trim().parse::<usize>().unwrap_or(0) > 0;
|
|
|
+ if found {
|
|
|
+ info!(" Session '{}' found on {}", session_name, node.name);
|
|
|
+ } else {
|
|
|
+ warn!(" Session '{}' NOT found on {}", session_name, node.name);
|
|
|
+ }
|
|
|
+
|
|
|
+ Ok(found)
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Performs a deep health check on a tmux session.
|
|
|
+ /// Checks: session exists, process alive, GPU status, API endpoint.
|
|
|
+ pub async fn deep_check_session(
|
|
|
+ &self,
|
|
|
+ config: &Config,
|
|
|
+ node: &Node,
|
|
|
+ _cluster: &Cluster,
|
|
|
+ session_name: &str,
|
|
|
+ recipe: &Recipe,
|
|
|
+ dry_run: bool,
|
|
|
+ ) -> Result<SessionHealth> {
|
|
|
+ if dry_run {
|
|
|
+ return Ok(SessionHealth {
|
|
|
+ session_exists: true,
|
|
|
+ process_alive: true,
|
|
|
+ gpu_ok: true,
|
|
|
+ api_ok: true,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 1. Check session exists
|
|
|
+ let exists = self
|
|
|
+ .check_session(config, node, session_name, dry_run)
|
|
|
+ .await?;
|
|
|
+
|
|
|
+ // 2. Check process is alive inside tmux
|
|
|
+ let process_alive = if exists {
|
|
|
+ let pid_cmd = format!(
|
|
|
+ "tmux list-panes -t {}:0 -F '#{{pane_pid}}' | head -1",
|
|
|
+ session_name
|
|
|
+ );
|
|
|
+ match run_ssh(config, node, pid_cmd, dry_run).await {
|
|
|
+ Ok(pid_str) => {
|
|
|
+ let pid = pid_str.trim();
|
|
|
+ if !pid.is_empty() && pid != "0" {
|
|
|
+ // Check if the process (or its child) is running
|
|
|
+ let ps_cmd = format!("ps -p {} -o pid= 2>/dev/null", pid);
|
|
|
+ run_ssh(config, node, ps_cmd, dry_run)
|
|
|
+ .await
|
|
|
+ .map(|o| !o.trim().is_empty())
|
|
|
+ .unwrap_or(false)
|
|
|
+ } else {
|
|
|
+ false
|
|
|
+ }
|
|
|
+ }
|
|
|
+ Err(_) => false,
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ false
|
|
|
+ };
|
|
|
+
|
|
|
+ // 3. Check GPU is available and not in error state
|
|
|
+ let gpu_ok = match run_ssh(config, node, "nvidia-smi --query-gpu=index,utilization.gpu,memory.used --format=csv,noheader 2>&1 | head -5".to_string(), dry_run).await {
|
|
|
+ Ok(out) => !out.contains("failed") && !out.contains("error") && !out.trim().is_empty(),
|
|
|
+ Err(_) => false,
|
|
|
+ };
|
|
|
+
|
|
|
+ // 4. Check API endpoint (head node only)
|
|
|
+ let api_ok = if node.rank == 0 && process_alive {
|
|
|
+ let api_check = format!(
|
|
|
+ "curl -s -o /dev/null -w '%{{http_code}}' http://127.0.0.1:{}/v1/models 2>/dev/null || echo 'fail'",
|
|
|
+ recipe.port
|
|
|
+ );
|
|
|
+ match run_ssh(config, node, api_check, dry_run).await {
|
|
|
+ Ok(out) => {
|
|
|
+ let code = out.trim();
|
|
|
+ code == "200" || /* vLLM openai compat */ code == "200" || !code.contains("fail")
|
|
|
+ }
|
|
|
+ Err(_) => false,
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // Non-head nodes or no process: skip API check
|
|
|
+ node.rank != 0 || process_alive
|
|
|
+ };
|
|
|
+
|
|
|
+ Ok(SessionHealth {
|
|
|
+ session_exists: exists,
|
|
|
+ process_alive,
|
|
|
+ gpu_ok,
|
|
|
+ api_ok,
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Gracefully stops a tmux session with timeout escalation.
|
|
|
+ /// Sends Ctrl+C, polls for exit up to 30s, then SIGTERM, then SIGKILL.
|
|
|
+ pub async fn graceful_stop(
|
|
|
+ &self,
|
|
|
+ config: &Config,
|
|
|
+ node: &Node,
|
|
|
+ session_name: &str,
|
|
|
+ dry_run: bool,
|
|
|
+ ) -> Result<()> {
|
|
|
+ if dry_run {
|
|
|
+ info!(" ssh {}@{}: graceful stop of session '{}'", config.ssh_user, node.ssh_ip, session_name);
|
|
|
+ return Ok(());
|
|
|
+ }
|
|
|
+
|
|
|
+ // Phase 1: Ctrl+C (graceful shutdown)
|
|
|
+ info!(" Sending Ctrl+C to session '{}' on {}", session_name, node.name);
|
|
|
+ let stop_cmd = format!("tmux send-keys -t {}:0 C-c", session_name);
|
|
|
+ let _ = run_ssh(config, node, stop_cmd, dry_run).await;
|
|
|
+
|
|
|
+ // Phase 2: Poll for exit up to 30 seconds
|
|
|
+ let mut exited = false;
|
|
|
+ for i in 0..15 {
|
|
|
+ sleep(Duration::from_secs(2)).await;
|
|
|
+ let check_cmd = format!(
|
|
|
+ "tmux list-panes -t {}:0 -F '#{{pane_pid}}' 2>/dev/null | head -1",
|
|
|
+ session_name
|
|
|
+ );
|
|
|
+ match run_ssh(config, node, check_cmd, dry_run).await {
|
|
|
+ Ok(pid_str) => {
|
|
|
+ let pid = pid_str.trim();
|
|
|
+ if pid.is_empty() || pid == "0" || pid == "No" {
|
|
|
+ // "No" from "no target session" — session already gone
|
|
|
+ exited = true;
|
|
|
+ info!(" Session '{}' exited gracefully (attempt {})", session_name, i + 1);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ // Check process still alive
|
|
|
+ let ps_cmd = format!("ps -p {} -o pid= 2>/dev/null", pid);
|
|
|
+ match run_ssh(config, node, ps_cmd, dry_run).await {
|
|
|
+ Ok(ps_out) if ps_out.trim().is_empty() => {
|
|
|
+ exited = true;
|
|
|
+ info!(" Session '{}' process exited (attempt {})", session_name, i + 1);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ _ => continue,
|
|
|
+ }
|
|
|
+ }
|
|
|
+ Err(_) => {
|
|
|
+ // SSH error — session may already be gone
|
|
|
+ exited = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Phase 3: Force kill tmux session
|
|
|
+ if !exited {
|
|
|
+ warn!(" Session '{}' did not exit gracefully, force killing...", session_name);
|
|
|
+ let kill_cmd = format!("tmux kill-session -t {}", session_name);
|
|
|
+ let _ = run_ssh(config, node, kill_cmd, dry_run).await;
|
|
|
+ }
|
|
|
+
|
|
|
+ Ok(())
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Streams the content of a tmux pane, optionally following new output.
|
|
|
+ pub async fn stream_pane(
|
|
|
+ &self,
|
|
|
+ config: &Config,
|
|
|
+ node: &Node,
|
|
|
+ session_name: &str,
|
|
|
+ window: u32,
|
|
|
+ follow: bool,
|
|
|
+ dry_run: bool,
|
|
|
+ ) -> Result<String> {
|
|
|
+ if dry_run {
|
|
|
+ if follow {
|
|
|
+ info!(
|
|
|
+ " ssh {}@{}: tail -f (streaming from session '{}' window {})",
|
|
|
+ config.ssh_user, node.ssh_ip, session_name, window
|
|
|
+ );
|
|
|
+ } else {
|
|
|
+ info!(
|
|
|
+ " ssh {}@{}: tmux capture-pane -t {}:{} -p -S -100",
|
|
|
+ config.ssh_user, node.ssh_ip, session_name, window
|
|
|
+ );
|
|
|
+ }
|
|
|
+ return Ok(String::new());
|
|
|
+ }
|
|
|
+
|
|
|
+ let cmd = if follow {
|
|
|
+ format!("tmux pipe-pane -o -t {}:{} 'cat > /tmp/sm121_pipe_{}_{}.log' && tail -f /tmp/sm121_pipe_{}_{}.log",
|
|
|
+ session_name, window, session_name.replace('-', "_"), window,
|
|
|
+ session_name.replace('-', "_"), window)
|
|
|
+ } else {
|
|
|
+ format!(
|
|
|
+ "tmux capture-pane -t {}:{} -p -S -200 2>/dev/null || echo 'no output'",
|
|
|
+ session_name, window
|
|
|
+ )
|
|
|
+ };
|
|
|
+
|
|
|
+ let output = run_ssh_with_retry(config, node, cmd, dry_run, 1).await?;
|
|
|
+ Ok(output)
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Attaches to a tmux session on a node via SSH (interactive).
|
|
|
+ pub async fn attach_session(
|
|
|
+ &self,
|
|
|
+ config: &Config,
|
|
|
+ node: &Node,
|
|
|
+ session_name: &str,
|
|
|
+ dry_run: bool,
|
|
|
+ ) -> Result<()> {
|
|
|
+ if dry_run {
|
|
|
+ info!(
|
|
|
+ " Would attach: ssh -t {}@{} tmux attach-session -t {}",
|
|
|
+ config.ssh_user, node.ssh_ip, session_name
|
|
|
+ );
|
|
|
+ return Ok(());
|
|
|
+ }
|
|
|
+
|
|
|
+ info!(
|
|
|
+ " Attaching to '{}' on {}... Press Ctrl+B D to detach.",
|
|
|
+ session_name, node.name
|
|
|
+ );
|
|
|
+
|
|
|
+ // For non-interactive SSH (russh), we can't really do a proper attach.
|
|
|
+ // Instead, print instructions and stream the pane.
|
|
|
+ info!(" (russh does not support interactive PTY. Use the following command directly:)");
|
|
|
+ println!(
|
|
|
+ " ssh -t {}@{} tmux attach-session -t {}",
|
|
|
+ config.ssh_user, node.ssh_ip, session_name
|
|
|
+ );
|
|
|
+ println!();
|
|
|
+ info!(" Streaming pane content instead:");
|
|
|
+ let output = self
|
|
|
+ .stream_pane(config, node, session_name, 0, true, dry_run)
|
|
|
+ .await?;
|
|
|
+ println!("{}", output);
|
|
|
+
|
|
|
+ Ok(())
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ====================== Health Report ======================
|
|
|
+
|
|
|
+/// Detailed health status of a tmux session.
|
|
|
+#[derive(Debug, Clone)]
|
|
|
+pub struct SessionHealth {
|
|
|
+ pub session_exists: bool,
|
|
|
+ pub process_alive: bool,
|
|
|
+ pub gpu_ok: bool,
|
|
|
+ pub api_ok: bool,
|
|
|
+}
|
|
|
+
|
|
|
+impl SessionHealth {
|
|
|
+ pub fn is_healthy(&self) -> bool {
|
|
|
+ self.session_exists && self.process_alive && self.gpu_ok && self.api_ok
|
|
|
+ }
|
|
|
+
|
|
|
+ #[allow(dead_code)]
|
|
|
+ pub fn is_degraded(&self) -> bool {
|
|
|
+ self.session_exists && !self.process_alive
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ====================== CLI Dispatch ======================
|
|
|
+
|
|
|
+/// Top-level handler for all `cargo run tmux <subcommand>` commands.
|
|
|
+pub async fn handle_tmux(
|
|
|
+ config: &Config,
|
|
|
+ cmd: TmuxCommands,
|
|
|
+ dry_run: bool,
|
|
|
+) -> Result<()> {
|
|
|
+ match cmd {
|
|
|
+ TmuxCommands::Serve { recipe, cluster } => {
|
|
|
+ tmux_serve(config, &recipe, &cluster, dry_run).await?;
|
|
|
+ }
|
|
|
+ TmuxCommands::Stop { recipe, cluster } => {
|
|
|
+ tmux_stop(config, &recipe, &cluster, dry_run).await?;
|
|
|
+ }
|
|
|
+ TmuxCommands::Status { recipe, cluster } => {
|
|
|
+ tmux_status(config, &recipe, &cluster, dry_run).await?;
|
|
|
+ }
|
|
|
+ TmuxCommands::Logs { recipe, follow, cluster } => {
|
|
|
+ tmux_logs(config, &recipe, &cluster, follow, dry_run).await?;
|
|
|
+ }
|
|
|
+ TmuxCommands::Attach { recipe, cluster } => {
|
|
|
+ tmux_attach(config, &recipe, &cluster, dry_run).await?;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ Ok(())
|
|
|
+}
|
|
|
+
|
|
|
+// ====================== Serve (tmux) ======================
|
|
|
+
|
|
|
+/// Launches a cluster via tmux sessions.
|
|
|
+/// For Ray backend: starts head node first, waits for readiness, then workers.
|
|
|
+async fn tmux_serve(
|
|
|
+ config: &Config,
|
|
|
+ recipe_name: &str,
|
|
|
+ cluster_name: &str,
|
|
|
+ dry_run: bool,
|
|
|
+) -> Result<()> {
|
|
|
+ let recipe_path = format!("recipes/{}/recipe.toml", recipe_name);
|
|
|
+ let recipe: Recipe = toml::from_str(&tokio::fs::read_to_string(&recipe_path).await?)?;
|
|
|
+
|
|
|
+ info!(
|
|
|
+ "🚀 TMUX launch: {} ({}) [backend: {}] [engine: {}]",
|
|
|
+ recipe.name, recipe_name, recipe.distributed_backend, recipe.engine
|
|
|
+ );
|
|
|
+
|
|
|
+ let cluster_choice = if cluster_name != "auto" {
|
|
|
+ cluster_name.to_string()
|
|
|
+ } else if let Some(ref rc) = recipe.cluster {
|
|
|
+ rc.clone()
|
|
|
+ } else {
|
|
|
+ "auto".to_string()
|
|
|
+ };
|
|
|
+ let (cluster, nccl) = select_cluster(config, &cluster_choice)?;
|
|
|
+
|
|
|
+ if let Some(required) = recipe.required_nodes {
|
|
|
+ let actual = cluster.nodes.len() as u32;
|
|
|
+ if actual != required {
|
|
|
+ anyhow::bail!(
|
|
|
+ "Recipe '{}' requires {} nodes, but cluster '{}' has {} nodes",
|
|
|
+ recipe.name,
|
|
|
+ required,
|
|
|
+ cluster.name,
|
|
|
+ actual
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ let manager = TmuxManager {
|
|
|
+ session_prefix: recipe_name.to_string(),
|
|
|
+ };
|
|
|
+
|
|
|
+ let is_ray = recipe.distributed_backend == "ray";
|
|
|
+
|
|
|
+ if is_ray {
|
|
|
+ // Sequential startup for Ray: head first, then workers
|
|
|
+ info!("🐍 Ray backend detected — starting head node first...");
|
|
|
+
|
|
|
+ let head_node = cluster
|
|
|
+ .nodes
|
|
|
+ .iter()
|
|
|
+ .find(|n| n.rank == 0)
|
|
|
+ .context("No head node (rank 0) in cluster")?;
|
|
|
+
|
|
|
+ // Start head node
|
|
|
+ let head_cmds = manager.build_session_commands(head_node, &recipe, nccl, &cluster);
|
|
|
+ manager
|
|
|
+ .create_session(config, head_node, &head_cmds, dry_run)
|
|
|
+ .await?;
|
|
|
+
|
|
|
+ if !dry_run {
|
|
|
+ info!("⏳ Waiting for head node to be ready...");
|
|
|
+ let sess = manager.session_name(&recipe, 0);
|
|
|
+ let head_ready = wait_for_head_ready(config, head_node, &sess, &recipe).await?;
|
|
|
+ if head_ready {
|
|
|
+ info!("✅ Head node ready. Starting worker nodes...");
|
|
|
+ } else {
|
|
|
+ warn!("⚠️ Head node may not be fully ready — starting workers anyway.");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Start worker nodes
|
|
|
+ for node in &cluster.nodes {
|
|
|
+ if node.rank == 0 {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ let worker_cmds = manager.build_session_commands(node, &recipe, nccl, &cluster);
|
|
|
+ manager
|
|
|
+ .create_session(config, node, &worker_cmds, dry_run)
|
|
|
+ .await?;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // MP mode: start all nodes in parallel
|
|
|
+ info!("🔗 MP backend detected — starting all nodes in parallel...");
|
|
|
+
|
|
|
+ let mut handles = Vec::new();
|
|
|
+ for node in &cluster.nodes {
|
|
|
+ let config = config.clone();
|
|
|
+ let node = node.clone();
|
|
|
+ let recipe = recipe.clone();
|
|
|
+ let nccl = nccl.clone();
|
|
|
+ let cluster = cluster.clone();
|
|
|
+ let manager = manager.clone();
|
|
|
+ let dry = dry_run;
|
|
|
+
|
|
|
+ handles.push(tokio::spawn(async move {
|
|
|
+ let cmds = manager.build_session_commands(&node, &recipe, &nccl, &cluster);
|
|
|
+ manager
|
|
|
+ .create_session(&config, &node, &cmds, dry)
|
|
|
+ .await
|
|
|
+ .map(|_| node.name.clone())
|
|
|
+ }));
|
|
|
+ }
|
|
|
+
|
|
|
+ let results = futures::future::join_all(handles).await;
|
|
|
+ let mut failures = Vec::new();
|
|
|
+ for result in results {
|
|
|
+ match result {
|
|
|
+ Ok(Ok(name)) => info!(" ✅ Session started on {}", name),
|
|
|
+ Ok(Err(e)) => failures.push(e),
|
|
|
+ Err(e) => failures.push(anyhow::anyhow!("Task join: {}", e)),
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if !failures.is_empty() {
|
|
|
+ for e in &failures {
|
|
|
+ warn!(" ✗ {}", e);
|
|
|
+ }
|
|
|
+ anyhow::bail!("Failed to start sessions on {} node(s)", failures.len());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if !dry_run {
|
|
|
+ sleep(Duration::from_secs(5)).await;
|
|
|
+ info!(
|
|
|
+ "🎉 Cluster launched via tmux! API → http://{}:{}",
|
|
|
+ cluster.master_ib_ip, recipe.port
|
|
|
+ );
|
|
|
+ info!("📌 To view sessions: `cargo run tmux status --recipe {}`", recipe_name);
|
|
|
+ info!("📌 To attach: `cargo run tmux attach --recipe {}`", recipe_name);
|
|
|
+ }
|
|
|
+
|
|
|
+ Ok(())
|
|
|
+}
|
|
|
+
|
|
|
+/// Polls the head node until the API endpoint responds or timeout.
|
|
|
+async fn wait_for_head_ready(
|
|
|
+ config: &Config,
|
|
|
+ head_node: &Node,
|
|
|
+ session_name: &str,
|
|
|
+ recipe: &Recipe,
|
|
|
+) -> Result<bool> {
|
|
|
+ let max_attempts = 30; // 30 * 5s = 150s max wait
|
|
|
+ for i in 0..max_attempts {
|
|
|
+ // Quick check: is the session process still alive?
|
|
|
+ let pid_cmd = format!(
|
|
|
+ "tmux list-panes -t {}:0 -F '#{{pane_pid}}' | head -1",
|
|
|
+ session_name
|
|
|
+ );
|
|
|
+ match run_ssh(config, head_node, pid_cmd, false).await {
|
|
|
+ Ok(pid_str) => {
|
|
|
+ let pid = pid_str.trim();
|
|
|
+ if pid.is_empty() || pid == "0" {
|
|
|
+ sleep(Duration::from_secs(5)).await;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ Err(_) => {
|
|
|
+ sleep(Duration::from_secs(5)).await;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // API readiness check via curl from head node
|
|
|
+ let api_check = format!(
|
|
|
+ "curl -s -o /dev/null -w '%{{http_code}}' http://127.0.0.1:{}/v1/models 2>/dev/null || echo 'not_ready'",
|
|
|
+ recipe.port
|
|
|
+ );
|
|
|
+ match run_ssh(config, head_node, api_check, false).await {
|
|
|
+ Ok(out) => {
|
|
|
+ let code = out.trim();
|
|
|
+ if code == "200" {
|
|
|
+ info!(" Head node API ready (attempt {})", i + 1);
|
|
|
+ return Ok(true);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ Err(_) => {}
|
|
|
+ }
|
|
|
+
|
|
|
+ // Also check stderr/output for model load progress
|
|
|
+ let log_check = format!(
|
|
|
+ "tmux capture-pane -t {}:0 -p -S -5 2>/dev/null | grep -c 'Application startup complete' || true",
|
|
|
+ session_name
|
|
|
+ );
|
|
|
+ let _ = run_ssh(config, head_node, log_check, false).await;
|
|
|
+
|
|
|
+ if (i + 1) % 5 == 0 {
|
|
|
+ info!(
|
|
|
+ " Still waiting for head node... ({}/{} attempts)",
|
|
|
+ i + 1,
|
|
|
+ max_attempts
|
|
|
+ );
|
|
|
+ }
|
|
|
+ sleep(Duration::from_secs(5)).await;
|
|
|
+ }
|
|
|
+
|
|
|
+ warn!(" Head node did not become ready within timeout");
|
|
|
+ Ok(false)
|
|
|
+}
|
|
|
+
|
|
|
+// ====================== Stop (tmux) ======================
|
|
|
+
|
|
|
+/// Stops all tmux sessions for a recipe across all nodes in a cluster.
|
|
|
+async fn tmux_stop(
|
|
|
+ config: &Config,
|
|
|
+ recipe_name: &str,
|
|
|
+ cluster_name: &str,
|
|
|
+ dry_run: bool,
|
|
|
+) -> Result<()> {
|
|
|
+ let recipe_path = format!("recipes/{}/recipe.toml", recipe_name);
|
|
|
+ let recipe: Recipe = toml::from_str(&tokio::fs::read_to_string(&recipe_path).await?)?;
|
|
|
+
|
|
|
+ let cluster_choice = if cluster_name != "auto" {
|
|
|
+ cluster_name.to_string()
|
|
|
+ } else if let Some(ref rc) = recipe.cluster {
|
|
|
+ rc.clone()
|
|
|
+ } else {
|
|
|
+ "auto".to_string()
|
|
|
+ };
|
|
|
+ let (cluster, _) = select_cluster(config, &cluster_choice)?;
|
|
|
+
|
|
|
+ let manager = TmuxManager {
|
|
|
+ session_prefix: recipe_name.to_string(),
|
|
|
+ };
|
|
|
+
|
|
|
+ info!("🛑 Stopping tmux sessions for '{}' on cluster '{}'...", recipe_name, cluster.name);
|
|
|
+
|
|
|
+ let mut failures = Vec::new();
|
|
|
+ for node in &cluster.nodes {
|
|
|
+ let sess = manager.session_name(&recipe, node.rank);
|
|
|
+ match manager.graceful_stop(config, node, &sess, dry_run).await {
|
|
|
+ Ok(()) => info!(" ✅ Stopped '{}' on {}", sess, node.name),
|
|
|
+ Err(e) => {
|
|
|
+ warn!(" ✗ Failed to stop '{}' on {}: {}", sess, node.name, e);
|
|
|
+ failures.push((node.name.clone(), e));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if !failures.is_empty() {
|
|
|
+ for (name, e) in &failures {
|
|
|
+ warn!(" Failed on {}: {}", name, e);
|
|
|
+ }
|
|
|
+ anyhow::bail!("Failed to stop sessions on {} node(s)", failures.len());
|
|
|
+ }
|
|
|
+
|
|
|
+ info!("✅ All tmux sessions stopped.");
|
|
|
+ Ok(())
|
|
|
+}
|
|
|
+
|
|
|
+// ====================== Status (tmux) ======================
|
|
|
+
|
|
|
+/// Checks health status of tmux sessions across the cluster.
|
|
|
+async fn tmux_status(
|
|
|
+ config: &Config,
|
|
|
+ recipe_name: &str,
|
|
|
+ cluster_name: &str,
|
|
|
+ dry_run: bool,
|
|
|
+) -> Result<()> {
|
|
|
+ let recipe_path = format!("recipes/{}/recipe.toml", recipe_name);
|
|
|
+ let recipe: Recipe = toml::from_str(&tokio::fs::read_to_string(&recipe_path).await?)?;
|
|
|
+
|
|
|
+ let cluster_choice = if cluster_name != "auto" {
|
|
|
+ cluster_name.to_string()
|
|
|
+ } else if let Some(ref rc) = recipe.cluster {
|
|
|
+ rc.clone()
|
|
|
+ } else {
|
|
|
+ "auto".to_string()
|
|
|
+ };
|
|
|
+ let (cluster, _) = select_cluster(config, &cluster_choice)?;
|
|
|
+
|
|
|
+ let manager = TmuxManager {
|
|
|
+ session_prefix: recipe_name.to_string(),
|
|
|
+ };
|
|
|
+
|
|
|
+ println!("\n📊 TMUX Status: {} on cluster {}", recipe.name, cluster.name);
|
|
|
+ println!("{}", "─".repeat(60));
|
|
|
+
|
|
|
+ let mut handles = Vec::new();
|
|
|
+ for node in &cluster.nodes {
|
|
|
+ let config = config.clone();
|
|
|
+ let node = node.clone();
|
|
|
+ let cluster = cluster.clone();
|
|
|
+ let recipe = recipe.clone();
|
|
|
+ let manager = manager.clone();
|
|
|
+ let dry = dry_run;
|
|
|
+
|
|
|
+ handles.push(tokio::spawn(async move {
|
|
|
+ let sess = manager.session_name(&recipe, node.rank);
|
|
|
+ let health = manager
|
|
|
+ .deep_check_session(&config, &node, &cluster, &sess, &recipe, dry)
|
|
|
+ .await;
|
|
|
+ (node.name, node.rank, node.ib_ip, health)
|
|
|
+ }));
|
|
|
+ }
|
|
|
+
|
|
|
+ let mut all_healthy = true;
|
|
|
+ let results = futures::future::join_all(handles).await;
|
|
|
+ for result in results {
|
|
|
+ match result {
|
|
|
+ Ok((name, rank, ib_ip, Ok(health))) => {
|
|
|
+ let status_icon = if health.is_healthy() {
|
|
|
+ "✓".to_string()
|
|
|
+ } else if health.session_exists {
|
|
|
+ "⚠".to_string()
|
|
|
+ } else {
|
|
|
+ "✗".to_string()
|
|
|
+ };
|
|
|
+
|
|
|
+ print!(" {} {} (rank {}, {}): ", status_icon, name, rank, ib_ip);
|
|
|
+
|
|
|
+ if health.is_healthy() {
|
|
|
+ println!("✅ ALL OK");
|
|
|
+ } else {
|
|
|
+ let mut issues = Vec::new();
|
|
|
+ if !health.session_exists {
|
|
|
+ issues.push("no session");
|
|
|
+ }
|
|
|
+ if !health.process_alive {
|
|
|
+ issues.push("process dead");
|
|
|
+ }
|
|
|
+ if !health.gpu_ok {
|
|
|
+ issues.push("GPU issue");
|
|
|
+ }
|
|
|
+ if !health.api_ok && rank == 0 {
|
|
|
+ issues.push("API down");
|
|
|
+ }
|
|
|
+ println!("⚠️ {}", issues.join(", "));
|
|
|
+ all_healthy = false;
|
|
|
+ }
|
|
|
+
|
|
|
+ if health.session_exists {
|
|
|
+ // Show GPU stats
|
|
|
+ println!(" Session: alive | Process: {} | GPU: {} | API: {}",
|
|
|
+ if health.process_alive { "✓" } else { "✗" },
|
|
|
+ if health.gpu_ok { "✓" } else { "✗" },
|
|
|
+ if health.api_ok { "✓" } else { "✗" },
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+ Ok((name, _, _, Err(e))) => {
|
|
|
+ println!(" ✗ {} — SSH error: {}", name, e);
|
|
|
+ all_healthy = false;
|
|
|
+ }
|
|
|
+ Err(join_err) => {
|
|
|
+ println!(" ✗ Task join error: {}", join_err);
|
|
|
+ all_healthy = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ println!("{}", "─".repeat(60));
|
|
|
+ if all_healthy {
|
|
|
+ info!("✅ Cluster is healthy!");
|
|
|
+ } else {
|
|
|
+ warn!("⚠️ Cluster has issues — check details above.");
|
|
|
+ }
|
|
|
+
|
|
|
+ Ok(())
|
|
|
+}
|
|
|
+
|
|
|
+// ====================== Logs (tmux) ======================
|
|
|
+
|
|
|
+/// Shows or follows logs from tmux pane content.
|
|
|
+async fn tmux_logs(
|
|
|
+ config: &Config,
|
|
|
+ recipe_name: &str,
|
|
|
+ cluster_name: &str,
|
|
|
+ follow: bool,
|
|
|
+ dry_run: bool,
|
|
|
+) -> Result<()> {
|
|
|
+ let recipe_path = format!("recipes/{}/recipe.toml", recipe_name);
|
|
|
+ let recipe: Recipe = toml::from_str(&tokio::fs::read_to_string(&recipe_path).await?)?;
|
|
|
+
|
|
|
+ let cluster_choice = if cluster_name != "auto" {
|
|
|
+ cluster_name.to_string()
|
|
|
+ } else if let Some(ref rc) = recipe.cluster {
|
|
|
+ rc.clone()
|
|
|
+ } else {
|
|
|
+ "auto".to_string()
|
|
|
+ };
|
|
|
+ let (cluster, _) = select_cluster(config, &cluster_choice)?;
|
|
|
+
|
|
|
+ let manager = TmuxManager {
|
|
|
+ session_prefix: recipe_name.to_string(),
|
|
|
+ };
|
|
|
+
|
|
|
+ if follow {
|
|
|
+ // Follow head node logs only (most useful)
|
|
|
+ let head_node = cluster
|
|
|
+ .nodes
|
|
|
+ .first()
|
|
|
+ .context("No nodes in cluster")?;
|
|
|
+ let sess = manager.session_name(&recipe, 0);
|
|
|
+
|
|
|
+ info!("📜 Following logs from '{}' on {}...", sess, head_node.name);
|
|
|
+ let output = manager
|
|
|
+ .stream_pane(config, head_node, &sess, 0, true, dry_run)
|
|
|
+ .await?;
|
|
|
+ println!("{}", output);
|
|
|
+ } else {
|
|
|
+ info!(
|
|
|
+ "📜 Logs for '{}' on cluster '{}' ({} nodes):",
|
|
|
+ recipe.name,
|
|
|
+ cluster.name,
|
|
|
+ cluster.nodes.len()
|
|
|
+ );
|
|
|
+
|
|
|
+ let mut handles = Vec::new();
|
|
|
+ for node in &cluster.nodes {
|
|
|
+ let config = config.clone();
|
|
|
+ let node = node.clone();
|
|
|
+ let manager = manager.clone();
|
|
|
+ let recipe = recipe.clone();
|
|
|
+ let dry = dry_run;
|
|
|
+
|
|
|
+ handles.push(tokio::spawn(async move {
|
|
|
+ let sess = manager.session_name(&recipe, node.rank);
|
|
|
+ let output = manager
|
|
|
+ .stream_pane(&config, &node, &sess, 0, false, dry)
|
|
|
+ .await;
|
|
|
+ (node.name, sess, output)
|
|
|
+ }));
|
|
|
+ }
|
|
|
+
|
|
|
+ let results = futures::future::join_all(handles).await;
|
|
|
+ for result in results {
|
|
|
+ if let Ok((name, sess, Ok(output))) = result {
|
|
|
+ let trimmed = output.trim();
|
|
|
+ if !trimmed.is_empty() {
|
|
|
+ println!("\n--- {} ({}) ---", name, sess);
|
|
|
+ println!("{}", trimmed);
|
|
|
+ } else {
|
|
|
+ println!("\n--- {} — no output ---", name);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ Ok(())
|
|
|
+}
|
|
|
+
|
|
|
+// ====================== Attach (tmux) ======================
|
|
|
+
|
|
|
+/// Provides instructions and streamed output for attaching to a tmux session.
|
|
|
+async fn tmux_attach(
|
|
|
+ config: &Config,
|
|
|
+ recipe_name: &str,
|
|
|
+ cluster_name: &str,
|
|
|
+ dry_run: bool,
|
|
|
+) -> Result<()> {
|
|
|
+ let recipe_path = format!("recipes/{}/recipe.toml", recipe_name);
|
|
|
+ let recipe: Recipe = toml::from_str(&tokio::fs::read_to_string(&recipe_path).await?)?;
|
|
|
+
|
|
|
+ let cluster_choice = if cluster_name != "auto" {
|
|
|
+ cluster_name.to_string()
|
|
|
+ } else if let Some(ref rc) = recipe.cluster {
|
|
|
+ rc.clone()
|
|
|
+ } else {
|
|
|
+ "auto".to_string()
|
|
|
+ };
|
|
|
+ let (cluster, _) = select_cluster(config, &cluster_choice)?;
|
|
|
+
|
|
|
+ let head_node = cluster
|
|
|
+ .nodes
|
|
|
+ .iter()
|
|
|
+ .find(|n| n.rank == 0)
|
|
|
+ .context("No head node (rank 0) in cluster")?;
|
|
|
+
|
|
|
+ let manager = TmuxManager {
|
|
|
+ session_prefix: recipe_name.to_string(),
|
|
|
+ };
|
|
|
+ let sess = manager.session_name(&recipe, 0);
|
|
|
+
|
|
|
+ info!("🔗 Attaching to head node '{}' session '{}'", head_node.name, sess);
|
|
|
+ manager
|
|
|
+ .attach_session(config, head_node, &sess, dry_run)
|
|
|
+ .await?;
|
|
|
+
|
|
|
+ Ok(())
|
|
|
+}
|
|
|
+
|
|
|
+// ====================== Helpers ======================
|
|
|
+
|
|
|
+/// Selects a cluster and its NCCL config by name or auto-pick.
|
|
|
+fn select_cluster<'c>(config: &'c Config, cluster_name: &str) -> Result<(Cluster, &'c NcclConfig)> {
|
|
|
+ if cluster_name != "auto" {
|
|
|
+ let cluster = config
|
|
|
+ .clusters
|
|
|
+ .iter()
|
|
|
+ .find(|c| c.name == cluster_name)
|
|
|
+ .context(format!("Cluster '{}' not found", cluster_name))?;
|
|
|
+ let nccl = config
|
|
|
+ .nccl
|
|
|
+ .get(&cluster.name)
|
|
|
+ .context("NCCL config not found")?;
|
|
|
+ return Ok((cluster.clone(), nccl));
|
|
|
+ }
|
|
|
+
|
|
|
+ for cluster in &config.clusters {
|
|
|
+ if let Some(nccl) = config.nccl.get(&cluster.name) {
|
|
|
+ info!("✅ Auto-selected cluster: {}", cluster.name);
|
|
|
+ return Ok((cluster.clone(), nccl));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ anyhow::bail!("No suitable cluster found (none have NCCL config)");
|
|
|
+}
|