diff --git a/src/commands/kill.rs b/src/commands/kill.rs index 412c77e3..d1068211 100644 --- a/src/commands/kill.rs +++ b/src/commands/kill.rs @@ -887,6 +887,7 @@ mod tests { zellij_session_name: String::new(), herdr_socket_path: String::new(), wezterm_unix_socket: String::new(), + tmux: String::new(), session_id: String::new(), notify_port: 0, inject_port: 0, diff --git a/src/hooks/common.rs b/src/hooks/common.rs index 235795a1..e5681729 100644 --- a/src/hooks/common.rs +++ b/src/hooks/common.rs @@ -1289,6 +1289,7 @@ fn stop_instance_inner( let zellij_session_name = ti.zellij_session_name; let herdr_socket_path = ti.herdr_socket_path; let wezterm_unix_socket = ti.wezterm_unix_socket; + let tmux = ti.tmux; // Fallback: process_bindings table if proc_id.is_empty() && let Ok(mut stmt) = db @@ -1332,6 +1333,7 @@ fn stop_instance_inner( zellij_session_name: &zellij_session_name, herdr_socket_path: &herdr_socket_path, wezterm_unix_socket: &wezterm_unix_socket, + tmux: &tmux, session_id: instance_data.session_id.as_deref().unwrap_or(""), notify_port, inject_port, diff --git a/src/instance_binding.rs b/src/instance_binding.rs index 617d031b..6f31edf7 100644 --- a/src/instance_binding.rs +++ b/src/instance_binding.rs @@ -200,6 +200,7 @@ fn capture_context() -> serde_json::Map { "GUAKE_TAB_UUID", "WT_SESSION", "ConEmuHWND", + "TMUX", "TMUX_PANE", "STY", "ZELLIJ_SESSION_NAME", diff --git a/src/pidtrack.rs b/src/pidtrack.rs index 15896a24..0784af66 100644 --- a/src/pidtrack.rs +++ b/src/pidtrack.rs @@ -34,6 +34,8 @@ pub struct PidEntry { #[serde(default, skip_serializing_if = "String::is_empty")] pub wezterm_unix_socket: String, #[serde(default, skip_serializing_if = "String::is_empty")] + pub tmux: String, + #[serde(default, skip_serializing_if = "String::is_empty")] pub session_id: String, #[serde(default, skip_serializing_if = "is_zero")] pub notify_port: u16, @@ -62,6 +64,7 @@ pub struct OrphanProcess { pub zellij_session_name: String, pub herdr_socket_path: String, pub wezterm_unix_socket: String, + pub tmux: String, pub session_id: String, pub notify_port: u16, pub inject_port: u16, @@ -80,6 +83,7 @@ impl OrphanProcess { zellij_session_name: self.zellij_session_name.clone(), herdr_socket_path: self.herdr_socket_path.clone(), wezterm_unix_socket: self.wezterm_unix_socket.clone(), + tmux: self.tmux.clone(), } } } @@ -99,6 +103,7 @@ impl From<(u32, &PidEntry)> for OrphanProcess { zellij_session_name: entry.zellij_session_name.clone(), herdr_socket_path: entry.herdr_socket_path.clone(), wezterm_unix_socket: entry.wezterm_unix_socket.clone(), + tmux: entry.tmux.clone(), session_id: entry.session_id.clone(), notify_port: entry.notify_port, inject_port: entry.inject_port, @@ -148,6 +153,7 @@ pub struct PidRecord<'a> { pub zellij_session_name: &'a str, pub herdr_socket_path: &'a str, pub wezterm_unix_socket: &'a str, + pub tmux: &'a str, pub session_id: &'a str, pub notify_port: u16, pub inject_port: u16, @@ -177,6 +183,7 @@ impl<'a> PidRecord<'a> { zellij_session_name: "", herdr_socket_path: "", wezterm_unix_socket: "", + tmux: "", session_id: "", notify_port: 0, inject_port: 0, @@ -201,6 +208,7 @@ pub fn record_pid(rec: &PidRecord<'_>) { zellij_session_name, herdr_socket_path, wezterm_unix_socket, + tmux, session_id, notify_port, inject_port, @@ -239,6 +247,9 @@ pub fn record_pid(rec: &PidRecord<'_>) { if !wezterm_unix_socket.is_empty() && entry.wezterm_unix_socket.is_empty() { entry.wezterm_unix_socket = wezterm_unix_socket.to_string(); } + if !tmux.is_empty() && entry.tmux.is_empty() { + entry.tmux = tmux.to_string(); + } if !session_id.is_empty() && entry.session_id.is_empty() { entry.session_id = session_id.to_string(); } @@ -267,6 +278,7 @@ pub fn record_pid(rec: &PidRecord<'_>) { zellij_session_name: zellij_session_name.to_string(), herdr_socket_path: herdr_socket_path.to_string(), wezterm_unix_socket: wezterm_unix_socket.to_string(), + tmux: tmux.to_string(), session_id: session_id.to_string(), notify_port: *notify_port, inject_port: *inject_port, @@ -504,6 +516,7 @@ fn attach_runtime_state( ("ZELLIJ_SESSION_NAME", &orphan.zellij_session_name), ("HERDR_SOCKET_PATH", &orphan.herdr_socket_path), ("WEZTERM_UNIX_SOCKET", &orphan.wezterm_unix_socket), + ("TMUX", &orphan.tmux), ] { if value.is_empty() { continue; @@ -638,6 +651,7 @@ mod tests { zellij_session_name: "wise-kangaroo", herdr_socket_path: "/run/herdr/sessions/work/herdr.sock", wezterm_unix_socket: "", + tmux: "/tmp/tmux-1000/default,4242,0", session_id: "sess-1", notify_port: 8080, inject_port: 8081, @@ -672,6 +686,7 @@ mod tests { info.herdr_socket_path, "/run/herdr/sessions/work/herdr.sock" ); + assert_eq!(info.tmux, "/tmp/tmux-1000/default,4242,0"); } #[test] @@ -812,6 +827,7 @@ mod tests { zellij_session_name: String::new(), herdr_socket_path: String::new(), wezterm_unix_socket: String::new(), + tmux: String::new(), session_id: String::new(), notify_port: 0, inject_port: 0, @@ -843,6 +859,7 @@ mod tests { zellij_session_name: String::new(), herdr_socket_path: String::new(), wezterm_unix_socket: String::new(), + tmux: String::new(), session_id: "sess-retry".into(), notify_port: 0, inject_port: 0, diff --git a/src/shared/terminal_presets.rs b/src/shared/terminal_presets.rs index c71e0306..a7f0ff55 100644 --- a/src/shared/terminal_presets.rs +++ b/src/shared/terminal_presets.rs @@ -459,7 +459,7 @@ pub static TERMINAL_PRESETS: LazyLock> = Laz p( Some("tmux"), None, - argv(&["tmux", "split-window", "-h", "{script}"]), + argv(&["tmux", "split-window", "-h", "bash", "{script}"]), argv(&["tmux", "kill-pane", "-t", "{pane_id}"]), Some("TMUX_PANE"), DL, diff --git a/src/terminal.rs b/src/terminal.rs index debce16f..836a9b30 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -98,6 +98,59 @@ fn is_wezterm_argv(argv: &[String]) -> bool { .is_some_and(|stem| stem.eq_ignore_ascii_case("wezterm")) } +/// True if `argv` runs the tmux CLI (`tmux` or a full path). +fn is_tmux_argv(argv: &[String]) -> bool { + argv.first() + .and_then(|bin| std::path::Path::new(bin).file_stem()) + .and_then(|stem| stem.to_str()) + .is_some_and(|stem| stem == "tmux") +} + +/// True if `pane_id` exists on the tmux server named by `tmux` (a pane's +/// `TMUX` value) and that server is still the process that started the pane. +/// False if the server doesn't answer within `TERMINAL_CLOSE_TIMEOUT`, so a +/// hung server can't keep kill from signalling the process. +fn tmux_pane_on_server(tmux_bin: &str, tmux: &str, pane_id: &str) -> bool { + let Some(server_pid) = tmux.split(',').nth(1).filter(|pid| !pid.is_empty()) else { + return false; + }; + let Ok(mut child) = Command::new(tmux_bin) + .args(["display-message", "-p", "-t", pane_id, "#{pid}"]) + .env("TMUX", tmux) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + else { + return false; + }; + let started = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) if status.success() => break, + Ok(None) if started.elapsed() < TERMINAL_CLOSE_TIMEOUT => { + std::thread::sleep(Duration::from_millis(25)); + } + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + eprintln!( + "Timed out after {}s asking tmux about pane {pane_id}; not closing it", + TERMINAL_CLOSE_TIMEOUT.as_secs() + ); + return false; + } + _ => return false, + } + } + let mut out = String::new(); + child + .stdout + .take() + .is_some_and(|mut stdout| std::io::Read::read_to_string(&mut stdout, &mut out).is_ok()) + && out.trim() == server_pid +} + /// Terminal info resolved for an instance. #[derive(Debug, Clone, Default)] pub struct TerminalInfo { @@ -111,6 +164,8 @@ pub struct TerminalInfo { pub herdr_socket_path: String, /// The pane's `WEZTERM_UNIX_SOCKET`: which wezterm GUI process owns it. pub wezterm_unix_socket: String, + /// The pane's `TMUX` (`socket,server_pid,session`): which tmux server owns it. + pub tmux: String, } /// Result from launch_terminal. @@ -2790,6 +2845,7 @@ pub fn close_terminal_pane(pid: u32, info: &TerminalInfo) -> PaneCloseResult { zellij_session_name, herdr_socket_path, wezterm_unix_socket, + tmux, } = info; let failed_without_command = || PaneCloseResult { closed: false, @@ -2886,10 +2942,18 @@ pub fn close_terminal_pane(pid: u32, info: &TerminalInfo) -> PaneCloseResult { // environment would close another session's pane. // Same for wezterm: pane ids are per GUI process, and without the socket // `wezterm cli` talks to the most recently started GUI. + // And tmux: pane ids are per server and the CLI picks the server from + // `TMUX`. They also restart at %0 with each server, so a server restarted + // on the same socket may hold an unrelated pane with this id. let close_env: Vec<(&str, &str)> = if is_herdr_argv(&argv) && !herdr_socket_path.is_empty() { vec![("HERDR_SOCKET_PATH", herdr_socket_path.as_str())] } else if is_wezterm_argv(&argv) && !wezterm_unix_socket.is_empty() { vec![("WEZTERM_UNIX_SOCKET", wezterm_unix_socket.as_str())] + } else if is_tmux_argv(&argv) && !tmux.is_empty() { + if !tmux_pane_on_server(&argv[0], tmux, effective_pane_id) { + return failed_without_command(); + } + vec![("TMUX", tmux.as_str())] } else { Vec::new() }; @@ -3093,6 +3157,10 @@ pub fn resolve_terminal_info( .and_then(|e| e.get("WEZTERM_UNIX_SOCKET").and_then(|v| v.as_str())) .unwrap_or("") .to_string(); + info.tmux = lc_env + .and_then(|e| e.get("TMUX").and_then(|v| v.as_str())) + .unwrap_or("") + .to_string(); } // Legacy kitty launches may have pane/socket metadata but no persisted preset. @@ -4450,6 +4518,35 @@ mod tests { assert_eq!(info.herdr_socket_path, "/run/herdr/work.sock"); } + #[test] + fn test_resolve_terminal_info_reads_tmux_from_env_snapshot() { + let info = resolve_terminal_info( + Some("tmux-split"), + Some(r#"{"pane_id":"%1","env":{"TMUX":"/tmp/tmux-1000/other,4242,0"}}"#), + ); + assert_eq!(info.pane_id, "%1"); + assert_eq!(info.tmux, "/tmp/tmux-1000/other,4242,0"); + } + + #[test] + fn test_is_tmux_argv() { + assert!(is_tmux_argv(&["tmux".to_string()])); + assert!(is_tmux_argv(&["/opt/homebrew/bin/tmux".to_string()])); + assert!(!is_tmux_argv(&["tmuxinator".to_string()])); + assert!(!is_tmux_argv(&[])); + } + + #[test] + fn test_tmux_pane_on_server_needs_server_pid() { + // Without the server pid the pane's server can't be confirmed. + assert!(!tmux_pane_on_server("tmux", "/tmp/tmux-1000/default", "%1")); + assert!(!tmux_pane_on_server( + "tmux", + "/tmp/tmux-1000/default,,0", + "%1" + )); + } + #[test] fn test_is_herdr_argv_matches_bare_name_and_paths() { let is = |bin: &str| is_herdr_argv(&[bin.to_string(), "pane".to_string()]);