aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-21 02:01:22 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-21 02:01:22 +0300
commit741095256765d09d54d9fc541a7fd060a92287b4 (patch)
treed9a7b0450053baf61a2f19f1692fb75352c0024e /src
parent87633a8fb51d51cf7c9982f1bbad69c51bf0aa3e (diff)
fix: restart remote server after binary update
Diffstat (limited to 'src')
-rw-r--r--src/remote.rs249
1 files changed, 214 insertions, 35 deletions
diff --git a/src/remote.rs b/src/remote.rs
index 089772b4..66b9b6e5 100644
--- a/src/remote.rs
+++ b/src/remote.rs
@@ -13,10 +13,12 @@ use std::sync::{
Arc,
};
use std::thread::{self, JoinHandle};
-use std::time::Duration;
+use std::time::{Duration, Instant};
const BRIDGE_ACCEPT_POLL: Duration = Duration::from_millis(50);
const BRIDGE_SOCKET_PERMISSION_MODE: u32 = 0o600;
+const REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT: Duration = Duration::from_secs(5);
+const REMOTE_SERVER_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100);
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const CURRENT_PROTOCOL: u32 = crate::server::protocol::PROTOCOL_VERSION;
const UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json";
@@ -89,12 +91,16 @@ pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> {
.next()
.unwrap_or_else(|| "herdr".to_string());
let reattach_command = reattach_command(&program, &remote.target, &session_name);
- let remote_herdr = prepare_remote_herdr(&remote.target)?;
- ensure_remote_server_compatible(&remote.target, &remote_herdr)?;
+ let prepared_remote = prepare_remote_herdr(&remote.target)?;
+ ensure_remote_server_ready(
+ &remote.target,
+ &prepared_remote.remote_herdr,
+ prepared_remote.installed_or_replaced,
+ )?;
let _bridge = SshStdioBridge::start(
remote.target,
- remote_herdr,
+ prepared_remote.remote_herdr,
local_socket.clone(),
session_name,
)?;
@@ -141,7 +147,7 @@ fn ensure_remote_server_running() -> io::Result<()> {
return Ok(());
}
return Err(io::Error::other(format!(
- "remote herdr server is running with protocol {}, but this bridge needs protocol {CURRENT_PROTOCOL}; rerun `herdr --remote` from an interactive terminal to approve replacing it",
+ "remote herdr server is running with protocol {}, but this bridge needs protocol {CURRENT_PROTOCOL}; rerun `herdr --remote` from an interactive terminal to approve stopping it",
protocol_label(status.protocol)
)));
}
@@ -231,6 +237,11 @@ struct InstallSource {
temporary_dir: Option<PathBuf>,
}
+struct PreparedRemoteHerdr {
+ remote_herdr: RemoteHerdr,
+ installed_or_replaced: bool,
+}
+
impl InstallSource {
fn persistent(path: PathBuf) -> Self {
Self {
@@ -253,17 +264,23 @@ impl InstallSource {
}
}
-fn prepare_remote_herdr(target: &str) -> io::Result<RemoteHerdr> {
+fn prepare_remote_herdr(target: &str) -> io::Result<PreparedRemoteHerdr> {
let platform = detect_remote_platform(target)?;
let remote_herdr = RemoteHerdr::for_platform(platform);
let override_binary = remote_binary_override_path()?;
if override_binary.is_none() {
if let Some(path_remote_herdr) = remote_binary_on_path(target, &remote_herdr)? {
- return Ok(path_remote_herdr);
+ return Ok(PreparedRemoteHerdr {
+ remote_herdr: path_remote_herdr,
+ installed_or_replaced: false,
+ });
}
if remote_binary_matches(target, &remote_herdr)? {
- return Ok(remote_herdr);
+ return Ok(PreparedRemoteHerdr {
+ remote_herdr,
+ installed_or_replaced: false,
+ });
}
}
@@ -286,7 +303,10 @@ fn prepare_remote_herdr(target: &str) -> io::Result<RemoteHerdr> {
warn_if_remote_bin_not_on_path(target)?;
maybe_copy_local_keybindings_to_remote(target, &remote_herdr)?;
- Ok(remote_herdr)
+ Ok(PreparedRemoteHerdr {
+ remote_herdr,
+ installed_or_replaced: true,
+ })
}
fn detect_remote_platform(target: &str) -> io::Result<RemotePlatform> {
@@ -429,24 +449,59 @@ fn resolve_install_source(
download_release_asset(platform)
}
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq)]
enum RemoteServerStatus {
- Running { protocol: Option<u32> },
+ Running {
+ version: Option<String>,
+ protocol: Option<u32>,
+ },
NotRunning,
}
-fn ensure_remote_server_compatible(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<()> {
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum RemoteServerRestartReason {
+ ProtocolMismatch,
+ BinaryUpdated,
+ VersionMismatch,
+}
+
+fn ensure_remote_server_ready(
+ target: &str,
+ remote_herdr: &RemoteHerdr,
+ remote_binary_changed: bool,
+) -> io::Result<()> {
let status = remote_server_status(target, remote_herdr)?;
- let RemoteServerStatus::Running { protocol } = status else {
+ let RemoteServerStatus::Running { version, protocol } = status else {
return Ok(());
};
- if protocol == Some(CURRENT_PROTOCOL) {
+ let Some(reason) =
+ remote_server_restart_reason(version.as_deref(), protocol, remote_binary_changed)
+ else {
return Ok(());
+ };
+
+ if confirm_remote_server_stop(target, version.as_deref(), protocol, reason)? {
+ stop_remote_server(target, remote_herdr)?;
}
+ Ok(())
+}
- confirm_remote_server_stop(target, protocol)?;
- stop_remote_server(target, remote_herdr)
+fn remote_server_restart_reason(
+ version: Option<&str>,
+ protocol: Option<u32>,
+ remote_binary_changed: bool,
+) -> Option<RemoteServerRestartReason> {
+ if protocol != Some(CURRENT_PROTOCOL) {
+ return Some(RemoteServerRestartReason::ProtocolMismatch);
+ }
+ if remote_binary_changed {
+ return Some(RemoteServerRestartReason::BinaryUpdated);
+ }
+ if version != Some(CURRENT_VERSION) {
+ return Some(RemoteServerRestartReason::VersionMismatch);
+ }
+ None
}
fn remote_server_status(
@@ -469,6 +524,7 @@ fn remote_server_status(
if stdout.lines().any(|line| line.trim() == "status: running") {
return Ok(RemoteServerStatus::Running {
+ version: parse_status_version(&stdout).map(str::to_owned),
protocol: parse_status_protocol(&stdout),
});
}
@@ -479,49 +535,127 @@ fn remote_server_status(
)))
}
-fn parse_status_protocol(status: &str) -> Option<u32> {
+fn parse_status_value<'a>(status: &'a str, key: &str) -> Option<&'a str> {
status.lines().find_map(|line| {
- let (_, value) = line.trim().split_once(':')?;
- (line.trim_start().starts_with("protocol:")).then(|| value.trim().parse().ok())?
+ let line = line.trim();
+ let (found_key, value) = line.split_once(':')?;
+ (found_key == key).then_some(value.trim())
})
}
-fn confirm_remote_server_stop(target: &str, protocol: Option<u32>) -> io::Result<()> {
+fn parse_status_version(status: &str) -> Option<&str> {
+ parse_status_value(status, "version")
+}
+
+fn parse_status_protocol(status: &str) -> Option<u32> {
+ parse_status_value(status, "protocol")?.parse().ok()
+}
+
+fn confirm_remote_server_stop(
+ target: &str,
+ version: Option<&str>,
+ protocol: Option<u32>,
+ reason: RemoteServerRestartReason,
+) -> io::Result<bool> {
if !io::stdin().is_terminal() {
- return Err(io::Error::other(format!(
- "remote herdr server on {target} is running with protocol {}, but this client needs protocol {CURRENT_PROTOCOL}; run from an interactive terminal to approve stopping it",
- protocol_label(protocol)
- )));
+ if reason == RemoteServerRestartReason::ProtocolMismatch {
+ return Err(io::Error::other(format!(
+ "remote herdr server on {target} is running with protocol {}, but this client needs protocol {CURRENT_PROTOCOL}; run from an interactive terminal to approve stopping it",
+ protocol_label(protocol)
+ )));
+ }
+
+ eprintln!(
+ "remote herdr server on {target} is still running v{}; it will use v{CURRENT_VERSION} after it restarts.",
+ version_label(version)
+ );
+ return Ok(false);
}
+ eprintln!("remote herdr server on {target} is currently running:");
eprintln!(
- "remote herdr server on {target} is running with protocol {}, but this client needs protocol {CURRENT_PROTOCOL}.",
+ " server: v{} protocol {}",
+ version_label(version),
protocol_label(protocol)
);
- eprint!("Install/replace the remote binary if needed and stop the running remote server now? This will kill the remote Herdr server. [Y/n] ");
+ eprintln!(" prepared binary: v{CURRENT_VERSION} protocol {CURRENT_PROTOCOL}");
+ eprintln!();
+
+ match reason {
+ RemoteServerRestartReason::ProtocolMismatch => {
+ eprintln!(
+ "the remote server protocol does not match this client. the remote server must be stopped before attaching."
+ );
+ }
+ RemoteServerRestartReason::BinaryUpdated => {
+ eprintln!(
+ "the remote herdr binary was installed or replaced. restart the remote server so it uses the prepared binary."
+ );
+ }
+ RemoteServerRestartReason::VersionMismatch => {
+ eprintln!(
+ "the remote server is still running a different herdr version. restart it so it uses the prepared binary."
+ );
+ }
+ }
+
+ let prompt = if reason == RemoteServerRestartReason::ProtocolMismatch {
+ "stop the remote server and continue attaching? [Y/n] "
+ } else {
+ "restart the remote server now? [Y/n] "
+ };
+ eprint!("{prompt}");
io::stderr().flush()?;
let mut answer = String::new();
io::stdin().read_line(&mut answer)?;
let answer = answer.trim().to_ascii_lowercase();
if answer == "n" || answer == "no" {
- return Err(io::Error::new(
- io::ErrorKind::Interrupted,
- "remote herdr server replacement cancelled",
- ));
+ if reason == RemoteServerRestartReason::ProtocolMismatch {
+ return Err(io::Error::new(
+ io::ErrorKind::Interrupted,
+ "remote herdr server stop cancelled",
+ ));
+ }
+ return Ok(false);
}
- Ok(())
+ Ok(true)
}
fn stop_remote_server(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<()> {
let command = format!("{} server stop", remote_herdr.shell_path);
let output = ssh_output(target, &command)?;
- if output.status.success() {
- Ok(())
- } else {
- Err(command_failed("remote server stop failed", &output))
+ if !output.status.success() {
+ return Err(command_failed("remote server stop failed", &output));
}
+
+ wait_for_remote_server_shutdown(target, remote_herdr)?;
+ eprintln!("stopped the remote herdr server on {target}; it will restart when the remote client bridge attaches.");
+ Ok(())
+}
+
+fn wait_for_remote_server_shutdown(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<()> {
+ let deadline = Instant::now() + REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT;
+ loop {
+ if remote_server_status(target, remote_herdr)? == RemoteServerStatus::NotRunning {
+ return Ok(());
+ }
+ if Instant::now() >= deadline {
+ return Err(io::Error::new(
+ io::ErrorKind::TimedOut,
+ format!(
+ "shutdown was requested, but the old remote herdr server on {target} is still responding after {} seconds",
+ REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT.as_secs()
+ ),
+ ));
+ }
+ thread::sleep(REMOTE_SERVER_SHUTDOWN_POLL_INTERVAL);
+ }
+}
+
+fn version_label(version: Option<&str>) -> &str {
+ version.unwrap_or("unknown")
}
fn protocol_label(protocol: Option<u32>) -> String {
@@ -1387,6 +1521,51 @@ command = "lazygit"
}
#[test]
+ fn parse_status_version_reads_version_line() {
+ assert_eq!(
+ parse_status_version("status: running\nversion: 0.6.0\nprotocol: 8"),
+ Some("0.6.0")
+ );
+ assert_eq!(parse_status_version("status: not running"), None);
+ }
+
+ #[test]
+ fn remote_server_restart_reason_requires_stop_for_protocol_mismatch() {
+ assert_eq!(
+ remote_server_restart_reason(Some(CURRENT_VERSION), Some(0), false),
+ Some(RemoteServerRestartReason::ProtocolMismatch)
+ );
+ }
+
+ #[test]
+ fn remote_server_restart_reason_offers_restart_after_binary_update() {
+ assert_eq!(
+ remote_server_restart_reason(Some(CURRENT_VERSION), Some(CURRENT_PROTOCOL), true),
+ Some(RemoteServerRestartReason::BinaryUpdated)
+ );
+ }
+
+ #[test]
+ fn remote_server_restart_reason_offers_restart_for_version_mismatch() {
+ assert_eq!(
+ remote_server_restart_reason(Some("0.0.0"), Some(CURRENT_PROTOCOL), false),
+ Some(RemoteServerRestartReason::VersionMismatch)
+ );
+ assert_eq!(
+ remote_server_restart_reason(None, Some(CURRENT_PROTOCOL), false),
+ Some(RemoteServerRestartReason::VersionMismatch)
+ );
+ }
+
+ #[test]
+ fn remote_server_restart_reason_allows_current_server() {
+ assert_eq!(
+ remote_server_restart_reason(Some(CURRENT_VERSION), Some(CURRENT_PROTOCOL), false),
+ None
+ );
+ }
+
+ #[test]
fn install_source_description_uses_override_binary() {
let platform = RemotePlatform {
os: "linux",