From 2d5a07e5e9bd076cc5f13d06583a3d16b19bcec8 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Sun, 13 Sep 2026 23:19:27 +0300 Subject: [PATCH] fix: keep plugins working after client-only updates --- src/app/api/plugins/mod.rs | 106 +++++++++++++++++++++++++++++++++ src/app/api/plugins/panes.rs | 2 +- src/app/api/plugins/runtime.rs | 2 +- src/integration/env.rs | 2 +- src/platform/linux.rs | 20 +++++++ src/platform/mod.rs | 5 ++ 6 files changed, 134 insertions(+), 3 deletions(-) diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs index cf6327b6..35dbee2f 100644 --- a/src/app/api/plugins/mod.rs +++ b/src/app/api/plugins/mod.rs @@ -1738,6 +1738,112 @@ command = ["cmd.exe", "/d", "/c", "slot.cmd", "default"] } } + #[cfg(target_os = "linux")] + #[tokio::test] + async fn plugin_launch_survives_executable_replacement() { + const CHILD_ROOT: &str = "HERDR_TEST_PLUGIN_REPLACEMENT_ROOT"; + if let Some(root) = std::env::var_os(CHILD_ROOT) { + let root = std::path::PathBuf::from(root); + let executable = std::env::current_exe().unwrap(); + let replacement = root.join("replacement"); + std::fs::copy(&executable, &replacement).unwrap(); + std::fs::rename(&replacement, &executable).unwrap(); + assert!(!std::env::current_exe().unwrap().exists()); + + let mut app = test_app(); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("plugin-update")]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = crate::app::Mode::Terminal; + let plugin_root = root.join("plugin"); + write_manifest_content( + &plugin_root, + r#" +id = "example.update" +name = "Update Probe" +version = "0.1.0" +min_herdr_version = "0.6.10" +platforms = ["linux"] + +[[actions]] +id = "probe" +title = "Probe executable" +command = ["sh", "-c", '"$HERDR_BIN_PATH" --list >/dev/null; printf "%s\n" "$?" > action-status'] + +[[panes]] +id = "probe" +title = "Probe executable" +command = ["sh", "-c", '"$HERDR_BIN_PATH" --list >/dev/null; printf "%s\n" "$?" > pane-status'] +"#, + ); + link_manifest(&mut app, &plugin_root); + app.invoke_plugin_action_from_keybind("example.update.probe".into(), None) + .unwrap(); + let action_status = read_capture_when_ready(&plugin_root.join("action-status"), || { + app.drain_all_internal_events(); + }); + let open = app.handle_api_request(Request { + id: "update-pane".into(), + method: Method::PluginPaneOpen(PluginPaneOpenParams { + plugin_id: "example.update".into(), + entrypoint: "probe".into(), + placement: Some(PluginPanePlacement::Overlay), + width: None, + height: None, + workspace_id: None, + target_pane_id: None, + direction: None, + cwd: None, + focus: true, + env: std::collections::HashMap::new(), + }), + }); + assert!(matches!( + response_result(&open), + ResponseResult::PluginPaneOpened { .. } + )); + let pane_status = read_capture_when_ready(&plugin_root.join("pane-status"), || {}); + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + assert_eq!( + (action_status.trim(), pane_status.trim()), + ("0", "0"), + "plugin action and pane must launch Herdr after its executable is replaced" + ); + return; + } + + let root = std::path::PathBuf::from("/var/tmp").join(format!( + "herdr-plugin-update-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&root).unwrap(); + let executable = root.join("herdr test"); + std::fs::copy(std::env::current_exe().unwrap(), &executable).unwrap(); + let result = std::process::Command::new(&executable) + .args([ + "--exact", + "app::api::plugins::tests::plugin_launch_survives_executable_replacement", + "--nocapture", + ]) + .env(CHILD_ROOT, &root) + .output(); + std::fs::remove_dir_all(&root).unwrap(); + let output = result.unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + #[cfg(unix)] #[tokio::test] async fn plugin_pane_open_uses_plugin_root_title_env_and_target_context() { diff --git a/src/app/api/plugins/panes.rs b/src/app/api/plugins/panes.rs index 8553ce91..05791886 100644 --- a/src/app/api/plugins/panes.rs +++ b/src/app/api/plugins/panes.rs @@ -260,7 +260,7 @@ impl App { entrypoint.to_string(), )); env.push(("HERDR_PLUGIN_CONTEXT_JSON".to_string(), context_json)); - if let Ok(current_exe) = std::env::current_exe() { + if let Ok(current_exe) = crate::platform::launch_executable() { env.push(( "HERDR_BIN_PATH".to_string(), current_exe.display().to_string(), diff --git a/src/app/api/plugins/runtime.rs b/src/app/api/plugins/runtime.rs index b956fc98..30e2f7ae 100644 --- a/src/app/api/plugins/runtime.rs +++ b/src/app/api/plugins/runtime.rs @@ -46,7 +46,7 @@ impl App { ("HERDR_PLUGIN_ID".to_string(), plugin.plugin_id.clone()), ("HERDR_PLUGIN_CONTEXT_JSON".to_string(), context_json), ]); - if let Ok(current_exe) = std::env::current_exe() { + if let Ok(current_exe) = crate::platform::launch_executable() { env.push(( "HERDR_BIN_PATH".to_string(), current_exe.display().to_string(), diff --git a/src/integration/env.rs b/src/integration/env.rs index 548c1386..bec9e23b 100644 --- a/src/integration/env.rs +++ b/src/integration/env.rs @@ -27,7 +27,7 @@ pub(crate) const HERMES_HOME_ENV_VAR: &str = "HERMES_HOME"; pub(crate) fn apply_pane_base_env(cmd: &mut CommandBuilder) { cmd.env(crate::api::SOCKET_PATH_ENV_VAR, crate::api::socket_path()); - if let Ok(executable) = std::env::current_exe() { + if let Ok(executable) = crate::platform::launch_executable() { cmd.env("HERDR_BIN_PATH", executable); } } diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 7b0755e1..d563512d 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -82,6 +82,26 @@ struct ProcGroupMember { state: char, } +pub(crate) fn launch_executable() -> std::io::Result { + use std::os::unix::ffi::OsStrExt; + + let executable = std::env::current_exe()?; + if !executable.is_file() { + // Linux marks the old inode as deleted after an update replaces the binary. + if let Some(path) = executable + .as_os_str() + .as_bytes() + .strip_suffix(b" (deleted)") + { + let replacement = PathBuf::from(std::ffi::OsStr::from_bytes(path)); + if replacement.is_file() { + return Ok(replacement); + } + } + } + Ok(executable) +} + pub fn raise_server_nofile_limit() {} pub(crate) fn should_draw_host_cursor_by_default() -> bool { diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 67e474ff..3efad45e 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -55,6 +55,11 @@ pub(crate) fn classify_child_exit(_status: &portable_pty::ExitStatus) -> ChildEx ChildExitReason::Exited } +#[cfg(not(target_os = "linux"))] +pub(crate) fn launch_executable() -> std::io::Result { + std::env::current_exe() +} + pub(crate) fn detached_custom_command_process(command: &str) -> std::process::Command { let mut process = detached_custom_command_process_platform(command); configure_background_command(&mut process);