aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-03-31 13:29:57 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-03-31 13:29:57 +0300
commitf8c452294d6f76ccb264c0d9e205398492f00e18 (patch)
tree68f9505424de441b5da4dbe26fab6149e2a75734
parentb8ccdf600c750411b439d0eb41f6a70e3c595f4f (diff)
fix: terminate pane sessions on close
-rw-r--r--Cargo.toml4
-rw-r--r--src/app/mod.rs2
-rw-r--r--src/pane.rs67
-rw-r--r--src/platform/fallback.rs15
-rw-r--r--src/platform/linux.rs63
-rw-r--r--src/platform/macos.rs61
-rw-r--r--src/platform/mod.rs7
-rw-r--r--src/workspace.rs32
-rw-r--r--tests/cli_wrapper.rs142
9 files changed, 382 insertions, 11 deletions
diff --git a/Cargo.toml b/Cargo.toml
index 342f9a97..60fea56d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,6 +14,7 @@ include = ["src/**/*", "assets/sounds/*", "README.md", "LICENSE", "Cargo.toml"]
base64 = "0.22.1"
bytes = "1"
crossterm = "0.29"
+libc = "0.2"
portable-pty = "0.9"
ratatui = "0.30"
regex = "1"
@@ -25,6 +26,3 @@ tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
tui-term = "0.3"
vt100 = "0.16"
-
-[target.'cfg(target_os = "macos")'.dependencies]
-libc = "0.2"
diff --git a/src/app/mod.rs b/src/app/mod.rs
index db1cf9e8..82c3777a 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -1100,7 +1100,7 @@ impl App {
data: crate::api::schema::EventData::WorkspaceClosed { workspace_id },
});
} else {
- ws.remove_pane(pane_id);
+ ws.close_pane(pane_id);
self.emit_event(crate::api::schema::EventEnvelope {
event: crate::api::schema::EventKind::PaneClosed,
data: crate::api::schema::EventData::PaneClosed {
diff --git a/src/pane.rs b/src/pane.rs
index 7a5a75dd..c6b8e175 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -160,7 +160,74 @@ fn recent_text_from_parser(parser: &mut vt100::Parser<PtyResponses>, lines: usiz
}
}
+fn wait_for_processes_to_exit(pids: &[u32], timeout: std::time::Duration) -> bool {
+ let deadline = std::time::Instant::now() + timeout;
+ loop {
+ if pids
+ .iter()
+ .all(|pid| !crate::platform::process_exists(*pid))
+ {
+ return true;
+ }
+ if std::time::Instant::now() >= deadline {
+ return false;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(20));
+ }
+}
+
+fn shutdown_pane_processes(pane_id: PaneId, child_pid: u32) {
+ if child_pid == 0 {
+ return;
+ }
+
+ let mut pids = crate::platform::session_processes(child_pid);
+ if pids.is_empty() {
+ pids.push(child_pid);
+ }
+ pids.sort_unstable();
+ pids.dedup();
+
+ for (signal, grace) in [
+ (
+ crate::platform::Signal::Hangup,
+ std::time::Duration::from_millis(250),
+ ),
+ (
+ crate::platform::Signal::Terminate,
+ std::time::Duration::from_millis(250),
+ ),
+ (
+ crate::platform::Signal::Kill,
+ std::time::Duration::from_millis(250),
+ ),
+ ] {
+ crate::platform::signal_processes(&pids, signal);
+ if wait_for_processes_to_exit(&pids, grace) {
+ info!(
+ pane = pane_id.raw(),
+ pid = child_pid,
+ ?signal,
+ "pane session terminated"
+ );
+ return;
+ }
+ }
+
+ warn!(
+ pane = pane_id.raw(),
+ pid = child_pid,
+ pids = ?pids,
+ "pane session still alive after forced shutdown"
+ );
+}
+
impl PaneRuntime {
+ pub fn shutdown(self, pane_id: PaneId) {
+ self.detect_handle.abort();
+ shutdown_pane_processes(pane_id, self.child_pid.load(Ordering::Acquire));
+ }
+
pub fn spawn(
pane_id: PaneId,
rows: u16,
diff --git a/src/platform/fallback.rs b/src/platform/fallback.rs
index 7bcd23c5..4bf78352 100644
--- a/src/platform/fallback.rs
+++ b/src/platform/fallback.rs
@@ -1,6 +1,6 @@
use std::path::PathBuf;
-use super::ForegroundJob;
+use super::{ForegroundJob, Signal};
/// Unsupported platform stub.
pub fn foreground_job(_child_pid: u32) -> Option<ForegroundJob> {
@@ -11,3 +11,16 @@ pub fn foreground_job(_child_pid: u32) -> Option<ForegroundJob> {
pub fn process_cwd(_pid: u32) -> Option<PathBuf> {
None
}
+
+/// Unsupported platform stub.
+pub fn session_processes(_child_pid: u32) -> Vec<u32> {
+ Vec::new()
+}
+
+/// Unsupported platform stub.
+pub fn signal_processes(_pids: &[u32], _signal: Signal) {}
+
+/// Unsupported platform stub.
+pub fn process_exists(_pid: u32) -> bool {
+ false
+}
diff --git a/src/platform/linux.rs b/src/platform/linux.rs
index 10695e92..b6fc0272 100644
--- a/src/platform/linux.rs
+++ b/src/platform/linux.rs
@@ -1,6 +1,6 @@
use std::path::PathBuf;
-use super::{ForegroundJob, ForegroundProcess};
+use super::{ForegroundJob, ForegroundProcess, Signal};
/// Collect the foreground terminal job for a given child PID.
pub fn foreground_job(child_pid: u32) -> Option<ForegroundJob> {
@@ -87,3 +87,64 @@ pub fn process_cwd(pid: u32) -> Option<PathBuf> {
}
std::fs::read_link(format!("/proc/{pid}/cwd")).ok()
}
+
+pub fn session_processes(child_pid: u32) -> Vec<u32> {
+ let Some(session_id) = process_session_id(child_pid) else {
+ return Vec::new();
+ };
+
+ let mut pids = Vec::new();
+ for entry in std::fs::read_dir("/proc").into_iter().flatten().flatten() {
+ let file_name = entry.file_name();
+ let Some(pid_str) = file_name.to_str() else {
+ continue;
+ };
+ if !pid_str.bytes().all(|b| b.is_ascii_digit()) {
+ continue;
+ }
+
+ let Ok(pid) = pid_str.parse::<u32>() else {
+ continue;
+ };
+ if process_session_id(pid) == Some(session_id) {
+ pids.push(pid);
+ }
+ }
+ pids
+}
+
+pub fn signal_processes(pids: &[u32], signal: Signal) {
+ let sig = match signal {
+ Signal::Hangup => libc::SIGHUP,
+ Signal::Terminate => libc::SIGTERM,
+ Signal::Kill => libc::SIGKILL,
+ };
+
+ for &pid in pids {
+ if pid == 0 {
+ continue;
+ }
+ unsafe {
+ libc::kill(pid as i32, sig);
+ }
+ }
+}
+
+pub fn process_exists(pid: u32) -> bool {
+ if pid == 0 {
+ return false;
+ }
+ let result = unsafe { libc::kill(pid as i32, 0) };
+ if result == 0 {
+ true
+ } else {
+ std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
+ }
+}
+
+fn process_session_id(pid: u32) -> Option<i32> {
+ let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
+ let rest = stat.get(stat.rfind(')')? + 2..)?;
+ let fields: Vec<&str> = rest.split_whitespace().collect();
+ fields.get(3)?.parse().ok()
+}
diff --git a/src/platform/macos.rs b/src/platform/macos.rs
index e2f368f8..82de0c5b 100644
--- a/src/platform/macos.rs
+++ b/src/platform/macos.rs
@@ -2,7 +2,7 @@ use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
-use super::{ForegroundJob, ForegroundProcess};
+use super::{ForegroundJob, ForegroundProcess, Signal};
/// Collect the foreground terminal job for a given child PID.
pub fn foreground_job(child_pid: u32) -> Option<ForegroundJob> {
@@ -284,3 +284,62 @@ pub fn process_cwd(pid: u32) -> Option<PathBuf> {
}
Some(PathBuf::from(OsStr::from_bytes(&vip_path[..nul])))
}
+
+pub fn session_processes(child_pid: u32) -> Vec<u32> {
+ if child_pid == 0 {
+ return Vec::new();
+ }
+
+ let target_session = unsafe { libc::getsid(child_pid as libc::c_int) };
+ if target_session <= 0 {
+ return Vec::new();
+ }
+
+ let mut pids = vec![0i32; 4096];
+ let bytes = unsafe {
+ libc::proc_listallpids(
+ pids.as_mut_ptr() as *mut libc::c_void,
+ (pids.len() * std::mem::size_of::<i32>()) as libc::c_int,
+ )
+ };
+ if bytes <= 0 {
+ return Vec::new();
+ }
+
+ let count = (bytes as usize) / std::mem::size_of::<i32>();
+ pids.into_iter()
+ .take(count)
+ .filter(|pid| *pid > 0)
+ .filter(|pid| unsafe { libc::getsid(*pid) } == target_session)
+ .map(|pid| pid as u32)
+ .collect()
+}
+
+pub fn signal_processes(pids: &[u32], signal: Signal) {
+ let sig = match signal {
+ Signal::Hangup => libc::SIGHUP,
+ Signal::Terminate => libc::SIGTERM,
+ Signal::Kill => libc::SIGKILL,
+ };
+
+ for &pid in pids {
+ if pid == 0 {
+ continue;
+ }
+ unsafe {
+ libc::kill(pid as libc::c_int, sig);
+ }
+ }
+}
+
+pub fn process_exists(pid: u32) -> bool {
+ if pid == 0 {
+ return false;
+ }
+ let result = unsafe { libc::kill(pid as libc::c_int, 0) };
+ if result == 0 {
+ true
+ } else {
+ std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
+ }
+}
diff --git a/src/platform/mod.rs b/src/platform/mod.rs
index ade86249..8023cd11 100644
--- a/src/platform/mod.rs
+++ b/src/platform/mod.rs
@@ -17,6 +17,13 @@ pub struct ForegroundJob {
pub processes: Vec<ForegroundProcess>,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Signal {
+ Hangup,
+ Terminate,
+ Kill,
+}
+
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "linux")]
diff --git a/src/workspace.rs b/src/workspace.rs
index 63e98912..39ee19a0 100644
--- a/src/workspace.rs
+++ b/src/workspace.rs
@@ -35,6 +35,15 @@ pub struct Workspace {
pub(crate) render_dirty: Arc<AtomicBool>,
}
+impl Drop for Workspace {
+ fn drop(&mut self) {
+ let runtimes = std::mem::take(&mut self.runtimes);
+ for (pane_id, runtime) in runtimes {
+ runtime.shutdown(pane_id);
+ }
+ }
+}
+
impl Workspace {
pub fn new(
initial_cwd: PathBuf,
@@ -111,12 +120,27 @@ impl Workspace {
/// Close the focused pane. Returns the removed pane id, or None if last pane.
pub fn close_focused(&mut self) -> Option<PaneId> {
let pane_id = self.layout.focused();
- self.remove_pane(pane_id)
+ self.close_pane(pane_id)
+ }
+
+ /// Close a specific pane and terminate everything running inside it.
+ /// Returns None if it's the last pane and the whole workspace should close.
+ pub fn close_pane(&mut self, pane_id: PaneId) -> Option<PaneId> {
+ let (removed, runtime) = self.detach_pane(pane_id)?;
+ if let Some(runtime) = runtime {
+ runtime.shutdown(pane_id);
+ }
+ Some(removed)
}
- /// Remove a specific pane from this workspace.
+ /// Remove a specific pane from this workspace without terminating its runtime.
+ /// Used when the pane process has already exited and we are only cleaning up state.
/// Returns None if it's the last pane and the whole workspace should close.
pub fn remove_pane(&mut self, pane_id: PaneId) -> Option<PaneId> {
+ self.detach_pane(pane_id).map(|(removed, _)| removed)
+ }
+
+ fn detach_pane(&mut self, pane_id: PaneId) -> Option<(PaneId, Option<PaneRuntime>)> {
if self.layout.pane_count() <= 1 {
return None;
}
@@ -141,12 +165,12 @@ impl Workspace {
self.next_public_pane_number = self.public_pane_numbers.len() + 1;
}
self.panes.remove(&pane_id);
- self.runtimes.remove(&pane_id);
+ let runtime = self.runtimes.remove(&pane_id);
self.zoomed = false;
if let Some(next_root) = next_root {
self.root_pane = next_root;
}
- Some(pane_id)
+ Some((pane_id, runtime))
}
fn promoted_root_if_needed(&self, closing: PaneId) -> Option<PaneId> {
diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs
index c8dbc8b8..dd52c082 100644
--- a/tests/cli_wrapper.rs
+++ b/tests/cli_wrapper.rs
@@ -71,6 +71,26 @@ fn run_cli(socket_path: &Path, args: &[&str]) -> std::process::Output {
command.output().unwrap()
}
+fn process_exists(pid: u32) -> bool {
+ let result = unsafe { libc::kill(pid as i32, 0) };
+ if result == 0 {
+ true
+ } else {
+ std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
+ }
+}
+
+fn wait_for_pid_exit(pid: u32, timeout: Duration) -> bool {
+ let deadline = Instant::now() + timeout;
+ while Instant::now() < deadline {
+ if !process_exists(pid) {
+ return true;
+ }
+ thread::sleep(Duration::from_millis(25));
+ }
+ !process_exists(pid)
+}
+
fn send_request(socket_path: &Path, json: &str) -> serde_json::Value {
let mut stream = UnixStream::connect(socket_path).unwrap();
stream.write_all(json.as_bytes()).unwrap();
@@ -228,6 +248,128 @@ fn pane_run_read_and_wait_commands_work() {
}
#[test]
+fn closing_pane_terminates_processes_inside_it() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let socket_path = runtime_dir.join("herdr.sock");
+
+ let mut herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
+ wait_for_socket(&socket_path, Duration::from_secs(5));
+
+ let created = run_cli(
+ &socket_path,
+ &["workspace", "create", "--cwd", base.to_str().unwrap()],
+ );
+ assert!(created.status.success());
+
+ let split = run_cli(
+ &socket_path,
+ &["pane", "split", "1-1", "--direction", "right"],
+ );
+ assert!(split.status.success());
+ let split_json: serde_json::Value = serde_json::from_slice(&split.stdout).unwrap();
+ let pane_id = split_json["result"]["pane"]["pane_id"].as_str().unwrap();
+
+ let pid_file = base.join("pane-close.pid");
+ let command = format!(
+ "python3 -c 'import os,time,pathlib; pathlib.Path(r\"{}\").write_text(str(os.getpid())); time.sleep(1000)'",
+ pid_file.display()
+ );
+ let ran = run_cli(&socket_path, &["pane", "run", pane_id, &command]);
+ assert!(
+ ran.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&ran.stderr)
+ );
+
+ let deadline = Instant::now() + Duration::from_secs(5);
+ while Instant::now() < deadline && !pid_file.exists() {
+ thread::sleep(Duration::from_millis(25));
+ }
+ assert!(pid_file.exists(), "pid file was not created");
+
+ let pid: u32 = fs::read_to_string(&pid_file)
+ .unwrap()
+ .trim()
+ .parse()
+ .unwrap();
+ assert!(process_exists(pid), "child process was not running");
+
+ let closed = run_cli(&socket_path, &["pane", "close", pane_id]);
+ assert!(
+ closed.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&closed.stderr)
+ );
+ assert!(
+ wait_for_pid_exit(pid, Duration::from_secs(3)),
+ "process {pid} survived pane close"
+ );
+
+ let _ = herdr.child.kill();
+ let _ = herdr.child.wait();
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn closing_workspace_terminates_processes_inside_it() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let socket_path = runtime_dir.join("herdr.sock");
+
+ let mut herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
+ wait_for_socket(&socket_path, Duration::from_secs(5));
+
+ let created = run_cli(
+ &socket_path,
+ &["workspace", "create", "--cwd", base.to_str().unwrap()],
+ );
+ assert!(created.status.success());
+
+ let pid_file = base.join("workspace-close.pid");
+ let command = format!(
+ "python3 -c 'import os,time,pathlib; pathlib.Path(r\"{}\").write_text(str(os.getpid())); time.sleep(1000)'",
+ pid_file.display()
+ );
+ let ran = run_cli(&socket_path, &["pane", "run", "1-1", &command]);
+ assert!(
+ ran.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&ran.stderr)
+ );
+
+ let deadline = Instant::now() + Duration::from_secs(5);
+ while Instant::now() < deadline && !pid_file.exists() {
+ thread::sleep(Duration::from_millis(25));
+ }
+ assert!(pid_file.exists(), "pid file was not created");
+
+ let pid: u32 = fs::read_to_string(&pid_file)
+ .unwrap()
+ .trim()
+ .parse()
+ .unwrap();
+ assert!(process_exists(pid), "child process was not running");
+
+ let closed = run_cli(&socket_path, &["workspace", "close", "1"]);
+ assert!(
+ closed.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&closed.stderr)
+ );
+ assert!(
+ wait_for_pid_exit(pid, Duration::from_secs(3)),
+ "process {pid} survived workspace close"
+ );
+
+ let _ = herdr.child.kill();
+ let _ = herdr.child.wait();
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
fn ids_are_compact_and_positional() {
let base = unique_test_dir();
let config_home = base.join("config");