Add reconnection logic to remote server (#9289)
Migrated from /Users/kevinyang/Documents/GitHub/warp-internal via `script/migrate-private-to-public`. ## Commits - 823458b Add reconnect logic to remote server - 104aafb tech spec - c4039c3 clippy - c4e3a39 nit
This commit is contained in:
Generated
+1
@@ -10559,6 +10559,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
|
"async-io",
|
||||||
"async-process",
|
"async-process",
|
||||||
"command",
|
"command",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
|
|||||||
@@ -3,13 +3,18 @@
|
|||||||
//! [`SshTransport`] uses an existing SSH ControlMaster socket to check/install
|
//! [`SshTransport`] uses an existing SSH ControlMaster socket to check/install
|
||||||
//! the remote server binary and to launch the `remote-server-proxy` process
|
//! the remote server binary and to launch the `remote-server-proxy` process
|
||||||
//! whose stdin/stdout become the protocol channel.
|
//! whose stdin/stdout become the protocol channel.
|
||||||
|
use std::future::Future;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use warpui::r#async::executor;
|
use warpui::r#async::executor;
|
||||||
|
|
||||||
use remote_server::client::RemoteServerClient;
|
use remote_server::client::RemoteServerClient;
|
||||||
use remote_server::setup::RemotePlatform;
|
use remote_server::setup::{self, RemotePlatform, CHECK_TIMEOUT, INSTALL_TIMEOUT};
|
||||||
|
use remote_server::ssh::{run_ssh_command, run_ssh_script, ssh_args};
|
||||||
use remote_server::transport::{Connection, RemoteTransport};
|
use remote_server::transport::{Connection, RemoteTransport};
|
||||||
|
|
||||||
/// SSH transport: connects via a ControlMaster socket.
|
/// SSH transport: connects via a ControlMaster socket.
|
||||||
@@ -18,7 +23,7 @@ use remote_server::transport::{Connection, RemoteTransport};
|
|||||||
/// process (`ssh -N -o ControlMaster=yes -o ControlPath=<path>`). All SSH
|
/// process (`ssh -N -o ControlMaster=yes -o ControlPath=<path>`). All SSH
|
||||||
/// commands (binary check, install, proxy launch) are multiplexed through
|
/// commands (binary check, install, proxy launch) are multiplexed through
|
||||||
/// this socket without re-authenticating.
|
/// this socket without re-authenticating.
|
||||||
#[derive(Clone)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct SshTransport {
|
pub struct SshTransport {
|
||||||
socket_path: PathBuf,
|
socket_path: PathBuf,
|
||||||
}
|
}
|
||||||
@@ -28,123 +33,112 @@ impl SshTransport {
|
|||||||
Self { socket_path }
|
Self { socket_path }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RemoteTransport for SshTransport {
|
impl RemoteTransport for SshTransport {
|
||||||
async fn detect_platform(&self) -> Result<RemotePlatform, String> {
|
fn detect_platform(
|
||||||
match remote_server::ssh::run_ssh_command(
|
&self,
|
||||||
&self.socket_path,
|
) -> Pin<Box<dyn Future<Output = Result<RemotePlatform, String>> + Send>> {
|
||||||
"uname -sm",
|
let socket_path = self.socket_path.clone();
|
||||||
remote_server::setup::CHECK_TIMEOUT,
|
Box::pin(async move {
|
||||||
)
|
match run_ssh_command(&socket_path, "uname -sm", CHECK_TIMEOUT).await {
|
||||||
.await
|
Ok(output) if output.status.success() => {
|
||||||
{
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
Ok(output) if output.status.success() => {
|
setup::parse_uname_output(&stdout).map_err(|e| format!("{e:#}"))
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
||||||
remote_server::setup::parse_uname_output(&stdout).map_err(|e| format!("{e:#}"))
|
|
||||||
}
|
|
||||||
Ok(output) => {
|
|
||||||
let code = output.status.code().unwrap_or(-1);
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
Err(format!("uname -sm exited with code {code}: {stderr}"))
|
|
||||||
}
|
|
||||||
Err(e) => Err(format!("{e:#}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn check_binary(&self) -> Result<bool, String> {
|
|
||||||
let bin_path = remote_server::setup::remote_server_binary();
|
|
||||||
log::info!("Checking for remote server binary at {bin_path}");
|
|
||||||
match remote_server::ssh::run_ssh_command(
|
|
||||||
&self.socket_path,
|
|
||||||
&remote_server::setup::binary_check_command(),
|
|
||||||
remote_server::setup::CHECK_TIMEOUT,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
// `test -x` exits 0 when present, 1 when missing.
|
|
||||||
// Any other exit code (or None / signal) is treated as a check failure.
|
|
||||||
Ok(output) => match output.status.code() {
|
|
||||||
Some(0) => Ok(true),
|
|
||||||
Some(1) => Ok(false),
|
|
||||||
Some(code) => {
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
Err(format!("binary check exited with code {code}: {stderr}"))
|
|
||||||
}
|
}
|
||||||
None => Err("binary check terminated by signal".into()),
|
Ok(output) => {
|
||||||
},
|
let code = output.status.code().unwrap_or(-1);
|
||||||
Err(e) => Err(format!("{e:#}")),
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
}
|
Err(format!("uname -sm exited with code {code}: {stderr}"))
|
||||||
}
|
}
|
||||||
|
Err(e) => Err(format!("{e:#}")),
|
||||||
async fn install_binary(&self) -> Result<(), String> {
|
|
||||||
let script = remote_server::setup::install_script();
|
|
||||||
log::info!(
|
|
||||||
"Installing remote server binary to {}",
|
|
||||||
remote_server::setup::remote_server_binary()
|
|
||||||
);
|
|
||||||
match remote_server::ssh::run_ssh_script(
|
|
||||||
&self.socket_path,
|
|
||||||
&script,
|
|
||||||
remote_server::setup::INSTALL_TIMEOUT,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(output) if output.status.success() => Ok(()),
|
|
||||||
Ok(output) => {
|
|
||||||
let code = output.status.code().unwrap_or(-1);
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
Err(format!("install script failed (exit {code}): {stderr}"))
|
|
||||||
}
|
}
|
||||||
Err(e) => Err(format!("{e:#}")),
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn connect(&self, executor: &executor::Background) -> Result<Connection> {
|
fn check_binary(&self) -> Pin<Box<dyn Future<Output = Result<bool, String>> + Send>> {
|
||||||
let binary = remote_server::setup::remote_server_binary();
|
let socket_path = self.socket_path.clone();
|
||||||
let mut args = remote_server::ssh::ssh_args(&self.socket_path);
|
Box::pin(async move {
|
||||||
args.push(format!("{binary} remote-server-proxy"));
|
let bin_path = setup::remote_server_binary();
|
||||||
|
log::info!("Checking for remote server binary at {bin_path}");
|
||||||
|
match run_ssh_command(&socket_path, &setup::binary_check_command(), CHECK_TIMEOUT).await
|
||||||
|
{
|
||||||
|
Ok(output) => match output.status.code() {
|
||||||
|
Some(0) => Ok(true),
|
||||||
|
Some(1) => Ok(false),
|
||||||
|
Some(code) => {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
Err(format!("binary check exited with code {code}: {stderr}"))
|
||||||
|
}
|
||||||
|
None => Err("binary check terminated by signal".into()),
|
||||||
|
},
|
||||||
|
Err(e) => Err(format!("{e:#}")),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// `kill_on_drop(true)` pairs with ownership of the `Child` being
|
fn install_binary(&self) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send>> {
|
||||||
// returned in the [`Connection`] below: the
|
let socket_path = self.socket_path.clone();
|
||||||
// [`RemoteServerManager`] holds the `Child` on its per-session
|
Box::pin(async move {
|
||||||
// state, and dropping that state (on explicit teardown or
|
let script = setup::install_script();
|
||||||
// spontaneous disconnect) sends SIGKILL to this ssh process.
|
log::info!(
|
||||||
// Without this the ssh child is orphaned and keeps a channel
|
"Installing remote server binary to {}",
|
||||||
// open on the ControlMaster socket, blocking the master from
|
setup::remote_server_binary()
|
||||||
// exiting cleanly when the user logs out.
|
);
|
||||||
//
|
match run_ssh_script(&socket_path, &script, INSTALL_TIMEOUT).await {
|
||||||
// Note that the child's lifetime is decoupled from any
|
Ok(output) if output.status.success() => Ok(()),
|
||||||
// `Arc<RemoteServerClient>` clones: other owners (e.g. the
|
Ok(output) => {
|
||||||
// per-session command executor) can keep the client alive for
|
let code = output.status.code().unwrap_or(-1);
|
||||||
// their own purposes without pinning the subprocess.
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
let mut child = command::r#async::Command::new("ssh")
|
Err(format!("install script failed (exit {code}): {stderr}"))
|
||||||
.args(&args)
|
}
|
||||||
.stdin(std::process::Stdio::piped())
|
Err(e) => Err(format!("{e:#}")),
|
||||||
.stdout(std::process::Stdio::piped())
|
}
|
||||||
.stderr(std::process::Stdio::piped())
|
})
|
||||||
.kill_on_drop(true)
|
}
|
||||||
.spawn()?;
|
|
||||||
|
|
||||||
let stdin = child
|
fn connect(
|
||||||
.stdin
|
&self,
|
||||||
.take()
|
executor: Arc<executor::Background>,
|
||||||
.ok_or_else(|| anyhow::anyhow!("Failed to capture child stdin"))?;
|
) -> Pin<Box<dyn Future<Output = Result<Connection>> + Send>> {
|
||||||
let stdout = child
|
let socket_path = self.socket_path.clone();
|
||||||
.stdout
|
Box::pin(async move {
|
||||||
.take()
|
let binary = setup::remote_server_binary();
|
||||||
.ok_or_else(|| anyhow::anyhow!("Failed to capture child stdout"))?;
|
let mut args = ssh_args(&socket_path);
|
||||||
let stderr = child
|
args.push(format!("{binary} remote-server-proxy"));
|
||||||
.stderr
|
|
||||||
.take()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Failed to capture child stderr"))?;
|
|
||||||
|
|
||||||
let (client, event_rx) =
|
// `kill_on_drop(true)` pairs with ownership of the `Child` being
|
||||||
RemoteServerClient::from_child_streams(stdin, stdout, stderr, executor);
|
// returned in the [`Connection`] below: the
|
||||||
Ok(Connection {
|
// [`RemoteServerManager`] holds the `Child` on its per-session
|
||||||
client,
|
// state, and dropping that state (on explicit teardown or
|
||||||
event_rx,
|
// spontaneous disconnect) sends SIGKILL to this ssh process.
|
||||||
child,
|
let mut child = command::r#async::Command::new("ssh")
|
||||||
control_path: Some(self.socket_path.clone()),
|
.args(&args)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.spawn()?;
|
||||||
|
|
||||||
|
let stdin = child
|
||||||
|
.stdin
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Failed to capture child stdin"))?;
|
||||||
|
let stdout = child
|
||||||
|
.stdout
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Failed to capture child stdout"))?;
|
||||||
|
let stderr = child
|
||||||
|
.stderr
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Failed to capture child stderr"))?;
|
||||||
|
|
||||||
|
let (client, event_rx) =
|
||||||
|
RemoteServerClient::from_child_streams(stdin, stdout, stderr, &executor);
|
||||||
|
Ok(Connection {
|
||||||
|
client,
|
||||||
|
event_rx,
|
||||||
|
child,
|
||||||
|
control_path: Some(socket_path),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ use crate::server::telemetry::{BootstrappingInfo, TelemetryEvent};
|
|||||||
use crate::terminal::event::ExecutedExecutorCommandEvent;
|
use crate::terminal::event::ExecutedExecutorCommandEvent;
|
||||||
use crate::terminal::ShellHost;
|
use crate::terminal::ShellHost;
|
||||||
use crate::terminal::ShellLaunchData;
|
use crate::terminal::ShellLaunchData;
|
||||||
|
#[cfg(feature = "local_tty")]
|
||||||
|
use command_executor::remote_server_executor::RemoteServerCommandExecutor;
|
||||||
|
use parking_lot::{Mutex, RwLock};
|
||||||
|
|
||||||
use crate::terminal::shell::{Shell, ShellType};
|
use crate::terminal::shell::{Shell, ShellType};
|
||||||
use crate::terminal::warpify::SubshellSource;
|
use crate::terminal::warpify::SubshellSource;
|
||||||
@@ -172,6 +175,18 @@ impl Sessions {
|
|||||||
| RemoteServerManagerEvent::BinaryInstallComplete { .. }
|
| RemoteServerManagerEvent::BinaryInstallComplete { .. }
|
||||||
| RemoteServerManagerEvent::ClientRequestFailed { .. }
|
| RemoteServerManagerEvent::ClientRequestFailed { .. }
|
||||||
| RemoteServerManagerEvent::ServerMessageDecodingError { .. } => {}
|
| RemoteServerManagerEvent::ServerMessageDecodingError { .. } => {}
|
||||||
|
RemoteServerManagerEvent::SessionReconnected {
|
||||||
|
session_id: sid,
|
||||||
|
client,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
if let Some(session) = sessions.sessions.get(sid) {
|
||||||
|
let new_executor =
|
||||||
|
Arc::new(RemoteServerCommandExecutor::new(*sid, client.clone()));
|
||||||
|
session.set_command_executor(new_executor);
|
||||||
|
log::info!("Swapped command executor for session {sid:?} after reconnect");
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "local_tty"))]
|
#[cfg(not(feature = "local_tty"))]
|
||||||
@@ -847,14 +862,16 @@ impl From<BootstrapSessionType> for SessionType {
|
|||||||
pub struct Session {
|
pub struct Session {
|
||||||
info: SessionInfo,
|
info: SessionInfo,
|
||||||
external_commands: Arc<OnceCell<HashSet<SmolStr>>>,
|
external_commands: Arc<OnceCell<HashSet<SmolStr>>>,
|
||||||
command_executor: Arc<dyn CommandExecutor>,
|
/// The command executor for this session. Behind a `RwLock` so it can be
|
||||||
|
/// swapped after a remote server reconnect (via `set_command_executor`).
|
||||||
|
command_executor: RwLock<Arc<dyn CommandExecutor>>,
|
||||||
load_external_commands_future: OnceCell<Shared<BoxFuture<'static, ()>>>,
|
load_external_commands_future: OnceCell<Shared<BoxFuture<'static, ()>>>,
|
||||||
command_case_sensitivity: TopLevelCommandCaseSensitivity,
|
command_case_sensitivity: TopLevelCommandCaseSensitivity,
|
||||||
/// The authoritative session type, initially derived from the
|
/// The authoritative session type, initially derived from the
|
||||||
/// [`BootstrapSessionType`] in `SessionInfo` and updated by [`Sessions`]
|
/// [`BootstrapSessionType`] in `SessionInfo` and updated by [`Sessions`]
|
||||||
/// when `RemoteServerManager` reports a connected session (to fill in the
|
/// when `RemoteServerManager` reports a connected session (to fill in the
|
||||||
/// `host_id`). Interior mutability allows updating through `Arc<Session>`.
|
/// `host_id`). Interior mutability allows updating through `Arc<Session>`.
|
||||||
session_type: parking_lot::Mutex<SessionType>,
|
session_type: Mutex<SessionType>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Session {
|
impl Session {
|
||||||
@@ -873,10 +890,10 @@ impl Session {
|
|||||||
Self {
|
Self {
|
||||||
info: session_info,
|
info: session_info,
|
||||||
external_commands: Arc::new(OnceCell::new()),
|
external_commands: Arc::new(OnceCell::new()),
|
||||||
command_executor,
|
command_executor: RwLock::new(command_executor),
|
||||||
load_external_commands_future: Default::default(),
|
load_external_commands_future: Default::default(),
|
||||||
command_case_sensitivity,
|
command_case_sensitivity,
|
||||||
session_type: parking_lot::Mutex::new(session_type),
|
session_type: Mutex::new(session_type),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1025,9 +1042,17 @@ impl Session {
|
|||||||
&self.info.subshell_info
|
&self.info.subshell_info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replaces the command executor for this session. Used after a remote
|
||||||
|
/// server reconnect to swap in a new `RemoteServerCommandExecutor`
|
||||||
|
/// backed by the reconnected client.
|
||||||
|
pub fn set_command_executor(&self, executor: Arc<dyn CommandExecutor>) {
|
||||||
|
*self.command_executor.write() = executor;
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns true if the session is employing in-band command execution to run generators.
|
/// Returns true if the session is employing in-band command execution to run generators.
|
||||||
pub fn is_using_in_band_command_execution(&self) -> bool {
|
pub fn is_using_in_band_command_execution(&self) -> bool {
|
||||||
self.command_executor
|
self.command_executor
|
||||||
|
.read()
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.as_any()
|
.as_any()
|
||||||
.downcast_ref::<InBandCommandExecutor>()
|
.downcast_ref::<InBandCommandExecutor>()
|
||||||
@@ -1089,8 +1114,8 @@ impl Session {
|
|||||||
.path
|
.path
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|path| HashMap::from_iter([("PATH".to_string(), path.to_string())]));
|
.map(|path| HashMap::from_iter([("PATH".to_string(), path.to_string())]));
|
||||||
let windows_results = self
|
let executor = self.command_executor.read().clone();
|
||||||
.command_executor
|
let windows_results = executor
|
||||||
.execute_command(
|
.execute_command(
|
||||||
ShellType::PowerShell.shell_command_to_get_executables(),
|
ShellType::PowerShell.shell_command_to_get_executables(),
|
||||||
&Shell::new(ShellType::PowerShell, None, None, Default::default(), None),
|
&Shell::new(ShellType::PowerShell, None, None, Default::default(), None),
|
||||||
@@ -1381,7 +1406,10 @@ impl Session {
|
|||||||
environment_variables: Option<HashMap<String, String>>,
|
environment_variables: Option<HashMap<String, String>>,
|
||||||
execute_command_options: ExecuteCommandOptions,
|
execute_command_options: ExecuteCommandOptions,
|
||||||
) -> Result<CommandOutput> {
|
) -> Result<CommandOutput> {
|
||||||
self.command_executor
|
// Clone the Arc out of the lock so we don't hold the read guard
|
||||||
|
// across the await point.
|
||||||
|
let executor = self.command_executor.read().clone();
|
||||||
|
executor
|
||||||
.execute_command(
|
.execute_command(
|
||||||
command,
|
command,
|
||||||
&self.info.shell,
|
&self.info.shell,
|
||||||
@@ -1394,11 +1422,13 @@ impl Session {
|
|||||||
|
|
||||||
/// Whether the backing executor for the session supports execution of commands in parallel.
|
/// Whether the backing executor for the session supports execution of commands in parallel.
|
||||||
pub fn supports_parallel_command_execution(&self) -> bool {
|
pub fn supports_parallel_command_execution(&self) -> bool {
|
||||||
self.command_executor.supports_parallel_command_execution()
|
self.command_executor
|
||||||
|
.read()
|
||||||
|
.supports_parallel_command_execution()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cancel_active_commands(&self) {
|
pub fn cancel_active_commands(&self) {
|
||||||
self.command_executor.cancel_active_commands();
|
self.command_executor.read().cancel_active_commands();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn git_branches_for_command_corrections(&self, working_dir: &str) -> Vec<String> {
|
pub async fn git_branches_for_command_corrections(&self, working_dir: &str) -> Vec<String> {
|
||||||
@@ -1644,10 +1674,10 @@ pub mod testing {
|
|||||||
Self {
|
Self {
|
||||||
info,
|
info,
|
||||||
external_commands: Default::default(),
|
external_commands: Default::default(),
|
||||||
command_executor: Arc::new(TestCommandExecutor::default()),
|
command_executor: RwLock::new(Arc::new(TestCommandExecutor::default())),
|
||||||
load_external_commands_future: Default::default(),
|
load_external_commands_future: Default::default(),
|
||||||
command_case_sensitivity: TopLevelCommandCaseSensitivity::CaseSensitive,
|
command_case_sensitivity: TopLevelCommandCaseSensitivity::CaseSensitive,
|
||||||
session_type: parking_lot::Mutex::new(session_type),
|
session_type: Mutex::new(session_type),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1659,10 +1689,10 @@ pub mod testing {
|
|||||||
Self {
|
Self {
|
||||||
info,
|
info,
|
||||||
external_commands: Default::default(),
|
external_commands: Default::default(),
|
||||||
command_executor: Arc::new(TestCommandExecutor::default()),
|
command_executor: RwLock::new(Arc::new(TestCommandExecutor::default())),
|
||||||
load_external_commands_future: Default::default(),
|
load_external_commands_future: Default::default(),
|
||||||
command_case_sensitivity: TopLevelCommandCaseSensitivity::CaseSensitive,
|
command_case_sensitivity: TopLevelCommandCaseSensitivity::CaseSensitive,
|
||||||
session_type: parking_lot::Mutex::new(session_type),
|
session_type: Mutex::new(session_type),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4376,6 +4376,7 @@ impl TerminalView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
RemoteServerManagerEvent::SessionConnecting { .. }
|
RemoteServerManagerEvent::SessionConnecting { .. }
|
||||||
|
| RemoteServerManagerEvent::SessionReconnected { .. }
|
||||||
| RemoteServerManagerEvent::HostConnected { .. }
|
| RemoteServerManagerEvent::HostConnected { .. }
|
||||||
| RemoteServerManagerEvent::HostDisconnected { .. }
|
| RemoteServerManagerEvent::HostDisconnected { .. }
|
||||||
| RemoteServerManagerEvent::RepoMetadataSnapshot { .. }
|
| RemoteServerManagerEvent::RepoMetadataSnapshot { .. }
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ warp_util.workspace = true
|
|||||||
warpui.workspace = true
|
warpui.workspace = true
|
||||||
|
|
||||||
[target.'cfg(not(target_family = "wasm"))'.dependencies]
|
[target.'cfg(not(target_family = "wasm"))'.dependencies]
|
||||||
|
async-io.workspace = true
|
||||||
async-process.workspace = true
|
async-process.workspace = true
|
||||||
|
|
||||||
[target.'cfg(target_family = "wasm")'.dependencies]
|
[target.'cfg(target_family = "wasm")'.dependencies]
|
||||||
|
|||||||
+454
-136
@@ -2,6 +2,8 @@ use std::collections::{HashMap, HashSet};
|
|||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
use crate::client::ClientEvent;
|
use crate::client::ClientEvent;
|
||||||
@@ -17,6 +19,47 @@ use serde::Serialize;
|
|||||||
use warp_core::SessionId;
|
use warp_core::SessionId;
|
||||||
use warpui::{Entity, ModelContext, ModelSpawner, SingletonEntity};
|
use warpui::{Entity, ModelContext, ModelSpawner, SingletonEntity};
|
||||||
|
|
||||||
|
/// Maximum number of reconnection attempts after a spontaneous disconnect.
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
const MAX_RECONNECT_ATTEMPTS: u32 = 2;
|
||||||
|
/// Delay between reconnection attempts.
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
const RECONNECT_DELAY: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// Parameters that travel together through the reconnection flow.
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
struct ReconnectParams {
|
||||||
|
attempt: u32,
|
||||||
|
host_id: HostId,
|
||||||
|
exit_status: Option<RemoteServerExitStatus>,
|
||||||
|
transport: Arc<dyn RemoteTransport>,
|
||||||
|
control_path: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error from [`RemoteServerManager::run_connect_and_handshake`] that
|
||||||
|
/// preserves which phase failed so callers can report accurate telemetry.
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
enum ConnectAndHandshakeError {
|
||||||
|
/// `transport.connect()` failed, or the session was deregistered
|
||||||
|
/// before the connect phase could complete.
|
||||||
|
#[error("connect: {0:#}")]
|
||||||
|
Connect(anyhow::Error),
|
||||||
|
/// `client.initialize()` handshake failed.
|
||||||
|
#[error("initialize: {0:#}")]
|
||||||
|
Initialize(anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
impl ConnectAndHandshakeError {
|
||||||
|
fn phase(&self) -> RemoteServerInitPhase {
|
||||||
|
match self {
|
||||||
|
Self::Connect(_) => RemoteServerInitPhase::Connect,
|
||||||
|
Self::Initialize(_) => RemoteServerInitPhase::Initialize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Which phase of the remote server connection flow failed.
|
/// Which phase of the remote server connection flow failed.
|
||||||
#[derive(Clone, Copy, Debug, Serialize)]
|
#[derive(Clone, Copy, Debug, Serialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
@@ -45,6 +88,16 @@ pub enum RemoteServerErrorKind {
|
|||||||
Other,
|
Other,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Exit status information captured from the remote server subprocess
|
||||||
|
/// when the connection drops. Used for diagnostics and telemetry.
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct RemoteServerExitStatus {
|
||||||
|
/// Process exit code, if the process exited normally.
|
||||||
|
pub code: Option<i32>,
|
||||||
|
/// True if the process was killed by a signal (Unix only).
|
||||||
|
pub signal_killed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
impl RemoteServerErrorKind {
|
impl RemoteServerErrorKind {
|
||||||
/// Classify a [`ClientError`] into a telemetry error kind.
|
/// Classify a [`ClientError`] into a telemetry error kind.
|
||||||
pub fn from_client_error(error: &crate::client::ClientError) -> Self {
|
pub fn from_client_error(error: &crate::client::ClientError) -> Self {
|
||||||
@@ -104,6 +157,16 @@ pub enum RemoteSessionState {
|
|||||||
/// See type-level doc.
|
/// See type-level doc.
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
control_path: Option<PathBuf>,
|
control_path: Option<PathBuf>,
|
||||||
|
/// Transport stored for reconnection after spontaneous disconnect.
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
transport: Arc<dyn RemoteTransport>,
|
||||||
|
},
|
||||||
|
/// A reconnection attempt is in progress after a spontaneous disconnect.
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
Reconnecting {
|
||||||
|
attempt: u32,
|
||||||
|
host_id: HostId,
|
||||||
|
control_path: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
/// Connection dropped (EOF/error from the reader task).
|
/// Connection dropped (EOF/error from the reader task).
|
||||||
Disconnected,
|
Disconnected,
|
||||||
@@ -145,6 +208,19 @@ pub enum RemoteServerManagerEvent {
|
|||||||
SessionDisconnected {
|
SessionDisconnected {
|
||||||
session_id: SessionId,
|
session_id: SessionId,
|
||||||
host_id: HostId,
|
host_id: HostId,
|
||||||
|
/// Exit status of the remote server subprocess, if available.
|
||||||
|
/// `None` when the session was explicitly deregistered or when
|
||||||
|
/// the exit status could not be determined.
|
||||||
|
exit_status: Option<RemoteServerExitStatus>,
|
||||||
|
},
|
||||||
|
/// A reconnection attempt succeeded. Downstream owners (e.g.
|
||||||
|
/// `RemoteServerCommandExecutor`) should swap their client reference
|
||||||
|
/// to the new one carried in `client`.
|
||||||
|
SessionReconnected {
|
||||||
|
session_id: SessionId,
|
||||||
|
host_id: HostId,
|
||||||
|
attempt: u32,
|
||||||
|
client: Arc<RemoteServerClient>,
|
||||||
},
|
},
|
||||||
/// The manager is no longer tracking this session -- it has been
|
/// The manager is no longer tracking this session -- it has been
|
||||||
/// removed from the `sessions` map via `deregister_session`. Fires
|
/// removed from the `sessions` map via `deregister_session`. Fires
|
||||||
@@ -224,11 +300,13 @@ pub enum RemoteServerManagerEvent {
|
|||||||
ServerMessageDecodingError { session_id: SessionId },
|
ServerMessageDecodingError { session_id: SessionId },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shell info stashed by [`RemoteServerManager::notify_session_bootstrapped`]
|
/// Shell info recorded by [`RemoteServerManager::notify_session_bootstrapped`].
|
||||||
/// when the session is not yet in `Connected` state. Flushed automatically
|
///
|
||||||
/// when [`RemoteServerManager::mark_session_connected`] fires.
|
/// Persists for the lifetime of the session (removed only in
|
||||||
|
/// `deregister_session`) so that `mark_session_connected` can re-send
|
||||||
|
/// the notification after a reconnect.
|
||||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||||
struct PendingSessionBootstrappedNotification {
|
struct SessionBootstrapInfo {
|
||||||
shell_type: String,
|
shell_type: String,
|
||||||
shell_path: Option<String>,
|
shell_path: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -253,9 +331,10 @@ pub struct RemoteServerManager {
|
|||||||
/// `navigate_to_directory` calls when `update_active_session` fires
|
/// `navigate_to_directory` calls when `update_active_session` fires
|
||||||
/// repeatedly for the same CWD.
|
/// repeatedly for the same CWD.
|
||||||
last_navigated_path: HashMap<SessionId, String>,
|
last_navigated_path: HashMap<SessionId, String>,
|
||||||
/// Per-session `SessionBootstrapped` notifications that arrived before the
|
/// Per-session shell info recorded at bootstrap time and re-sent to the
|
||||||
/// session reached `Connected`. Flushed in `mark_session_connected`.
|
/// remote server daemon on every (re)connect. Persists until
|
||||||
pending_bootstrapped_notifications: HashMap<SessionId, PendingSessionBootstrappedNotification>,
|
/// `deregister_session`.
|
||||||
|
session_bootstrap_info: HashMap<SessionId, SessionBootstrapInfo>,
|
||||||
/// Detected remote platform per session, populated during the binary check
|
/// Detected remote platform per session, populated during the binary check
|
||||||
/// phase via `detect_platform()`. Used for telemetry.
|
/// phase via `detect_platform()`. Used for telemetry.
|
||||||
session_platforms: HashMap<SessionId, RemotePlatform>,
|
session_platforms: HashMap<SessionId, RemotePlatform>,
|
||||||
@@ -274,7 +353,7 @@ impl RemoteServerManager {
|
|||||||
host_to_sessions: HashMap::new(),
|
host_to_sessions: HashMap::new(),
|
||||||
spawner: ctx.spawner(),
|
spawner: ctx.spawner(),
|
||||||
last_navigated_path: HashMap::new(),
|
last_navigated_path: HashMap::new(),
|
||||||
pending_bootstrapped_notifications: HashMap::new(),
|
session_bootstrap_info: HashMap::new(),
|
||||||
session_platforms: HashMap::new(),
|
session_platforms: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -417,10 +496,7 @@ impl RemoteServerManager {
|
|||||||
{
|
{
|
||||||
log::info!("Starting remote server connection for session {session_id:?}");
|
log::info!("Starting remote server connection for session {session_id:?}");
|
||||||
|
|
||||||
// Advance the user-visible setup pipeline. Both callers (binary
|
// Advance the user-visible setup pipeline.
|
||||||
// already installed, and binary just installed) enter this
|
|
||||||
// method right when the Initializing phase begins, so we emit
|
|
||||||
// the state change from one place.
|
|
||||||
ctx.emit(RemoteServerManagerEvent::SetupStateChanged {
|
ctx.emit(RemoteServerManagerEvent::SetupStateChanged {
|
||||||
session_id,
|
session_id,
|
||||||
state: RemoteServerSetupState::Initializing,
|
state: RemoteServerSetupState::Initializing,
|
||||||
@@ -432,99 +508,31 @@ impl RemoteServerManager {
|
|||||||
|
|
||||||
let spawner = self.spawner.clone();
|
let spawner = self.spawner.clone();
|
||||||
let executor = ctx.background_executor().clone();
|
let executor = ctx.background_executor().clone();
|
||||||
|
// Wrap the transport in an Arc so it can be stored on `Connected`
|
||||||
|
// for reconnection after a spontaneous disconnect.
|
||||||
|
let transport: Arc<dyn RemoteTransport> = Arc::new(transport);
|
||||||
|
|
||||||
ctx.background_executor()
|
ctx.background_executor()
|
||||||
.spawn(async move {
|
.spawn(async move {
|
||||||
// ---- Phase 1: Connect (establish streams, create client) ----
|
match Self::run_connect_and_handshake(
|
||||||
match transport.connect(&executor).await {
|
session_id,
|
||||||
Ok(Connection {
|
&*transport,
|
||||||
client,
|
&spawner,
|
||||||
event_rx,
|
&executor,
|
||||||
child,
|
)
|
||||||
control_path,
|
.await
|
||||||
}) => {
|
{
|
||||||
let client = Arc::new(client);
|
Ok(host_id) => {
|
||||||
|
|
||||||
// Transition to Initializing and start draining
|
|
||||||
// the event channel for push events and disconnect.
|
|
||||||
// The `Child` is stashed on the session state so
|
|
||||||
// its lifetime is controlled by the manager -- on
|
|
||||||
// teardown the state is dropped, which runs the
|
|
||||||
// `Child`'s destructor and SIGKILLs the subprocess
|
|
||||||
// via `kill_on_drop`. `control_path` is stashed
|
|
||||||
// for explicit teardown's `ssh -O exit` call.
|
|
||||||
let client_for_state = Arc::clone(&client);
|
|
||||||
let _ = spawner
|
let _ = spawner
|
||||||
.spawn(move |me, ctx| {
|
.spawn(move |me, ctx| {
|
||||||
me.sessions.insert(
|
me.mark_session_connected(session_id, host_id, transport, ctx);
|
||||||
session_id,
|
|
||||||
RemoteSessionState::Initializing {
|
|
||||||
client: client_for_state,
|
|
||||||
_child: child,
|
|
||||||
control_path,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Drain the event channel on the main thread.
|
|
||||||
// Each push event is forwarded as a manager
|
|
||||||
// event in real-time. When the stream closes
|
|
||||||
// (after Disconnected or channel drop), we
|
|
||||||
// transition the session to Disconnected.
|
|
||||||
ctx.spawn_stream_local(
|
|
||||||
event_rx,
|
|
||||||
move |me, event, ctx| {
|
|
||||||
me.forward_client_event(session_id, event, ctx);
|
|
||||||
},
|
|
||||||
move |me, ctx| {
|
|
||||||
me.mark_session_disconnected(session_id, ctx);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// ---- Phase 2: Initialize handshake ----
|
|
||||||
match client.initialize().await {
|
|
||||||
Ok(resp) => {
|
|
||||||
let host_id = HostId::new(resp.host_id);
|
|
||||||
let _ = spawner
|
|
||||||
.spawn(move |me, ctx| {
|
|
||||||
me.mark_session_connected(session_id, host_id, ctx);
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!(
|
|
||||||
"Initialize handshake failed for session {session_id:?}: {e}"
|
|
||||||
);
|
|
||||||
let error = format!("{e:#}");
|
|
||||||
let _ = spawner
|
|
||||||
.spawn(move |me, ctx| {
|
|
||||||
ctx.emit(
|
|
||||||
RemoteServerManagerEvent::SetupStateChanged {
|
|
||||||
session_id,
|
|
||||||
state: RemoteServerSetupState::Failed {
|
|
||||||
error: error.clone(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
ctx.emit(
|
|
||||||
RemoteServerManagerEvent::SessionConnectionFailed {
|
|
||||||
session_id,
|
|
||||||
phase: RemoteServerInitPhase::Initialize,
|
|
||||||
error,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
me.mark_session_disconnected(session_id, ctx);
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!(
|
log::error!("Connection failed for session {session_id:?}: {e}");
|
||||||
"Failed to connect remote server for session {session_id:?}: {e:#}"
|
let phase = e.phase();
|
||||||
);
|
let error = format!("{e}");
|
||||||
let error = format!("{e:#}");
|
|
||||||
let _ = spawner
|
let _ = spawner
|
||||||
.spawn(move |me, ctx| {
|
.spawn(move |me, ctx| {
|
||||||
ctx.emit(RemoteServerManagerEvent::SetupStateChanged {
|
ctx.emit(RemoteServerManagerEvent::SetupStateChanged {
|
||||||
@@ -535,7 +543,7 @@ impl RemoteServerManager {
|
|||||||
});
|
});
|
||||||
ctx.emit(RemoteServerManagerEvent::SessionConnectionFailed {
|
ctx.emit(RemoteServerManagerEvent::SessionConnectionFailed {
|
||||||
session_id,
|
session_id,
|
||||||
phase: RemoteServerInitPhase::Connect,
|
phase,
|
||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
me.mark_session_disconnected(session_id, ctx);
|
me.mark_session_disconnected(session_id, ctx);
|
||||||
@@ -548,6 +556,81 @@ impl RemoteServerManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shared connect + handshake logic used by both `connect_session` and
|
||||||
|
/// `attempt_reconnect`.
|
||||||
|
///
|
||||||
|
/// 1. Calls `transport.connect()` to establish streams.
|
||||||
|
/// 2. Transitions the session to `Initializing` and starts draining the
|
||||||
|
/// event channel.
|
||||||
|
/// 3. Runs the initialize handshake.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(host_id)` on success, or a phase-tagged error.
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
async fn run_connect_and_handshake(
|
||||||
|
session_id: SessionId,
|
||||||
|
transport: &dyn RemoteTransport,
|
||||||
|
spawner: &ModelSpawner<Self>,
|
||||||
|
executor: &Arc<warpui::r#async::executor::Background>,
|
||||||
|
) -> Result<HostId, ConnectAndHandshakeError> {
|
||||||
|
// Phase 1: Connect (establish streams, create client).
|
||||||
|
let Connection {
|
||||||
|
client,
|
||||||
|
event_rx,
|
||||||
|
child,
|
||||||
|
control_path,
|
||||||
|
} = transport
|
||||||
|
.connect(executor.clone())
|
||||||
|
.await
|
||||||
|
.map_err(ConnectAndHandshakeError::Connect)?;
|
||||||
|
|
||||||
|
let client = Arc::new(client);
|
||||||
|
let client_for_init = Arc::clone(&client);
|
||||||
|
|
||||||
|
// Transition to Initializing and start draining the event channel.
|
||||||
|
// Guard: if the session was deregistered during `transport.connect()`,
|
||||||
|
// the entry will have been removed; don't re-insert it.
|
||||||
|
let was_inserted = spawner
|
||||||
|
.spawn(move |me, ctx| {
|
||||||
|
if !me.sessions.contains_key(&session_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
me.sessions.insert(
|
||||||
|
session_id,
|
||||||
|
RemoteSessionState::Initializing {
|
||||||
|
client: client_for_init,
|
||||||
|
_child: child,
|
||||||
|
control_path,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.spawn_stream_local(
|
||||||
|
event_rx,
|
||||||
|
move |me, event, ctx| {
|
||||||
|
me.forward_client_event(session_id, event, ctx);
|
||||||
|
},
|
||||||
|
move |me, ctx| {
|
||||||
|
me.mark_session_disconnected(session_id, ctx);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
true
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !was_inserted {
|
||||||
|
return Err(ConnectAndHandshakeError::Connect(anyhow::anyhow!(
|
||||||
|
"Session {session_id:?} was deregistered during connect"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Initialize handshake.
|
||||||
|
let resp = client
|
||||||
|
.initialize()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConnectAndHandshakeError::Initialize(anyhow::anyhow!("{e:#}")))?;
|
||||||
|
Ok(HostId::new(resp.host_id))
|
||||||
|
}
|
||||||
|
|
||||||
/// Removes a session from the manager and tears down its connection.
|
/// Removes a session from the manager and tears down its connection.
|
||||||
///
|
///
|
||||||
/// Assumes the caller has already observed that the user's shell
|
/// Assumes the caller has already observed that the user's shell
|
||||||
@@ -587,7 +670,7 @@ impl RemoteServerManager {
|
|||||||
/// spontaneous drops -- only for explicit teardown.
|
/// spontaneous drops -- only for explicit teardown.
|
||||||
pub fn deregister_session(&mut self, session_id: SessionId, ctx: &mut ModelContext<Self>) {
|
pub fn deregister_session(&mut self, session_id: SessionId, ctx: &mut ModelContext<Self>) {
|
||||||
self.last_navigated_path.remove(&session_id);
|
self.last_navigated_path.remove(&session_id);
|
||||||
self.pending_bootstrapped_notifications.remove(&session_id);
|
self.session_bootstrap_info.remove(&session_id);
|
||||||
self.session_platforms.remove(&session_id);
|
self.session_platforms.remove(&session_id);
|
||||||
|
|
||||||
// Remove the session entry. Dropping the `RemoteSessionState`
|
// Remove the session entry. Dropping the `RemoteSessionState`
|
||||||
@@ -604,19 +687,26 @@ impl RemoteServerManager {
|
|||||||
let control_path = match &prev {
|
let control_path = match &prev {
|
||||||
Some(RemoteSessionState::Connected { control_path, .. })
|
Some(RemoteSessionState::Connected { control_path, .. })
|
||||||
| Some(RemoteSessionState::Initializing { control_path, .. }) => control_path.clone(),
|
| Some(RemoteSessionState::Initializing { control_path, .. }) => control_path.clone(),
|
||||||
|
Some(RemoteSessionState::Reconnecting { control_path, .. }) => control_path.clone(),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(RemoteSessionState::Connected { host_id, .. }) = prev {
|
// Extract `host_id` from states that track a host connection.
|
||||||
|
let host_id = match &prev {
|
||||||
|
Some(RemoteSessionState::Connected { host_id, .. }) => Some(host_id.clone()),
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
Some(RemoteSessionState::Reconnecting { host_id, .. }) => Some(host_id.clone()),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(host_id) = host_id {
|
||||||
self.remove_from_host_index(&host_id, session_id);
|
self.remove_from_host_index(&host_id, session_id);
|
||||||
ctx.emit(RemoteServerManagerEvent::SessionDisconnected {
|
ctx.emit(RemoteServerManagerEvent::SessionDisconnected {
|
||||||
session_id,
|
session_id,
|
||||||
host_id: host_id.clone(),
|
host_id: host_id.clone(),
|
||||||
|
exit_status: None,
|
||||||
});
|
});
|
||||||
if !self.host_to_sessions.contains_key(&host_id) {
|
if !self.host_to_sessions.contains_key(&host_id) {
|
||||||
ctx.emit(RemoteServerManagerEvent::HostDisconnected {
|
ctx.emit(RemoteServerManagerEvent::HostDisconnected { host_id });
|
||||||
host_id: host_id.clone(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx.emit(RemoteServerManagerEvent::SessionDeregistered { session_id });
|
ctx.emit(RemoteServerManagerEvent::SessionDeregistered { session_id });
|
||||||
@@ -742,19 +832,21 @@ impl RemoteServerManager {
|
|||||||
shell_type: &str,
|
shell_type: &str,
|
||||||
shell_path: Option<&str>,
|
shell_path: Option<&str>,
|
||||||
) {
|
) {
|
||||||
|
// Always persist so we can re-send after a reconnect.
|
||||||
|
self.session_bootstrap_info.insert(
|
||||||
|
session_id,
|
||||||
|
SessionBootstrapInfo {
|
||||||
|
shell_type: shell_type.to_owned(),
|
||||||
|
shell_path: shell_path.map(ToOwned::to_owned),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if let Some(client) = self.client_for_session(session_id) {
|
if let Some(client) = self.client_for_session(session_id) {
|
||||||
client.notify_session_bootstrapped(session_id, shell_type, shell_path);
|
client.notify_session_bootstrapped(session_id, shell_type, shell_path);
|
||||||
} else {
|
} else {
|
||||||
log::info!(
|
log::info!(
|
||||||
"notify_session_bootstrapped: session {session_id:?} not yet connected, \
|
"notify_session_bootstrapped: session {session_id:?} not yet connected, \
|
||||||
stashing notification"
|
will send on connect"
|
||||||
);
|
|
||||||
self.pending_bootstrapped_notifications.insert(
|
|
||||||
session_id,
|
|
||||||
PendingSessionBootstrappedNotification {
|
|
||||||
shell_type: shell_type.to_owned(),
|
|
||||||
shell_path: shell_path.map(ToOwned::to_owned),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -856,18 +948,19 @@ impl RemoteServerManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Transitions a session from `Initializing` to `Connected`. Stores the
|
||||||
|
/// `transport` for reconnection support after a spontaneous disconnect.
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
fn mark_session_connected(
|
fn mark_session_connected(
|
||||||
&mut self,
|
&mut self,
|
||||||
session_id: SessionId,
|
session_id: SessionId,
|
||||||
host_id: HostId,
|
host_id: HostId,
|
||||||
|
transport: Arc<dyn RemoteTransport>,
|
||||||
ctx: &mut ModelContext<Self>,
|
ctx: &mut ModelContext<Self>,
|
||||||
) {
|
) {
|
||||||
log::info!("Remote server connected for session {session_id:?}, host {host_id}");
|
log::info!("Remote server connected for session {session_id:?}, host {host_id}");
|
||||||
|
|
||||||
// Only transition if the session is still in Initializing state.
|
// Only transition if the session is still in Initializing state.
|
||||||
// Remove first so we can move the client handle (and owned `Child`)
|
|
||||||
// out.
|
|
||||||
let Some(RemoteSessionState::Initializing {
|
let Some(RemoteSessionState::Initializing {
|
||||||
client,
|
client,
|
||||||
_child,
|
_child,
|
||||||
@@ -881,10 +974,11 @@ impl RemoteServerManager {
|
|||||||
self.sessions.insert(
|
self.sessions.insert(
|
||||||
session_id,
|
session_id,
|
||||||
RemoteSessionState::Connected {
|
RemoteSessionState::Connected {
|
||||||
client,
|
client: client.clone(),
|
||||||
host_id: host_id.clone(),
|
host_id: host_id.clone(),
|
||||||
_child,
|
_child,
|
||||||
control_path,
|
control_path,
|
||||||
|
transport,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
self.host_to_sessions
|
self.host_to_sessions
|
||||||
@@ -905,51 +999,275 @@ impl RemoteServerManager {
|
|||||||
host_id,
|
host_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Flush any SessionBootstrapped notification that was stashed before
|
// (Re-)send the SessionBootstrapped notification so the daemon
|
||||||
// the session reached Connected.
|
// registers an executor for this session. This fires on both the
|
||||||
if let Some(notif) = self.pending_bootstrapped_notifications.remove(&session_id) {
|
// initial connect and every reconnect.
|
||||||
|
if let Some(info) = self.session_bootstrap_info.get(&session_id) {
|
||||||
if let Some(client) = self.client_for_session(session_id) {
|
if let Some(client) = self.client_for_session(session_id) {
|
||||||
log::info!(
|
log::info!("Sending SessionBootstrapped notification for session {session_id:?}");
|
||||||
"Flushing stashed SessionBootstrapped notification for session \
|
|
||||||
{session_id:?}"
|
|
||||||
);
|
|
||||||
client.notify_session_bootstrapped(
|
client.notify_session_bootstrapped(
|
||||||
session_id,
|
session_id,
|
||||||
¬if.shell_type,
|
&info.shell_type,
|
||||||
notif.shell_path.as_deref(),
|
info.shell_path.as_deref(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Captures the exit status from a `Child` process, if available.
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
fn capture_exit_status(
|
||||||
|
child: &mut async_process::Child,
|
||||||
|
session_id: SessionId,
|
||||||
|
) -> Option<RemoteServerExitStatus> {
|
||||||
|
match child.try_status() {
|
||||||
|
Ok(Some(status)) => {
|
||||||
|
let code = status.code();
|
||||||
|
#[cfg(unix)]
|
||||||
|
let signal_killed = {
|
||||||
|
use std::os::unix::process::ExitStatusExt;
|
||||||
|
status.signal().is_some()
|
||||||
|
};
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let signal_killed = false;
|
||||||
|
log::warn!(
|
||||||
|
"Remote server process exited for session {session_id:?}: \
|
||||||
|
code={code:?}, signal_killed={signal_killed}"
|
||||||
|
);
|
||||||
|
Some(RemoteServerExitStatus {
|
||||||
|
code,
|
||||||
|
signal_killed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
log::warn!(
|
||||||
|
"Remote server process still running for session {session_id:?} \
|
||||||
|
despite EOF on reader task"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to read exit status for session {session_id:?}: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
pub(crate) fn mark_session_disconnected(
|
pub(crate) fn mark_session_disconnected(
|
||||||
&mut self,
|
&mut self,
|
||||||
session_id: SessionId,
|
session_id: SessionId,
|
||||||
ctx: &mut ModelContext<Self>,
|
ctx: &mut ModelContext<Self>,
|
||||||
) {
|
) {
|
||||||
self.pending_bootstrapped_notifications.remove(&session_id);
|
|
||||||
let Some(prev) = self.sessions.remove(&session_id) else {
|
let Some(prev) = self.sessions.remove(&session_id) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
self.sessions
|
|
||||||
.insert(session_id, RemoteSessionState::Disconnected);
|
|
||||||
|
|
||||||
if let RemoteSessionState::Connected { host_id, .. } = prev {
|
// Only attempt reconnect for sessions that were in Connected state
|
||||||
|
// with a transport available, and not being explicitly deregistered.
|
||||||
|
if let RemoteSessionState::Connected {
|
||||||
|
host_id,
|
||||||
|
mut _child,
|
||||||
|
control_path,
|
||||||
|
transport,
|
||||||
|
..
|
||||||
|
} = prev
|
||||||
|
{
|
||||||
|
let exit_status = Self::capture_exit_status(&mut _child, session_id);
|
||||||
|
// Drop the old child process explicitly before reconnecting.
|
||||||
|
drop(_child);
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Spontaneous disconnect for session {session_id:?}, \
|
||||||
|
will attempt reconnect (transport={transport:?})"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear stale repo metadata and host index so downstream
|
||||||
|
// models don't hold onto data from the dead server process.
|
||||||
self.remove_from_host_index(&host_id, session_id);
|
self.remove_from_host_index(&host_id, session_id);
|
||||||
// Emit `SessionDisconnected` before `HostDisconnected` so that
|
if !self.host_to_sessions.contains_key(&host_id) {
|
||||||
// subscribers (e.g. the command executor) drop their
|
ctx.emit(RemoteServerManagerEvent::HostDisconnected {
|
||||||
// `Arc<RemoteServerClient>` reference before any host-scoped
|
host_id: host_id.clone(),
|
||||||
// teardown runs. This matches the ordering in
|
});
|
||||||
// `deregister_session` so both teardown paths look identical
|
}
|
||||||
// to subscribers.
|
|
||||||
|
// Clear last navigated path so navigate_to_directory
|
||||||
|
// re-fires after reconnect.
|
||||||
|
// We need to do this on disconnect because the cached
|
||||||
|
// navigated path is only deduping for the current _remote server session.
|
||||||
|
self.last_navigated_path.remove(&session_id);
|
||||||
|
|
||||||
|
self.attempt_reconnect(
|
||||||
|
session_id,
|
||||||
|
ReconnectParams {
|
||||||
|
attempt: 1,
|
||||||
|
host_id,
|
||||||
|
exit_status,
|
||||||
|
transport,
|
||||||
|
control_path,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Non-Connected states (Initializing, Connecting, etc.) —
|
||||||
|
// no reconnect, just mark disconnected.
|
||||||
|
self.sessions
|
||||||
|
.insert(session_id, RemoteSessionState::Disconnected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempt to re-establish the remote server connection.
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
fn attempt_reconnect(
|
||||||
|
&mut self,
|
||||||
|
session_id: SessionId,
|
||||||
|
params: ReconnectParams,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
|
) {
|
||||||
|
let ReconnectParams {
|
||||||
|
attempt,
|
||||||
|
host_id,
|
||||||
|
exit_status,
|
||||||
|
transport,
|
||||||
|
control_path,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Attempting reconnect for session {session_id:?} \
|
||||||
|
(attempt {attempt}/{MAX_RECONNECT_ATTEMPTS})"
|
||||||
|
);
|
||||||
|
|
||||||
|
self.sessions.insert(
|
||||||
|
session_id,
|
||||||
|
RemoteSessionState::Reconnecting {
|
||||||
|
attempt,
|
||||||
|
host_id: host_id.clone(),
|
||||||
|
control_path: control_path.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let spawner = self.spawner.clone();
|
||||||
|
let executor = ctx.background_executor().clone();
|
||||||
|
let transport_clone = Arc::clone(&transport);
|
||||||
|
|
||||||
|
ctx.background_executor()
|
||||||
|
.spawn(async move {
|
||||||
|
async_io::Timer::after(RECONNECT_DELAY).await;
|
||||||
|
|
||||||
|
// Check if the session was deregistered during the delay.
|
||||||
|
// (Checked via spawner since sessions lives on the main thread.)
|
||||||
|
let was_removed = spawner
|
||||||
|
.spawn(move |me, _ctx| !me.sessions.contains_key(&session_id))
|
||||||
|
.await
|
||||||
|
.unwrap_or(true);
|
||||||
|
if was_removed {
|
||||||
|
log::info!("Session {session_id:?} removed during reconnect delay, aborting");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match Self::run_connect_and_handshake(
|
||||||
|
session_id,
|
||||||
|
&*transport_clone,
|
||||||
|
&spawner,
|
||||||
|
&executor,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(new_host_id) => {
|
||||||
|
let _ = spawner
|
||||||
|
.spawn(move |me, ctx| {
|
||||||
|
// If the session was deregistered during the
|
||||||
|
// handshake, don't resurrect it.
|
||||||
|
if !me.sessions.contains_key(&session_id) {
|
||||||
|
log::info!(
|
||||||
|
"Session {session_id:?} deregistered during \
|
||||||
|
reconnect handshake, aborting"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
me.mark_session_connected(
|
||||||
|
session_id,
|
||||||
|
new_host_id.clone(),
|
||||||
|
transport,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
if let Some(client) = me.client_for_session(session_id).cloned() {
|
||||||
|
ctx.emit(RemoteServerManagerEvent::SessionReconnected {
|
||||||
|
session_id,
|
||||||
|
host_id: new_host_id,
|
||||||
|
attempt,
|
||||||
|
client,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!(
|
||||||
|
"Reconnect failed for session {session_id:?} \
|
||||||
|
(attempt {attempt}): {e}"
|
||||||
|
);
|
||||||
|
let _ = spawner
|
||||||
|
.spawn(move |me, ctx| {
|
||||||
|
// If the session was deregistered during the
|
||||||
|
// handshake, don't retry or insert Disconnected.
|
||||||
|
if !me.sessions.contains_key(&session_id) {
|
||||||
|
log::info!(
|
||||||
|
"Session {session_id:?} deregistered during \
|
||||||
|
reconnect handshake, aborting"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
me.handle_reconnect_failure(
|
||||||
|
session_id,
|
||||||
|
ReconnectParams {
|
||||||
|
attempt,
|
||||||
|
host_id,
|
||||||
|
exit_status,
|
||||||
|
transport,
|
||||||
|
control_path,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a failed reconnection attempt: either retry or give up.
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
fn handle_reconnect_failure(
|
||||||
|
&mut self,
|
||||||
|
session_id: SessionId,
|
||||||
|
params: ReconnectParams,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
|
) {
|
||||||
|
if params.attempt < MAX_RECONNECT_ATTEMPTS {
|
||||||
|
self.attempt_reconnect(
|
||||||
|
session_id,
|
||||||
|
ReconnectParams {
|
||||||
|
attempt: params.attempt + 1,
|
||||||
|
..params
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log::warn!(
|
||||||
|
"Reconnect exhausted for session {session_id:?} after {} attempt(s)",
|
||||||
|
params.attempt
|
||||||
|
);
|
||||||
|
self.sessions
|
||||||
|
.insert(session_id, RemoteSessionState::Disconnected);
|
||||||
ctx.emit(RemoteServerManagerEvent::SessionDisconnected {
|
ctx.emit(RemoteServerManagerEvent::SessionDisconnected {
|
||||||
session_id,
|
session_id,
|
||||||
host_id: host_id.clone(),
|
host_id: params.host_id,
|
||||||
|
exit_status: params.exit_status,
|
||||||
});
|
});
|
||||||
if !self.host_to_sessions.contains_key(&host_id) {
|
// Note: HostDisconnected was already emitted by
|
||||||
ctx.emit(RemoteServerManagerEvent::HostDisconnected { host_id });
|
// mark_session_disconnected when entering the reconnect flow.
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,13 @@
|
|||||||
//! in-process for tests) implement the same trait without touching the
|
//! in-process for tests) implement the same trait without touching the
|
||||||
//! manager.
|
//! manager.
|
||||||
//!
|
//!
|
||||||
//! Methods are async. Callers use the trait via generics
|
//! Returns boxed futures for object safety — the manager stores
|
||||||
//! (`T: RemoteTransport`) rather than `dyn` dispatch.
|
//! `Arc<dyn RemoteTransport>` for reconnection.
|
||||||
//!
|
//!
|
||||||
//! [`RemoteServerManager`]: crate::manager::RemoteServerManager
|
//! [`RemoteServerManager`]: crate::manager::RemoteServerManager
|
||||||
use std::future::Future;
|
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::pin::Pin;
|
||||||
|
|
||||||
use async_channel::Receiver;
|
use async_channel::Receiver;
|
||||||
use warpui::r#async::executor;
|
use warpui::r#async::executor;
|
||||||
@@ -56,12 +56,18 @@ pub struct Connection {
|
|||||||
pub control_path: Option<PathBuf>,
|
pub control_path: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait RemoteTransport: Send + Sync {
|
/// Transport abstraction for remote server connections.
|
||||||
|
///
|
||||||
|
/// Object-safe: returns boxed futures so implementations can be stored
|
||||||
|
/// as `Arc<dyn RemoteTransport>` for reconnection.
|
||||||
|
pub trait RemoteTransport: Send + Sync + std::fmt::Debug {
|
||||||
/// Detects the remote host's OS and architecture by running `uname -sm`.
|
/// Detects the remote host's OS and architecture by running `uname -sm`.
|
||||||
///
|
///
|
||||||
/// Returns the parsed [`RemotePlatform`] on success, or an error string
|
/// Returns the parsed [`RemotePlatform`] on success, or an error string
|
||||||
/// if the command fails or the output cannot be parsed.
|
/// if the command fails or the output cannot be parsed.
|
||||||
fn detect_platform(&self) -> impl Future<Output = Result<RemotePlatform, String>> + Send;
|
fn detect_platform(
|
||||||
|
&self,
|
||||||
|
) -> Pin<Box<dyn std::future::Future<Output = Result<RemotePlatform, String>> + Send>>;
|
||||||
|
|
||||||
/// Checks whether the remote server binary is present on the remote host.
|
/// Checks whether the remote server binary is present on the remote host.
|
||||||
///
|
///
|
||||||
@@ -72,7 +78,9 @@ pub trait RemoteTransport: Send + Sync {
|
|||||||
/// Returns `Ok(true)` if the binary is installed and executable,
|
/// Returns `Ok(true)` if the binary is installed and executable,
|
||||||
/// `Ok(false)` if it is definitively not installed, and
|
/// `Ok(false)` if it is definitively not installed, and
|
||||||
/// `Err(_)` if the check failed (e.g. SSH timeout/unreachable).
|
/// `Err(_)` if the check failed (e.g. SSH timeout/unreachable).
|
||||||
fn check_binary(&self) -> impl Future<Output = Result<bool, String>> + Send;
|
fn check_binary(
|
||||||
|
&self,
|
||||||
|
) -> Pin<Box<dyn std::future::Future<Output = Result<bool, String>> + Send>>;
|
||||||
|
|
||||||
/// Installs the remote server binary on the remote host.
|
/// Installs the remote server binary on the remote host.
|
||||||
///
|
///
|
||||||
@@ -82,7 +90,9 @@ pub trait RemoteTransport: Send + Sync {
|
|||||||
///
|
///
|
||||||
/// Returns `Ok(())` if the install succeeded, and
|
/// Returns `Ok(())` if the install succeeded, and
|
||||||
/// `Err(_)` if the install failed (e.g. SSH timeout, script error).
|
/// `Err(_)` if the install failed (e.g. SSH timeout, script error).
|
||||||
fn install_binary(&self) -> impl Future<Output = Result<(), String>> + Send;
|
fn install_binary(
|
||||||
|
&self,
|
||||||
|
) -> Pin<Box<dyn std::future::Future<Output = Result<(), String>> + Send>>;
|
||||||
|
|
||||||
/// Establish a new connection to the remote server.
|
/// Establish a new connection to the remote server.
|
||||||
///
|
///
|
||||||
@@ -96,6 +106,6 @@ pub trait RemoteTransport: Send + Sync {
|
|||||||
/// a socket). Stderr forwarding to local logging should also happen here.
|
/// a socket). Stderr forwarding to local logging should also happen here.
|
||||||
fn connect(
|
fn connect(
|
||||||
&self,
|
&self,
|
||||||
executor: &executor::Background,
|
executor: std::sync::Arc<executor::Background>,
|
||||||
) -> impl Future<Output = anyhow::Result<Connection>> + Send;
|
) -> Pin<Box<dyn std::future::Future<Output = anyhow::Result<Connection>> + Send>>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# APP-4283: Remote Server Reconnection on Spontaneous Disconnect
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
When the SSH remote server connection drops spontaneously (daemon crash, proxy killed, transient network failure), the `RemoteServerClient` reader task hits EOF and calls `mark_session_disconnected`. Before this change the session transitions straight to `Disconnected` and stays there — completions, repo metadata, and all other remote-server-backed features stop working until the user manually exits and re-SSHes.
|
||||||
|
|
||||||
|
The remote server architecture uses a **proxy → daemon** model:
|
||||||
|
|
||||||
|
- `SshTransport` (`app/src/remote_server/ssh_transport.rs`) spawns `ssh … remote-server-proxy` whose stdin/stdout become the protocol channel.
|
||||||
|
- The proxy (`app/src/remote_server/unix/proxy.rs:34`) connects to a long-lived daemon via a local Unix socket. The daemon survives across proxy lifetimes.
|
||||||
|
|
||||||
|
The core session lifecycle lives in `RemoteServerManager` (`crates/remote_server/src/manager.rs`), a singleton model with per-session state tracked via `RemoteSessionState`. The state machine before this change:
|
||||||
|
|
||||||
|
```
|
||||||
|
Connecting → Initializing → Connected → Disconnected
|
||||||
|
```
|
||||||
|
|
||||||
|
Key files:
|
||||||
|
|
||||||
|
- `crates/remote_server/src/manager.rs` — `RemoteServerManager`, session state machine, connect/disconnect lifecycle
|
||||||
|
- `crates/remote_server/src/transport.rs` — `RemoteTransport` trait, `Connection` struct
|
||||||
|
- `app/src/remote_server/ssh_transport.rs` — SSH `RemoteTransport` implementation
|
||||||
|
- `app/src/terminal/model/session.rs (135-191)` — `Sessions` model, subscribes to manager events, owns the `Session` and its `CommandExecutor`
|
||||||
|
- `app/src/terminal/model/session.rs (862-1050)` — `Session` struct, holds the `command_executor` used by completions
|
||||||
|
|
||||||
|
## Proposed changes
|
||||||
|
|
||||||
|
### 1. Object-safe `RemoteTransport` for reconnection
|
||||||
|
|
||||||
|
The `RemoteTransport` trait (`crates/remote_server/src/transport.rs:63`) now returns boxed futures (`Pin<Box<dyn Future<…> + Send>>`) for object safety. This allows `RemoteServerManager` to store `Arc<dyn RemoteTransport>` on the `Connected` state and carry it forward through reconnection without knowing the concrete transport type.
|
||||||
|
|
||||||
|
`SshTransport` (`app/src/remote_server/ssh_transport.rs:36`) implements the trait directly with `Box::pin(async move { … })`.
|
||||||
|
|
||||||
|
### 2. Exit status capture
|
||||||
|
|
||||||
|
New `RemoteServerExitStatus` type (`manager.rs:67`) records `code: Option<i32>` and `signal_killed: bool`.
|
||||||
|
|
||||||
|
`capture_exit_status()` (`manager.rs:982`) reads `child.try_status()` before the `Child` is dropped on disconnect. The result is carried on `SessionDisconnected.exit_status` for diagnostics and telemetry.
|
||||||
|
|
||||||
|
### 3. Reconnection state machine
|
||||||
|
|
||||||
|
New state variant `Reconnecting { attempt, host_id, control_path }` (`manager.rs:139`). The state machine becomes:
|
||||||
|
|
||||||
|
```
|
||||||
|
Connecting → Initializing → Connected → Reconnecting → Initializing → Connected
|
||||||
|
↘ Disconnected (if retries exhausted)
|
||||||
|
```
|
||||||
|
|
||||||
|
Constants: `MAX_RECONNECT_ATTEMPTS = 2`, `RECONNECT_DELAY = 2s` (`manager.rs:22-24`).
|
||||||
|
|
||||||
|
`mark_session_disconnected()` (`manager.rs:1020`) checks whether the session was `Connected` with a stored transport. If so it clears stale host-index and repo-metadata state, then calls `attempt_reconnect()`.
|
||||||
|
|
||||||
|
`attempt_reconnect()` (`manager.rs:1100`) transitions to `Reconnecting`, waits `RECONNECT_DELAY` via `async_io::Timer`, then calls the shared `run_connect_and_handshake()`. On success it calls `mark_session_connected()` and emits `SessionReconnected`. On failure, `handle_reconnect_failure()` (`manager.rs:1203`) either increments the attempt and retries, or gives up and emits `SessionDisconnected`.
|
||||||
|
|
||||||
|
The retry parameters are bundled in `ReconnectParams` (`manager.rs:28`) to stay under clippy's argument limit.
|
||||||
|
|
||||||
|
### 4. Shared connect + handshake helper
|
||||||
|
|
||||||
|
`run_connect_and_handshake()` (`manager.rs:546`) is extracted from `connect_session()` so both the initial connect and reconnect share the same two-phase logic: `transport.connect()` → `Initializing` → `client.initialize()` → `HostId`.
|
||||||
|
|
||||||
|
### 5. Session bootstrap info persistence
|
||||||
|
|
||||||
|
Previously, `notify_session_bootstrapped()` stashed shell info in a `pending_bootstrapped_notifications` map that was consumed on the first connect and wiped on disconnect. After reconnect, the daemon had no executor registered for the session (`SessionNotFound` error on completions).
|
||||||
|
|
||||||
|
Fix: renamed to `session_bootstrap_info` (`manager.rs:310`), which persists for the session lifetime (removed only in `deregister_session`). `notify_session_bootstrapped()` (`manager.rs:790`) always stores the info. `mark_session_connected()` (`manager.rs:965`) re-sends the `SessionBootstrapped` notification on every connect/reconnect.
|
||||||
|
|
||||||
|
### 6. Command executor swap on reconnect
|
||||||
|
|
||||||
|
`Session.command_executor` (`session.rs:867`) changed from `Arc<dyn CommandExecutor>` to `RwLock<Arc<dyn CommandExecutor>>` for interior mutability through `Arc<Session>`. New `Session::set_command_executor()` (`session.rs:1048`).
|
||||||
|
|
||||||
|
All read sites (e.g. `execute_command` at `session.rs:1117`, `load_external_commands` at `session.rs:1083`) clone the `Arc` out of the lock before `.await` to avoid holding the guard across await points.
|
||||||
|
|
||||||
|
The `Sessions` model subscribes to `SessionReconnected` (`session.rs:178-189`) and swaps in a new `RemoteServerCommandExecutor` backed by the reconnected client.
|
||||||
|
|
||||||
|
### 7. Downstream match arm updates
|
||||||
|
|
||||||
|
Added exhaustive match arms for `SessionReconnected` in `Sessions` subscriber (`session.rs:178`) and `terminal/view.rs`.
|
||||||
|
|
||||||
|
## Diagram
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> Connecting: connect_session()
|
||||||
|
Connecting --> Initializing: transport.connect() ok
|
||||||
|
Initializing --> Connected: client.initialize() ok
|
||||||
|
Connected --> Reconnecting: spontaneous EOF + transport available
|
||||||
|
Reconnecting --> Initializing: transport.connect() ok (after delay)
|
||||||
|
Reconnecting --> Reconnecting: attempt < MAX (after delay)
|
||||||
|
Reconnecting --> Disconnected: retries exhausted
|
||||||
|
Connected --> Disconnected: no transport / explicit deregister
|
||||||
|
Initializing --> Disconnected: handshake failed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing and validation
|
||||||
|
|
||||||
|
Manual E2E (verified):
|
||||||
|
|
||||||
|
1. Build Warp from this branch (`cargo run`).
|
||||||
|
2. SSH into a remote host with the remote server feature flag enabled.
|
||||||
|
3. Kill the `remote-server-proxy` on the remote side: `ssh <host> "pkill -f remote-server-proxy"`.
|
||||||
|
4. Observe in `Warp.log`:
|
||||||
|
- `"Remote server process exited for session …"` — exit status captured
|
||||||
|
- `"Spontaneous disconnect for session …, will attempt reconnect"` — reconnect triggered
|
||||||
|
- `"Attempting reconnect for session … (attempt 1/2)"` — delay + retry
|
||||||
|
- `"Remote server connected for session …"` — reconnect succeeded
|
||||||
|
- `"Sending SessionBootstrapped notification for session …"` — daemon re-registered
|
||||||
|
- `"Swapped command executor for session … after reconnect"` — executor swapped
|
||||||
|
5. Press Tab in the SSH session to trigger completions — should work.
|
||||||
|
6. Verify `navigate_to_directory` re-fires (repo metadata restored).
|
||||||
|
|
||||||
|
Edge cases verified manually:
|
||||||
|
- Kill the daemon (`pkill -f remote-server-daemon`) — harder reconnect since daemon must restart; proxy's `run()` re-spawns it.
|
||||||
|
- `deregister_session` during reconnect delay — reconnect aborts cleanly (checked via `sessions.contains_key`).
|
||||||
|
|
||||||
|
## Follow-ups
|
||||||
|
|
||||||
|
- **User-visible reconnecting indicator**: surface the `Reconnecting` state in the terminal UI so the user knows a retry is in progress.
|
||||||
|
- **Telemetry**: emit a structured event with `exit_status`, `attempt`, and `reconnect_succeeded` for reconnect outcomes.
|
||||||
|
- **Exponential backoff**: the current fixed 2s delay works for the proxy-restart case; longer backoffs may be warranted for network-level failures.
|
||||||
Reference in New Issue
Block a user