Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/commands/kill.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/hooks/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/instance_binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ fn capture_context() -> serde_json::Map<String, serde_json::Value> {
"GUAKE_TAB_UUID",
"WT_SESSION",
"ConEmuHWND",
"TMUX",
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Startup kills lack the socket

Capturing TMUX only in the start hook leaves startup kills unfixed. Proxy::spawn saves the PID and pane ID before the tool runs its start hook, and hcom kill accepts those records. If startup is slow or stuck, killing the instance from another server still runs tmux kill-pane without -S and can close that server's matching pane.

Capture the socket in build_early_launch_context too.

Knowledge Base Used:

"TMUX_PANE",
"STY",
"ZELLIJ_SESSION_NAME",
Expand Down
17 changes: 17 additions & 0 deletions src/pidtrack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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(),
}
}
}
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/shared/terminal_presets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,7 @@ pub static TERMINAL_PRESETS: LazyLock<Vec<(&'static str, TerminalPreset)>> = 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,
Expand Down
97 changes: 97 additions & 0 deletions src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Comment on lines +2953 to +2954

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Failed cleanup looks successful

If the caller cannot reach the tmux socket, the query fails and returns failed_without_command(), which has no retry command. Pane cleanup is skipped, but hcom kill reports success after signalling the process. The user gets neither a warning nor a command to retry.

Keep an error or retry command for query failures instead of treating them like a confirmed stale owner.

Knowledge Base Used: Command workflows

}
vec![("TMUX", tmux.as_str())]
} else {
Vec::new()
Comment on lines +2952 to 2958

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Missing ownership closes unrelated panes

Instances saved before this change lack env.TMUX. Their empty tmux now skips the server check and runs the close command with the caller’s environment. If both servers contain %0, killing an older instance can close the caller’s unrelated pane.

Refuse to close without a saved owner unless the close command explicitly selects a server.

Knowledge Base Used: Runtime environment and process setup

};
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()]);
Expand Down
Loading