session.rs 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. use anyhow::Result;
  2. use std::time::Duration;
  3. use tokio::time::sleep;
  4. use tracing::{info, warn};
  5. use crate::config::{Cluster, Config, Node, Recipe};
  6. use crate::ssh::*;
  7. use crate::tmux::{SessionHealth, TmuxManager};
  8. impl TmuxManager {
  9. /// Executes a list of commands via SSH on a node to create a tmux session.
  10. pub async fn create_session(
  11. &self,
  12. config: &Config,
  13. node: &Node,
  14. commands: &[String],
  15. dry_run: bool,
  16. ) -> Result<()> {
  17. if dry_run {
  18. info!(" ssh {}@{}:", config.ssh_user, node.ssh_ip);
  19. for cmd in commands {
  20. info!(" {}", cmd);
  21. }
  22. return Ok(());
  23. }
  24. let script = commands.join(" && ");
  25. let output = run_ssh(config, node, script, dry_run).await?;
  26. if output.trim().is_empty() {
  27. info!(" ✅ tmux session created on {}", node.name);
  28. } else {
  29. info!(" tmux session output on {}: {}", node.name, output.trim());
  30. }
  31. Ok(())
  32. }
  33. /// Checks whether a tmux session exists on the given node.
  34. pub async fn check_session(
  35. &self,
  36. config: &Config,
  37. node: &Node,
  38. session_name: &str,
  39. dry_run: bool,
  40. ) -> Result<bool> {
  41. if dry_run {
  42. info!(
  43. " ssh {}@{}: tmux list-sessions 2>/dev/null | grep '{}'",
  44. config.ssh_user, node.ssh_ip, session_name
  45. );
  46. return Ok(true);
  47. }
  48. let cmd = format!(
  49. "tmux list-sessions 2>/dev/null | grep -c '{}'",
  50. session_name
  51. );
  52. let output = run_ssh(config, node, cmd, dry_run).await?;
  53. let found = output.trim().parse::<usize>().unwrap_or(0) > 0;
  54. if found {
  55. info!(" Session '{}' found on {}", session_name, node.name);
  56. } else {
  57. warn!(" Session '{}' NOT found on {}", session_name, node.name);
  58. }
  59. Ok(found)
  60. }
  61. /// Performs a deep health check on a tmux session.
  62. pub async fn deep_check_session(
  63. &self,
  64. config: &Config,
  65. node: &Node,
  66. _cluster: &Cluster,
  67. session_name: &str,
  68. recipe: &Recipe,
  69. dry_run: bool,
  70. ) -> Result<SessionHealth> {
  71. if dry_run {
  72. return Ok(SessionHealth {
  73. session_exists: true,
  74. process_alive: true,
  75. gpu_ok: true,
  76. api_ok: true,
  77. });
  78. }
  79. // 1. Check session exists
  80. let exists = self
  81. .check_session(config, node, session_name, dry_run)
  82. .await?;
  83. // 2. Check process is alive inside tmux
  84. let process_alive = if exists {
  85. let pid_cmd = format!(
  86. "tmux list-panes -t {}:0 -F '#{{pane_pid}}' | head -1",
  87. session_name
  88. );
  89. match run_ssh(config, node, pid_cmd, dry_run).await {
  90. Ok(pid_str) => {
  91. let pid = pid_str.trim();
  92. if !pid.is_empty() && pid != "0" {
  93. let ps_cmd = format!("ps -p {} -o pid= 2>/dev/null", pid);
  94. run_ssh(config, node, ps_cmd, dry_run)
  95. .await
  96. .map(|o| !o.trim().is_empty())
  97. .unwrap_or(false)
  98. } else {
  99. false
  100. }
  101. }
  102. Err(_) => false,
  103. }
  104. } else {
  105. false
  106. };
  107. // 3. Check GPU is available
  108. let gpu_ok = match run_ssh(
  109. config,
  110. node,
  111. "nvidia-smi --query-gpu=index,utilization.gpu,memory.used --format=csv,noheader 2>&1 | head -5"
  112. .to_string(),
  113. dry_run,
  114. )
  115. .await
  116. {
  117. Ok(out) => !out.contains("failed") && !out.contains("error") && !out.trim().is_empty(),
  118. Err(_) => false,
  119. };
  120. // 4. Check API endpoint (head node only)
  121. let api_ok = if node.rank == 0 && process_alive {
  122. let api_check = format!(
  123. "curl -s -o /dev/null -w '%{{http_code}}' http://127.0.0.1:{}/v1/models 2>/dev/null || echo 'fail'",
  124. recipe.port
  125. );
  126. match run_ssh(config, node, api_check, dry_run).await {
  127. Ok(out) => {
  128. let code = out.trim();
  129. code == "200" || !code.contains("fail")
  130. }
  131. Err(_) => false,
  132. }
  133. } else {
  134. node.rank != 0 || process_alive
  135. };
  136. Ok(SessionHealth {
  137. session_exists: exists,
  138. process_alive,
  139. gpu_ok,
  140. api_ok,
  141. })
  142. }
  143. /// Gracefully stops a tmux session with timeout escalation.
  144. pub async fn graceful_stop(
  145. &self,
  146. config: &Config,
  147. node: &Node,
  148. session_name: &str,
  149. dry_run: bool,
  150. ) -> Result<()> {
  151. if dry_run {
  152. info!(
  153. " ssh {}@{}: graceful stop of session '{}'",
  154. config.ssh_user, node.ssh_ip, session_name
  155. );
  156. return Ok(());
  157. }
  158. info!(
  159. " Sending Ctrl+C to session '{}' on {}",
  160. session_name, node.name
  161. );
  162. let _ = run_ssh(
  163. config,
  164. node,
  165. format!("tmux send-keys -t {}:0 C-c", session_name),
  166. dry_run,
  167. )
  168. .await;
  169. let mut exited = false;
  170. for i in 0..15 {
  171. sleep(Duration::from_secs(2)).await;
  172. let check_cmd = format!(
  173. "tmux list-panes -t {}:0 -F '#{{pane_pid}}' 2>/dev/null | head -1",
  174. session_name
  175. );
  176. match run_ssh(config, node, check_cmd, dry_run).await {
  177. Ok(pid_str) => {
  178. let pid = pid_str.trim();
  179. if pid.is_empty() || pid == "0" || pid == "No" {
  180. exited = true;
  181. info!(
  182. " Session '{}' exited gracefully (attempt {})",
  183. session_name,
  184. i + 1
  185. );
  186. break;
  187. }
  188. let ps_cmd = format!("ps -p {} -o pid= 2>/dev/null", pid);
  189. match run_ssh(config, node, ps_cmd, dry_run).await {
  190. Ok(ps_out) if ps_out.trim().is_empty() => {
  191. exited = true;
  192. info!(
  193. " Session '{}' process exited (attempt {})",
  194. session_name,
  195. i + 1
  196. );
  197. break;
  198. }
  199. _ => continue,
  200. }
  201. }
  202. Err(_) => {
  203. exited = true;
  204. break;
  205. }
  206. }
  207. }
  208. if !exited {
  209. warn!(
  210. " Session '{}' did not exit gracefully, force killing...",
  211. session_name
  212. );
  213. let _ = run_ssh(
  214. config,
  215. node,
  216. format!("tmux kill-session -t {}", session_name),
  217. dry_run,
  218. )
  219. .await;
  220. }
  221. Ok(())
  222. }
  223. /// Streams the content of a tmux pane, optionally following new output.
  224. pub async fn stream_pane(
  225. &self,
  226. config: &Config,
  227. node: &Node,
  228. session_name: &str,
  229. window: u32,
  230. follow: bool,
  231. dry_run: bool,
  232. ) -> Result<String> {
  233. if dry_run {
  234. if follow {
  235. info!(
  236. " ssh {}@{}: tail -f (streaming from session '{}' window {})",
  237. config.ssh_user, node.ssh_ip, session_name, window
  238. );
  239. } else {
  240. info!(
  241. " ssh {}@{}: tmux capture-pane -t {}:{} -p -S -100",
  242. config.ssh_user, node.ssh_ip, session_name, window
  243. );
  244. }
  245. return Ok(String::new());
  246. }
  247. let cmd = if follow {
  248. format!(
  249. "tmux pipe-pane -o -t {}:{} 'cat > /tmp/sm121_pipe_{}_{}.log' && tail -f /tmp/sm121_pipe_{}_{}.log",
  250. session_name,
  251. window,
  252. session_name.replace('-', "_"),
  253. window,
  254. session_name.replace('-', "_"),
  255. window
  256. )
  257. } else {
  258. format!(
  259. "tmux capture-pane -t {}:{} -p -S -200 2>/dev/null || echo 'no output'",
  260. session_name, window
  261. )
  262. };
  263. run_ssh_with_retry(config, node, cmd, dry_run, 1).await
  264. }
  265. /// Attaches to a tmux session on a node via SSH (interactive).
  266. pub async fn attach_session(
  267. &self,
  268. config: &Config,
  269. node: &Node,
  270. session_name: &str,
  271. dry_run: bool,
  272. ) -> Result<()> {
  273. if dry_run {
  274. info!(
  275. " Would attach: ssh -t {}@{} tmux attach-session -t {}",
  276. config.ssh_user, node.ssh_ip, session_name
  277. );
  278. return Ok(());
  279. }
  280. info!(
  281. " Attaching to '{}' on {}... Press Ctrl+B D to detach.",
  282. session_name, node.name
  283. );
  284. info!(" (russh does not support interactive PTY. Use the following command directly:)");
  285. println!(
  286. " ssh -t {}@{} tmux attach-session -t {}",
  287. config.ssh_user, node.ssh_ip, session_name
  288. );
  289. println!();
  290. info!(" Streaming pane content instead:");
  291. let output = self
  292. .stream_pane(config, node, session_name, 0, true, dry_run)
  293. .await?;
  294. println!("{}", output);
  295. Ok(())
  296. }
  297. }