diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-31 13:29:57 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-31 13:29:57 +0300 |
| commit | f8c452294d6f76ccb264c0d9e205398492f00e18 (patch) | |
| tree | 68f9505424de441b5da4dbe26fab6149e2a75734 | |
| parent | b8ccdf600c750411b439d0eb41f6a70e3c595f4f (diff) | |
fix: terminate pane sessions on close
| -rw-r--r-- | Cargo.toml | 4 | ||||
| -rw-r--r-- | src/app/mod.rs | 2 | ||||
| -rw-r--r-- | src/pane.rs | 67 | ||||
| -rw-r--r-- | src/platform/fallback.rs | 15 | ||||
| -rw-r--r-- | src/platform/linux.rs | 63 | ||||
| -rw-r--r-- | src/platform/macos.rs | 61 | ||||
| -rw-r--r-- | src/platform/mod.rs | 7 | ||||
| -rw-r--r-- | src/workspace.rs | 32 | ||||
| -rw-r--r-- | tests/cli_wrapper.rs | 142 |
9 files changed, 382 insertions, 11 deletions
@@ -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"); |
