aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorakbash <akbash@herdr.dev>2026-07-13 17:25:52 +0300
committerGitHub <noreply@github.com>2026-07-13 17:25:52 +0300
commit75ed6abc6258f09af5d4fc995a5004c1abf14fc2 (patch)
tree7dd561becfc189f2dbe6f153d0c870ac913351ae /src
parent6e85ff07f188186edcec8ac0e8263f899c7f1f26 (diff)
fix: reap detached custom command children (#1384)
* fix: reap detached custom command children refs #1360 * test: harden child cleanup and sidebar metrics refs #1360 --------- Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com>
Diffstat (limited to 'src')
-rw-r--r--src/app/input/navigate.rs49
-rw-r--r--src/app/mod.rs3
-rw-r--r--src/app/runtime.rs27
-rw-r--r--src/server/headless.rs1
-rw-r--r--src/ui/sidebar.rs3
5 files changed, 75 insertions, 8 deletions
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs
index a1597669..7f98f530 100644
--- a/src/app/input/navigate.rs
+++ b/src/app/input/navigate.rs
@@ -843,7 +843,7 @@ impl App {
}
fn spawn_custom_command(
- &self,
+ &mut self,
binding: &crate::config::CustomCommandKeybind,
) -> std::io::Result<()> {
let mut command = crate::platform::detached_custom_command_process(&binding.command);
@@ -856,7 +856,8 @@ impl App {
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
- command.spawn()?;
+ let child = command.spawn()?;
+ self.detached_custom_command_children.push(child);
Ok(())
}
@@ -2943,9 +2944,11 @@ navigate_pane_down = "ctrl+j"
app.state.mode = Mode::Terminal;
let output_path = unique_temp_path("custom-command-keybind");
+ let release_path = unique_temp_path("custom-command-release");
let command = format!(
- "printf '%s\\n%s\\n%s\\n' \"$HERDR_ACTIVE_WORKSPACE_ID\" \"$HERDR_ACTIVE_TAB_ID\" \"$HERDR_ACTIVE_PANE_ID\" > '{}'",
- output_path.display()
+ "printf '%s\\n%s\\n%s\\n%s\\n' \"$$\" \"$HERDR_ACTIVE_WORKSPACE_ID\" \"$HERDR_ACTIVE_TAB_ID\" \"$HERDR_ACTIVE_PANE_ID\" > '{}'; i=0; while [ ! -e '{}' ] && [ \"$i\" -lt 250 ]; do sleep 0.02; i=$((i + 1)); done",
+ output_path.display(),
+ release_path.display(),
);
app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
bindings: crate::config::ActionKeybinds::prefix("m"),
@@ -2962,18 +2965,48 @@ navigate_pane_down = "ctrl+j"
.await;
assert_eq!(app.state.mode, Mode::Prefix);
+ let launch_started = std::time::Instant::now();
app.handle_key(TerminalKey::new(KeyCode::Char('m'), KeyModifiers::empty()))
.await;
+ assert!(launch_started.elapsed() < Duration::from_secs(2));
let content = wait_for_file(&output_path);
let lines: Vec<&str> = content.lines().collect();
- assert_eq!(lines.len(), 3);
- assert_eq!(lines[0], app.state.workspaces[0].id);
- assert_eq!(lines[1], format!("{}:t1", app.state.workspaces[0].id));
- assert_eq!(lines[2], format!("{}:p1", app.state.workspaces[0].id));
+ assert_eq!(lines.len(), 4);
+ let pid = lines[0]
+ .parse::<u32>()
+ .expect("command should report its pid");
+ assert!(crate::platform::process_exists(pid));
+ assert_eq!(lines[1], app.state.workspaces[0].id);
+ assert_eq!(lines[2], format!("{}:t1", app.state.workspaces[0].id));
+ assert_eq!(lines[3], format!("{}:p1", app.state.workspaces[0].id));
assert_eq!(app.state.mode, Mode::Terminal);
+ std::fs::write(&release_path, b"release").expect("release command");
+ let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
+ while crate::platform::process_exists(pid) && tokio::time::Instant::now() < deadline {
+ app.reap_finished_custom_commands();
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ }
+ app.reap_finished_custom_commands();
+ let reaped_by_runtime = !crate::platform::process_exists(pid);
+ if !reaped_by_runtime {
+ if let Some(child) = app
+ .detached_custom_command_children
+ .iter_mut()
+ .find(|child| child.id() == pid)
+ {
+ let _ = child.kill();
+ let _ = child.wait();
+ }
+ }
+ assert!(
+ reaped_by_runtime,
+ "detached command child {pid} was not reaped"
+ );
+
let _ = std::fs::remove_file(output_path);
+ let _ = std::fs::remove_file(release_path);
}
#[cfg(unix)]
diff --git a/src/app/mod.rs b/src/app/mod.rs
index bff04c44..16cfeafb 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -130,6 +130,7 @@ pub struct App {
pub(crate) selection_highlight_clear_deadline: Option<Instant>,
pub(crate) session_save_deadline: Option<Instant>,
pub(crate) session_save_thread: Option<std::thread::JoinHandle<()>>,
+ pub(crate) detached_custom_command_children: Vec<std::process::Child>,
pub(crate) persist_pane_history: bool,
pub(crate) last_render_at: Option<Instant>,
pub(crate) suppressed_repeat_keys:
@@ -725,6 +726,7 @@ impl App {
pending_agent_resume_deadline: None,
session_save_deadline: None,
session_save_thread: None,
+ detached_custom_command_children: Vec::new(),
selection_autoscroll_deadline: None,
selection_highlight_clear_deadline: None,
persist_pane_history: config.experimental.pane_history,
@@ -879,6 +881,7 @@ impl App {
let mut host_mouse_capture_active = self.state.mouse_capture;
while !self.state.should_quit {
+ self.reap_finished_custom_commands();
if self.render_dirty.load(Ordering::Acquire) {
needs_render = true;
}
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index 88081743..25275cd7 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -38,7 +38,27 @@ pub(crate) struct WorkspaceGitRefreshOutput {
pub(crate) cache_updates: Vec<(std::path::PathBuf, GitStatusCacheEntry)>,
}
+fn retain_custom_command_after_wait(
+ pid: u32,
+ result: std::io::Result<Option<std::process::ExitStatus>>,
+) -> bool {
+ match result {
+ Ok(None) => true,
+ Ok(Some(_)) => false,
+ Err(err) if err.kind() == std::io::ErrorKind::Interrupted => true,
+ Err(err) => {
+ tracing::warn!(pid, err = %err, "failed to reap detached custom command");
+ false
+ }
+ }
+}
+
impl App {
+ pub(crate) fn reap_finished_custom_commands(&mut self) {
+ self.detached_custom_command_children
+ .retain_mut(|child| retain_custom_command_after_wait(child.id(), child.try_wait()));
+ }
+
pub(crate) fn shutdown_detached_terminal_runtimes(&mut self) {
let terminal_ids = std::mem::take(&mut self.state.terminal_runtime_shutdowns);
for terminal_id in terminal_ids {
@@ -697,6 +717,13 @@ mod tests {
use crate::workspace::Workspace;
use std::path::PathBuf;
+ #[test]
+ fn interrupted_custom_command_wait_keeps_child_for_retry() {
+ let interrupted = std::io::Error::new(std::io::ErrorKind::Interrupted, "test interrupt");
+
+ assert!(retain_custom_command_after_wait(42, Err(interrupted)));
+ }
+
fn test_app_with_pane() -> (super::super::App, crate::layout::PaneId) {
let mut app = super::super::App::new(
&crate::config::Config::default(),
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 6cec130a..c965d8fb 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -445,6 +445,7 @@ impl HeadlessServer {
loop {
crate::render_prof::event("loop.tick");
crate::render_prof::flush_if_due();
+ self.app.reap_finished_custom_commands();
// If shutdown has been initiated, complete it and exit.
if self.shutting_down {
diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs
index 18d13b26..cedae025 100644
--- a/src/ui/sidebar.rs
+++ b/src/ui/sidebar.rs
@@ -2067,6 +2067,9 @@ mod tests {
workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"),
Workspace::test_new("notes"),
];
+ for workspace in &mut app.workspaces {
+ workspace.cached_git_branch = Some("main".into());
+ }
app.collapsed_space_keys.insert("repo-key".into());
app.active = None;
app.mode = Mode::Terminal;