From 1eae76e85638fbf6da433d008fda6a29ae954ed5 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 26 Aug 2025 17:15:39 -0700 Subject: [PATCH] Restructure remote client crate, consolidate SSH logic (#36967) This is a pure refactor that consolidates all SSH remoting logic such that it should be straightforward to add another transport to the remoting system. Release Notes: - N/A --------- Co-authored-by: Mikayla Maki --- crates/agent_ui/src/inline_prompt_editor.rs | 2 +- crates/call/src/call_impl/room.rs | 2 +- .../remote_editing_collaboration_tests.rs | 22 +- crates/collab/src/tests/test_server.rs | 6 +- crates/debugger_ui/src/session/running.rs | 9 +- crates/editor/src/editor.rs | 6 +- crates/extension_host/src/extension_host.rs | 18 +- crates/file_finder/src/file_finder.rs | 2 +- crates/language_tools/src/lsp_log.rs | 6 +- crates/outline_panel/src/outline_panel.rs | 6 +- crates/project/src/debugger/dap_store.rs | 108 +- crates/project/src/git_store.rs | 68 +- crates/project/src/project.rs | 311 +- crates/project/src/terminals.rs | 355 +-- crates/project/src/worktree_store.rs | 6 +- crates/project_panel/src/project_panel.rs | 4 +- crates/proto/src/proto.rs | 4 +- .../src/disconnected_overlay.rs | 4 +- crates/recent_projects/src/remote_servers.rs | 8 +- crates/recent_projects/src/ssh_connections.rs | 19 +- crates/remote/src/protocol.rs | 10 + crates/remote/src/remote.rs | 10 +- crates/remote/src/remote_client.rs | 1478 +++++++++ crates/remote/src/ssh_session.rs | 2749 ----------------- crates/remote/src/transport.rs | 1 + crates/remote/src/transport/ssh.rs | 1358 ++++++++ crates/remote_server/src/headless_project.rs | 52 +- .../remote_server/src/remote_editing_tests.rs | 12 +- crates/remote_server/src/unix.rs | 25 +- crates/task/src/shell_builder.rs | 15 +- crates/terminal_view/src/terminal_element.rs | 2 +- crates/terminal_view/src/terminal_panel.rs | 29 +- crates/title_bar/src/collab.rs | 2 +- crates/title_bar/src/title_bar.rs | 12 +- crates/vim/src/command.rs | 4 +- crates/workspace/src/tasks.rs | 2 +- crates/workspace/src/workspace.rs | 14 +- crates/zed/src/reliability.rs | 4 +- crates/zed/src/zed.rs | 4 +- 39 files changed, 3334 insertions(+), 3415 deletions(-) create mode 100644 crates/remote/src/remote_client.rs delete mode 100644 crates/remote/src/ssh_session.rs create mode 100644 crates/remote/src/transport.rs create mode 100644 crates/remote/src/transport/ssh.rs diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs index a626122769f656cf6627d104d00f4fa3a368e7db..3abefac8e8964ffdddc1397132541d0056f33ea8 100644 --- a/crates/agent_ui/src/inline_prompt_editor.rs +++ b/crates/agent_ui/src/inline_prompt_editor.rs @@ -334,7 +334,7 @@ impl PromptEditor { EditorEvent::Edited { .. } => { if let Some(workspace) = window.root::().flatten() { workspace.update(cx, |workspace, cx| { - let is_via_ssh = workspace.project().read(cx).is_via_ssh(); + let is_via_ssh = workspace.project().read(cx).is_via_remote_server(); workspace .client() diff --git a/crates/call/src/call_impl/room.rs b/crates/call/src/call_impl/room.rs index ffe4c6c25191dc8f9087ccfcc77252b8e5a25a13..c31a458c64124c266c56a7004746d7b6a0a4adc6 100644 --- a/crates/call/src/call_impl/room.rs +++ b/crates/call/src/call_impl/room.rs @@ -1161,7 +1161,7 @@ impl Room { let request = self.client.request(proto::ShareProject { room_id: self.id(), worktrees: project.read(cx).worktree_metadata_protos(cx), - is_ssh_project: project.read(cx).is_via_ssh(), + is_ssh_project: project.read(cx).is_via_remote_server(), }); cx.spawn(async move |this, cx| { diff --git a/crates/collab/src/tests/remote_editing_collaboration_tests.rs b/crates/collab/src/tests/remote_editing_collaboration_tests.rs index 8ab6e6910c88880bc8b6451d972e39b5c2315812..6b46459a59b16717d965b42c4e19820f6d1dc062 100644 --- a/crates/collab/src/tests/remote_editing_collaboration_tests.rs +++ b/crates/collab/src/tests/remote_editing_collaboration_tests.rs @@ -26,7 +26,7 @@ use project::{ debugger::session::ThreadId, lsp_store::{FormatTrigger, LspFormatTarget}, }; -use remote::SshRemoteClient; +use remote::RemoteClient; use remote_server::{HeadlessAppState, HeadlessProject}; use rpc::proto; use serde_json::json; @@ -59,7 +59,7 @@ async fn test_sharing_an_ssh_remote_project( .await; // Set up project on remote FS - let (opts, server_ssh) = SshRemoteClient::fake_server(cx_a, server_cx); + let (opts, server_ssh) = RemoteClient::fake_server(cx_a, server_cx); let remote_fs = FakeFs::new(server_cx.executor()); remote_fs .insert_tree( @@ -101,7 +101,7 @@ async fn test_sharing_an_ssh_remote_project( ) }); - let client_ssh = SshRemoteClient::fake_client(opts, cx_a).await; + let client_ssh = RemoteClient::fake_client(opts, cx_a).await; let (project_a, worktree_id) = client_a .build_ssh_project(path!("/code/project1"), client_ssh, cx_a) .await; @@ -235,7 +235,7 @@ async fn test_ssh_collaboration_git_branches( .await; // Set up project on remote FS - let (opts, server_ssh) = SshRemoteClient::fake_server(cx_a, server_cx); + let (opts, server_ssh) = RemoteClient::fake_server(cx_a, server_cx); let remote_fs = FakeFs::new(server_cx.executor()); remote_fs .insert_tree("/project", serde_json::json!({ ".git":{} })) @@ -268,7 +268,7 @@ async fn test_ssh_collaboration_git_branches( ) }); - let client_ssh = SshRemoteClient::fake_client(opts, cx_a).await; + let client_ssh = RemoteClient::fake_client(opts, cx_a).await; let (project_a, _) = client_a .build_ssh_project("/project", client_ssh, cx_a) .await; @@ -420,7 +420,7 @@ async fn test_ssh_collaboration_formatting_with_prettier( .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) .await; - let (opts, server_ssh) = SshRemoteClient::fake_server(cx_a, server_cx); + let (opts, server_ssh) = RemoteClient::fake_server(cx_a, server_cx); let remote_fs = FakeFs::new(server_cx.executor()); let buffer_text = "let one = \"two\""; let prettier_format_suffix = project::TEST_PRETTIER_FORMAT_SUFFIX; @@ -473,7 +473,7 @@ async fn test_ssh_collaboration_formatting_with_prettier( ) }); - let client_ssh = SshRemoteClient::fake_client(opts, cx_a).await; + let client_ssh = RemoteClient::fake_client(opts, cx_a).await; let (project_a, worktree_id) = client_a .build_ssh_project(path!("/project"), client_ssh, cx_a) .await; @@ -602,7 +602,7 @@ async fn test_remote_server_debugger( release_channel::init(SemanticVersion::default(), cx); dap_adapters::init(cx); }); - let (opts, server_ssh) = SshRemoteClient::fake_server(cx_a, server_cx); + let (opts, server_ssh) = RemoteClient::fake_server(cx_a, server_cx); let remote_fs = FakeFs::new(server_cx.executor()); remote_fs .insert_tree( @@ -633,7 +633,7 @@ async fn test_remote_server_debugger( ) }); - let client_ssh = SshRemoteClient::fake_client(opts, cx_a).await; + let client_ssh = RemoteClient::fake_client(opts, cx_a).await; let mut server = TestServer::start(server_cx.executor()).await; let client_a = server.create_client(cx_a, "user_a").await; cx_a.update(|cx| { @@ -711,7 +711,7 @@ async fn test_slow_adapter_startup_retries( release_channel::init(SemanticVersion::default(), cx); dap_adapters::init(cx); }); - let (opts, server_ssh) = SshRemoteClient::fake_server(cx_a, server_cx); + let (opts, server_ssh) = RemoteClient::fake_server(cx_a, server_cx); let remote_fs = FakeFs::new(server_cx.executor()); remote_fs .insert_tree( @@ -742,7 +742,7 @@ async fn test_slow_adapter_startup_retries( ) }); - let client_ssh = SshRemoteClient::fake_client(opts, cx_a).await; + let client_ssh = RemoteClient::fake_client(opts, cx_a).await; let mut server = TestServer::start(server_cx.executor()).await; let client_a = server.create_client(cx_a, "user_a").await; cx_a.update(|cx| { diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index fd5e3eefc158034e8b15dc3fd7e401b1041fe08e..eb7df28478158a10a0c2d52c3560cad391937383 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -26,7 +26,7 @@ use node_runtime::NodeRuntime; use notifications::NotificationStore; use parking_lot::Mutex; use project::{Project, WorktreeId}; -use remote::SshRemoteClient; +use remote::RemoteClient; use rpc::{ RECEIVE_TIMEOUT, proto::{self, ChannelRole}, @@ -765,11 +765,11 @@ impl TestClient { pub async fn build_ssh_project( &self, root_path: impl AsRef, - ssh: Entity, + ssh: Entity, cx: &mut TestAppContext, ) -> (Entity, WorktreeId) { let project = cx.update(|cx| { - Project::ssh( + Project::remote( ssh, self.client().clone(), self.app_state.node_runtime.clone(), diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs index 9991395f351dd3cd6d6a7f6d95ded11024ba6a4e..a0e7c9a10173c31edc193fd0309913b60c6f8a95 100644 --- a/crates/debugger_ui/src/session/running.rs +++ b/crates/debugger_ui/src/session/running.rs @@ -916,10 +916,11 @@ impl RunningState { let task_store = project.read(cx).task_store().downgrade(); let weak_project = project.downgrade(); let weak_workspace = workspace.downgrade(); - let ssh_info = project + let remote_shell = project .read(cx) - .ssh_client() - .and_then(|it| it.read(cx).ssh_info()); + .remote_client() + .as_ref() + .and_then(|remote| remote.read(cx).shell()); cx.spawn_in(window, async move |this, cx| { let DebugScenario { @@ -1003,7 +1004,7 @@ impl RunningState { None }; - let builder = ShellBuilder::new(ssh_info.as_ref().map(|info| &*info.shell), &task.resolved.shell); + let builder = ShellBuilder::new(remote_shell.as_deref(), &task.resolved.shell); let command_label = builder.command_label(&task.resolved.command_label); let (command, args) = builder.build(task.resolved.command.clone(), &task.resolved.args); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 80680ae9c00999bd91eb1ad66971259f587752ac..52549902dd603ee8ffdc7c50dd331c87c95828cb 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -20074,7 +20074,7 @@ impl Editor { let (telemetry, is_via_ssh) = { let project = project.read(cx); let telemetry = project.client().telemetry().clone(); - let is_via_ssh = project.is_via_ssh(); + let is_via_ssh = project.is_via_remote_server(); (telemetry, is_via_ssh) }; refresh_linked_ranges(self, window, cx); @@ -20642,7 +20642,7 @@ impl Editor { copilot_enabled, copilot_enabled_for_language, edit_predictions_provider, - is_via_ssh = project.is_via_ssh(), + is_via_ssh = project.is_via_remote_server(), ); } else { telemetry::event!( @@ -20652,7 +20652,7 @@ impl Editor { copilot_enabled, copilot_enabled_for_language, edit_predictions_provider, - is_via_ssh = project.is_via_ssh(), + is_via_ssh = project.is_via_remote_server(), ); }; } diff --git a/crates/extension_host/src/extension_host.rs b/crates/extension_host/src/extension_host.rs index fde0aeac9405d114f9cee89ca054d4503a35d482..b8189c36511a03f136e5e215549453947e888bb1 100644 --- a/crates/extension_host/src/extension_host.rs +++ b/crates/extension_host/src/extension_host.rs @@ -43,7 +43,7 @@ use language::{ use node_runtime::NodeRuntime; use project::ContextProviderWithTasks; use release_channel::ReleaseChannel; -use remote::SshRemoteClient; +use remote::RemoteClient; use semantic_version::SemanticVersion; use serde::{Deserialize, Serialize}; use settings::Settings; @@ -117,7 +117,7 @@ pub struct ExtensionStore { pub wasm_host: Arc, pub wasm_extensions: Vec<(Arc, WasmExtension)>, pub tasks: Vec>, - pub ssh_clients: HashMap>, + pub remote_clients: HashMap>, pub ssh_registered_tx: UnboundedSender<()>, } @@ -270,7 +270,7 @@ impl ExtensionStore { reload_tx, tasks: Vec::new(), - ssh_clients: HashMap::default(), + remote_clients: HashMap::default(), ssh_registered_tx: connection_registered_tx, }; @@ -1693,7 +1693,7 @@ impl ExtensionStore { async fn sync_extensions_over_ssh( this: &WeakEntity, - client: WeakEntity, + client: WeakEntity, cx: &mut AsyncApp, ) -> Result<()> { let extensions = this.update(cx, |this, _cx| { @@ -1765,8 +1765,8 @@ impl ExtensionStore { pub async fn update_ssh_clients(this: &WeakEntity, cx: &mut AsyncApp) -> Result<()> { let clients = this.update(cx, |this, _cx| { - this.ssh_clients.retain(|_k, v| v.upgrade().is_some()); - this.ssh_clients.values().cloned().collect::>() + this.remote_clients.retain(|_k, v| v.upgrade().is_some()); + this.remote_clients.values().cloned().collect::>() })?; for client in clients { @@ -1778,17 +1778,17 @@ impl ExtensionStore { anyhow::Ok(()) } - pub fn register_ssh_client(&mut self, client: Entity, cx: &mut Context) { + pub fn register_remote_client(&mut self, client: Entity, cx: &mut Context) { let connection_options = client.read(cx).connection_options(); let ssh_url = connection_options.ssh_url(); - if let Some(existing_client) = self.ssh_clients.get(&ssh_url) + if let Some(existing_client) = self.remote_clients.get(&ssh_url) && existing_client.upgrade().is_some() { return; } - self.ssh_clients.insert(ssh_url, client.downgrade()); + self.remote_clients.insert(ssh_url, client.downgrade()); self.ssh_registered_tx.unbounded_send(()).ok(); } } diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 75121523245cc9233fa4c66c9021d8a958524f6e..e2f8d55cf2a0c8f21a1ed6b3eab870d267173bba 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -1381,7 +1381,7 @@ impl PickerDelegate for FileFinderDelegate { project .worktree_for_id(history_item.project.worktree_id, cx) .is_some() - || ((project.is_local() || project.is_via_ssh()) + || ((project.is_local() || project.is_via_remote_server()) && history_item.absolute.is_some()) }), self.currently_opened_path.as_ref(), diff --git a/crates/language_tools/src/lsp_log.rs b/crates/language_tools/src/lsp_log.rs index d5206c1f264b3b49f504090154f2df6e4ddf55be..a71e434e5274392add0463830519834202b7ba58 100644 --- a/crates/language_tools/src/lsp_log.rs +++ b/crates/language_tools/src/lsp_log.rs @@ -222,7 +222,7 @@ pub fn init(cx: &mut App) { cx.observe_new(move |workspace: &mut Workspace, _, cx| { let project = workspace.project(); - if project.read(cx).is_local() || project.read(cx).is_via_ssh() { + if project.read(cx).is_local() || project.read(cx).is_via_remote_server() { log_store.update(cx, |store, cx| { store.add_project(project, cx); }); @@ -231,7 +231,7 @@ pub fn init(cx: &mut App) { let log_store = log_store.clone(); workspace.register_action(move |workspace, _: &OpenLanguageServerLogs, window, cx| { let project = workspace.project().read(cx); - if project.is_local() || project.is_via_ssh() { + if project.is_local() || project.is_via_remote_server() { let project = workspace.project().clone(); let log_store = log_store.clone(); get_or_create_tool( @@ -321,7 +321,7 @@ impl LogStore { .retain(|_, state| state.kind.project() != Some(&weak_project)); }), cx.subscribe(project, |this, project, event, cx| { - let server_kind = if project.read(cx).is_via_ssh() { + let server_kind = if project.read(cx).is_via_remote_server() { LanguageServerKind::Remote { project: project.downgrade(), } diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 10698cead8656885d0ea2d2f98ebd235e29fec1b..1521d012955050c932d2d2f797a25ab8f3c99d48 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -5102,9 +5102,9 @@ impl EventEmitter for OutlinePanel {} impl Render for OutlinePanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let (is_local, is_via_ssh) = self - .project - .read_with(cx, |project, _| (project.is_local(), project.is_via_ssh())); + let (is_local, is_via_ssh) = self.project.read_with(cx, |project, _| { + (project.is_local(), project.is_via_remote_server()) + }); let query = self.query(cx); let pinned = self.pinned; let settings = OutlinePanelSettings::get_global(cx); diff --git a/crates/project/src/debugger/dap_store.rs b/crates/project/src/debugger/dap_store.rs index 2906c32ff4f67ba733d4f0faf8f511d0c433ec91..d8c6d3acc1116e9a97b2f6ca3fc54ec098029cbe 100644 --- a/crates/project/src/debugger/dap_store.rs +++ b/crates/project/src/debugger/dap_store.rs @@ -5,11 +5,8 @@ use super::{ session::{self, Session, SessionStateEvent}, }; use crate::{ - InlayHint, InlayHintLabel, ProjectEnvironment, ResolveState, - debugger::session::SessionQuirks, - project_settings::ProjectSettings, - terminals::{SshCommand, wrap_for_ssh}, - worktree_store::WorktreeStore, + InlayHint, InlayHintLabel, ProjectEnvironment, ResolveState, debugger::session::SessionQuirks, + project_settings::ProjectSettings, worktree_store::WorktreeStore, }; use anyhow::{Context as _, Result, anyhow}; use async_trait::async_trait; @@ -34,7 +31,7 @@ use http_client::HttpClient; use language::{Buffer, LanguageToolchainStore, language_settings::InlayHintKind}; use node_runtime::NodeRuntime; -use remote::{SshInfo, SshRemoteClient, ssh_session::SshArgs}; +use remote::RemoteClient; use rpc::{ AnyProtoClient, TypedEnvelope, proto::{self}, @@ -68,7 +65,7 @@ pub enum DapStoreEvent { enum DapStoreMode { Local(LocalDapStore), - Ssh(SshDapStore), + Remote(RemoteDapStore), Collab, } @@ -80,8 +77,8 @@ pub struct LocalDapStore { toolchain_store: Arc, } -pub struct SshDapStore { - ssh_client: Entity, +pub struct RemoteDapStore { + remote_client: Entity, upstream_client: AnyProtoClient, upstream_project_id: u64, } @@ -147,16 +144,16 @@ impl DapStore { Self::new(mode, breakpoint_store, worktree_store, cx) } - pub fn new_ssh( + pub fn new_remote( project_id: u64, - ssh_client: Entity, + remote_client: Entity, breakpoint_store: Entity, worktree_store: Entity, cx: &mut Context, ) -> Self { - let mode = DapStoreMode::Ssh(SshDapStore { - upstream_client: ssh_client.read(cx).proto_client(), - ssh_client, + let mode = DapStoreMode::Remote(RemoteDapStore { + upstream_client: remote_client.read(cx).proto_client(), + remote_client, upstream_project_id: project_id, }); @@ -242,64 +239,51 @@ impl DapStore { Ok(binary) }) } - DapStoreMode::Ssh(ssh) => { - let request = ssh.upstream_client.request(proto::GetDebugAdapterBinary { - session_id: session_id.to_proto(), - project_id: ssh.upstream_project_id, - worktree_id: worktree.read(cx).id().to_proto(), - definition: Some(definition.to_proto()), - }); - let ssh_client = ssh.ssh_client.clone(); + DapStoreMode::Remote(remote) => { + let request = remote + .upstream_client + .request(proto::GetDebugAdapterBinary { + session_id: session_id.to_proto(), + project_id: remote.upstream_project_id, + worktree_id: worktree.read(cx).id().to_proto(), + definition: Some(definition.to_proto()), + }); + let remote = remote.remote_client.clone(); cx.spawn(async move |_, cx| { let response = request.await?; let binary = DebugAdapterBinary::from_proto(response)?; - let (mut ssh_command, envs, path_style, ssh_shell) = - ssh_client.read_with(cx, |ssh, _| { - let SshInfo { - args: SshArgs { arguments, envs }, - path_style, - shell, - } = ssh.ssh_info().context("SSH arguments not found")?; - anyhow::Ok(( - SshCommand { arguments }, - envs.unwrap_or_default(), - path_style, - shell, - )) - })??; - - let mut connection = None; - if let Some(c) = binary.connection { - let local_bind_addr = Ipv4Addr::LOCALHOST; - let port = - dap::transport::TcpTransport::unused_port(local_bind_addr).await?; - ssh_command.add_port_forwarding(port, c.host.to_string(), c.port); + let port_forwarding; + let connection; + if let Some(c) = binary.connection { + let host = Ipv4Addr::LOCALHOST; + let port = dap::transport::TcpTransport::unused_port(host).await?; + port_forwarding = Some((port, c.host.to_string(), c.port)); connection = Some(TcpArguments { port, - host: local_bind_addr, + host, timeout: c.timeout, }) + } else { + port_forwarding = None; + connection = None; } - let (program, args) = wrap_for_ssh( - &ssh_shell, - &ssh_command, - binary - .command - .as_ref() - .map(|command| (command, &binary.arguments)), - binary.cwd.as_deref(), - binary.envs, - None, - path_style, - ); + let command = remote.read_with(cx, |remote, _cx| { + remote.build_command( + binary.command, + &binary.arguments, + &binary.envs, + binary.cwd.map(|path| path.display().to_string()), + port_forwarding, + ) + })??; Ok(DebugAdapterBinary { - command: Some(program), - arguments: args, - envs, + command: Some(command.program), + arguments: command.args, + envs: command.env, cwd: None, connection, request_args: binary.request_args, @@ -365,9 +349,9 @@ impl DapStore { ))) } } - DapStoreMode::Ssh(ssh) => { - let request = ssh.upstream_client.request(proto::RunDebugLocators { - project_id: ssh.upstream_project_id, + DapStoreMode::Remote(remote) => { + let request = remote.upstream_client.request(proto::RunDebugLocators { + project_id: remote.upstream_project_id, build_command: Some(build_command.to_proto()), locator: locator_name.to_owned(), }); diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 5cf298a8bff1dbecbe5899020e4463ba658ed719..a1c0508c3ed5355685828487229967c83b59cbd3 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -44,7 +44,7 @@ use parking_lot::Mutex; use postage::stream::Stream as _; use rpc::{ AnyProtoClient, TypedEnvelope, - proto::{self, FromProto, SSH_PROJECT_ID, ToProto, git_reset, split_repository_update}, + proto::{self, FromProto, ToProto, git_reset, split_repository_update}, }; use serde::Deserialize; use std::{ @@ -141,14 +141,10 @@ enum GitStoreState { project_environment: Entity, fs: Arc, }, - Ssh { - upstream_client: AnyProtoClient, - upstream_project_id: ProjectId, - downstream: Option<(AnyProtoClient, ProjectId)>, - }, Remote { upstream_client: AnyProtoClient, - upstream_project_id: ProjectId, + upstream_project_id: u64, + downstream: Option<(AnyProtoClient, ProjectId)>, }, } @@ -355,7 +351,7 @@ impl GitStore { worktree_store: &Entity, buffer_store: Entity, upstream_client: AnyProtoClient, - project_id: ProjectId, + project_id: u64, cx: &mut Context, ) -> Self { Self::new( @@ -364,23 +360,6 @@ impl GitStore { GitStoreState::Remote { upstream_client, upstream_project_id: project_id, - }, - cx, - ) - } - - pub fn ssh( - worktree_store: &Entity, - buffer_store: Entity, - upstream_client: AnyProtoClient, - cx: &mut Context, - ) -> Self { - Self::new( - worktree_store.clone(), - buffer_store, - GitStoreState::Ssh { - upstream_client, - upstream_project_id: ProjectId(SSH_PROJECT_ID), downstream: None, }, cx, @@ -451,7 +430,7 @@ impl GitStore { pub fn shared(&mut self, project_id: u64, client: AnyProtoClient, cx: &mut Context) { match &mut self.state { - GitStoreState::Ssh { + GitStoreState::Remote { downstream: downstream_client, .. } => { @@ -527,9 +506,6 @@ impl GitStore { }), }); } - GitStoreState::Remote { .. } => { - debug_panic!("shared called on remote store"); - } } } @@ -541,15 +517,12 @@ impl GitStore { } => { downstream_client.take(); } - GitStoreState::Ssh { + GitStoreState::Remote { downstream: downstream_client, .. } => { downstream_client.take(); } - GitStoreState::Remote { .. } => { - debug_panic!("unshared called on remote store"); - } } self.shared_diffs.clear(); } @@ -1047,21 +1020,17 @@ impl GitStore { } => downstream_client .as_ref() .map(|state| (state.client.clone(), state.project_id)), - GitStoreState::Ssh { + GitStoreState::Remote { downstream: downstream_client, .. } => downstream_client.clone(), - GitStoreState::Remote { .. } => None, } } fn upstream_client(&self) -> Option { match &self.state { GitStoreState::Local { .. } => None, - GitStoreState::Ssh { - upstream_client, .. - } - | GitStoreState::Remote { + GitStoreState::Remote { upstream_client, .. } => Some(upstream_client.clone()), } @@ -1432,12 +1401,7 @@ impl GitStore { cx.background_executor() .spawn(async move { fs.git_init(&path, fallback_branch_name) }) } - GitStoreState::Ssh { - upstream_client, - upstream_project_id: project_id, - .. - } - | GitStoreState::Remote { + GitStoreState::Remote { upstream_client, upstream_project_id: project_id, .. @@ -1447,7 +1411,7 @@ impl GitStore { cx.background_executor().spawn(async move { client .request(proto::GitInit { - project_id: project_id.0, + project_id: project_id, abs_path: path.to_string_lossy().to_string(), fallback_branch_name, }) @@ -1471,13 +1435,18 @@ impl GitStore { cx.background_executor() .spawn(async move { fs.git_clone(&repo, &path).await }) } - GitStoreState::Ssh { + GitStoreState::Remote { upstream_client, upstream_project_id, .. } => { + if upstream_client.is_via_collab() { + return Task::ready(Err(anyhow!( + "Git Clone isn't supported for project guests" + ))); + } let request = upstream_client.request(proto::GitClone { - project_id: upstream_project_id.0, + project_id: *upstream_project_id, abs_path: path.to_string_lossy().to_string(), remote_repo: repo, }); @@ -1491,9 +1460,6 @@ impl GitStore { } }) } - GitStoreState::Remote { .. } => { - Task::ready(Err(anyhow!("Git Clone isn't supported for remote users"))) - } } } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 9fd4eed641a17bf317c7d69ef0afa0802c8ae276..9e3900198cbc9aa4845428235196763511c0751c 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -42,9 +42,7 @@ pub use manifest_tree::ManifestTree; use anyhow::{Context as _, Result, anyhow}; use buffer_store::{BufferStore, BufferStoreEvent}; -use client::{ - Client, Collaborator, PendingEntitySubscription, ProjectId, TypedEnvelope, UserStore, proto, -}; +use client::{Client, Collaborator, PendingEntitySubscription, TypedEnvelope, UserStore, proto}; use clock::ReplicaId; use dap::client::DebugAdapterClient; @@ -89,10 +87,10 @@ use node_runtime::NodeRuntime; use parking_lot::Mutex; pub use prettier_store::PrettierStore; use project_settings::{ProjectSettings, SettingsObserver, SettingsObserverEvent}; -use remote::{SshConnectionOptions, SshRemoteClient}; +use remote::{RemoteClient, SshConnectionOptions}; use rpc::{ AnyProtoClient, ErrorCode, - proto::{FromProto, LanguageServerPromptResponse, SSH_PROJECT_ID, ToProto}, + proto::{FromProto, LanguageServerPromptResponse, REMOTE_SERVER_PROJECT_ID, ToProto}, }; use search::{SearchInputKind, SearchQuery, SearchResult}; use search_history::SearchHistory; @@ -177,12 +175,12 @@ pub struct Project { dap_store: Entity, breakpoint_store: Entity, - client: Arc, + collab_client: Arc, join_project_response_message_id: u32, task_store: Entity, user_store: Entity, fs: Arc, - ssh_client: Option>, + remote_client: Option>, client_state: ProjectClientState, git_store: Entity, collaborators: HashMap, @@ -1154,12 +1152,12 @@ impl Project { active_entry: None, snippets, languages, - client, + collab_client: client, task_store, user_store, settings_observer, fs, - ssh_client: None, + remote_client: None, breakpoint_store, dap_store, @@ -1183,8 +1181,8 @@ impl Project { }) } - pub fn ssh( - ssh: Entity, + pub fn remote( + remote: Entity, client: Arc, node: NodeRuntime, user_store: Entity, @@ -1200,10 +1198,15 @@ impl Project { let snippets = SnippetProvider::new(fs.clone(), BTreeSet::from_iter([global_snippets_dir]), cx); - let (ssh_proto, path_style) = - ssh.read_with(cx, |ssh, _| (ssh.proto_client(), ssh.path_style())); + let (remote_proto, path_style) = + remote.read_with(cx, |remote, _| (remote.proto_client(), remote.path_style())); let worktree_store = cx.new(|_| { - WorktreeStore::remote(false, ssh_proto.clone(), SSH_PROJECT_ID, path_style) + WorktreeStore::remote( + false, + remote_proto.clone(), + REMOTE_SERVER_PROJECT_ID, + path_style, + ) }); cx.subscribe(&worktree_store, Self::on_worktree_store_event) .detach(); @@ -1215,31 +1218,32 @@ impl Project { let buffer_store = cx.new(|cx| { BufferStore::remote( worktree_store.clone(), - ssh.read(cx).proto_client(), - SSH_PROJECT_ID, + remote.read(cx).proto_client(), + REMOTE_SERVER_PROJECT_ID, cx, ) }); let image_store = cx.new(|cx| { ImageStore::remote( worktree_store.clone(), - ssh.read(cx).proto_client(), - SSH_PROJECT_ID, + remote.read(cx).proto_client(), + REMOTE_SERVER_PROJECT_ID, cx, ) }); cx.subscribe(&buffer_store, Self::on_buffer_store_event) .detach(); - let toolchain_store = cx - .new(|cx| ToolchainStore::remote(SSH_PROJECT_ID, ssh.read(cx).proto_client(), cx)); + let toolchain_store = cx.new(|cx| { + ToolchainStore::remote(REMOTE_SERVER_PROJECT_ID, remote.read(cx).proto_client(), cx) + }); let task_store = cx.new(|cx| { TaskStore::remote( fs.clone(), buffer_store.downgrade(), worktree_store.clone(), toolchain_store.read(cx).as_language_toolchain_store(), - ssh.read(cx).proto_client(), - SSH_PROJECT_ID, + remote.read(cx).proto_client(), + REMOTE_SERVER_PROJECT_ID, cx, ) }); @@ -1262,8 +1266,8 @@ impl Project { buffer_store.clone(), worktree_store.clone(), languages.clone(), - ssh_proto.clone(), - SSH_PROJECT_ID, + remote_proto.clone(), + REMOTE_SERVER_PROJECT_ID, fs.clone(), cx, ) @@ -1271,12 +1275,12 @@ impl Project { cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach(); let breakpoint_store = - cx.new(|_| BreakpointStore::remote(SSH_PROJECT_ID, ssh_proto.clone())); + cx.new(|_| BreakpointStore::remote(REMOTE_SERVER_PROJECT_ID, remote_proto.clone())); let dap_store = cx.new(|cx| { - DapStore::new_ssh( - SSH_PROJECT_ID, - ssh.clone(), + DapStore::new_remote( + REMOTE_SERVER_PROJECT_ID, + remote.clone(), breakpoint_store.clone(), worktree_store.clone(), cx, @@ -1284,10 +1288,16 @@ impl Project { }); let git_store = cx.new(|cx| { - GitStore::ssh(&worktree_store, buffer_store.clone(), ssh_proto.clone(), cx) + GitStore::remote( + &worktree_store, + buffer_store.clone(), + remote_proto.clone(), + REMOTE_SERVER_PROJECT_ID, + cx, + ) }); - cx.subscribe(&ssh, Self::on_ssh_event).detach(); + cx.subscribe(&remote, Self::on_remote_client_event).detach(); let this = Self { buffer_ordered_messages_tx: tx, @@ -1306,11 +1316,13 @@ impl Project { _subscriptions: vec![ cx.on_release(Self::release), cx.on_app_quit(|this, cx| { - let shutdown = this.ssh_client.take().and_then(|client| { - client.read(cx).shutdown_processes( - Some(proto::ShutdownRemoteServer {}), - cx.background_executor().clone(), - ) + let shutdown = this.remote_client.take().and_then(|client| { + client.update(cx, |client, cx| { + client.shutdown_processes( + Some(proto::ShutdownRemoteServer {}), + cx.background_executor().clone(), + ) + }) }); cx.background_executor().spawn(async move { @@ -1323,12 +1335,12 @@ impl Project { active_entry: None, snippets, languages, - client, + collab_client: client, task_store, user_store, settings_observer, fs, - ssh_client: Some(ssh.clone()), + remote_client: Some(remote.clone()), buffers_needing_diff: Default::default(), git_diff_debouncer: DebouncedDelay::new(), terminals: Terminals { @@ -1346,52 +1358,34 @@ impl Project { agent_location: None, }; - // ssh -> local machine handlers - ssh_proto.subscribe_to_entity(SSH_PROJECT_ID, &cx.entity()); - ssh_proto.subscribe_to_entity(SSH_PROJECT_ID, &this.buffer_store); - ssh_proto.subscribe_to_entity(SSH_PROJECT_ID, &this.worktree_store); - ssh_proto.subscribe_to_entity(SSH_PROJECT_ID, &this.lsp_store); - ssh_proto.subscribe_to_entity(SSH_PROJECT_ID, &this.dap_store); - ssh_proto.subscribe_to_entity(SSH_PROJECT_ID, &this.settings_observer); - ssh_proto.subscribe_to_entity(SSH_PROJECT_ID, &this.git_store); - - ssh_proto.add_entity_message_handler(Self::handle_create_buffer_for_peer); - ssh_proto.add_entity_message_handler(Self::handle_update_worktree); - ssh_proto.add_entity_message_handler(Self::handle_update_project); - ssh_proto.add_entity_message_handler(Self::handle_toast); - ssh_proto.add_entity_request_handler(Self::handle_language_server_prompt_request); - ssh_proto.add_entity_message_handler(Self::handle_hide_toast); - ssh_proto.add_entity_request_handler(Self::handle_update_buffer_from_ssh); - BufferStore::init(&ssh_proto); - LspStore::init(&ssh_proto); - SettingsObserver::init(&ssh_proto); - TaskStore::init(Some(&ssh_proto)); - ToolchainStore::init(&ssh_proto); - DapStore::init(&ssh_proto, cx); - GitStore::init(&ssh_proto); + // remote server -> local machine handlers + remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &cx.entity()); + remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.buffer_store); + remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.worktree_store); + remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.lsp_store); + remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.dap_store); + remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.settings_observer); + remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.git_store); + + remote_proto.add_entity_message_handler(Self::handle_create_buffer_for_peer); + remote_proto.add_entity_message_handler(Self::handle_update_worktree); + remote_proto.add_entity_message_handler(Self::handle_update_project); + remote_proto.add_entity_message_handler(Self::handle_toast); + remote_proto.add_entity_request_handler(Self::handle_language_server_prompt_request); + remote_proto.add_entity_message_handler(Self::handle_hide_toast); + remote_proto.add_entity_request_handler(Self::handle_update_buffer_from_remote_server); + BufferStore::init(&remote_proto); + LspStore::init(&remote_proto); + SettingsObserver::init(&remote_proto); + TaskStore::init(Some(&remote_proto)); + ToolchainStore::init(&remote_proto); + DapStore::init(&remote_proto, cx); + GitStore::init(&remote_proto); this }) } - pub async fn remote( - remote_id: u64, - client: Arc, - user_store: Entity, - languages: Arc, - fs: Arc, - cx: AsyncApp, - ) -> Result> { - let project = - Self::in_room(remote_id, client, user_store, languages, fs, cx.clone()).await?; - cx.update(|cx| { - connection_manager::Manager::global(cx).update(cx, |manager, cx| { - manager.maintain_project_connection(&project, cx) - }) - })?; - Ok(project) - } - pub async fn in_room( remote_id: u64, client: Arc, @@ -1523,7 +1517,7 @@ impl Project { &worktree_store, buffer_store.clone(), client.clone().into(), - ProjectId(remote_id), + remote_id, cx, ) })?; @@ -1574,11 +1568,11 @@ impl Project { task_store, snippets, fs, - ssh_client: None, + remote_client: None, settings_observer: settings_observer.clone(), client_subscriptions: Default::default(), _subscriptions: vec![cx.on_release(Self::release)], - client: client.clone(), + collab_client: client.clone(), client_state: ProjectClientState::Remote { sharing_has_stopped: false, capability: Capability::ReadWrite, @@ -1661,11 +1655,13 @@ impl Project { } fn release(&mut self, cx: &mut App) { - if let Some(client) = self.ssh_client.take() { - let shutdown = client.read(cx).shutdown_processes( - Some(proto::ShutdownRemoteServer {}), - cx.background_executor().clone(), - ); + if let Some(client) = self.remote_client.take() { + let shutdown = client.update(cx, |client, cx| { + client.shutdown_processes( + Some(proto::ShutdownRemoteServer {}), + cx.background_executor().clone(), + ) + }); cx.background_spawn(async move { if let Some(shutdown) = shutdown { @@ -1681,7 +1677,7 @@ impl Project { let _ = self.unshare_internal(cx); } ProjectClientState::Remote { remote_id, .. } => { - let _ = self.client.send(proto::LeaveProject { + let _ = self.collab_client.send(proto::LeaveProject { project_id: *remote_id, }); self.disconnected_from_host_internal(cx); @@ -1808,11 +1804,11 @@ impl Project { } pub fn client(&self) -> Arc { - self.client.clone() + self.collab_client.clone() } - pub fn ssh_client(&self) -> Option> { - self.ssh_client.clone() + pub fn remote_client(&self) -> Option> { + self.remote_client.clone() } pub fn user_store(&self) -> Entity { @@ -1893,30 +1889,30 @@ impl Project { if self.is_local() { return true; } - if self.is_via_ssh() { + if self.is_via_remote_server() { return true; } false } - pub fn ssh_connection_state(&self, cx: &App) -> Option { - self.ssh_client + pub fn remote_connection_state(&self, cx: &App) -> Option { + self.remote_client .as_ref() - .map(|ssh| ssh.read(cx).connection_state()) + .map(|remote| remote.read(cx).connection_state()) } - pub fn ssh_connection_options(&self, cx: &App) -> Option { - self.ssh_client + pub fn remote_connection_options(&self, cx: &App) -> Option { + self.remote_client .as_ref() - .map(|ssh| ssh.read(cx).connection_options()) + .map(|remote| remote.read(cx).connection_options()) } pub fn replica_id(&self) -> ReplicaId { match self.client_state { ProjectClientState::Remote { replica_id, .. } => replica_id, _ => { - if self.ssh_client.is_some() { + if self.remote_client.is_some() { 1 } else { 0 @@ -2220,55 +2216,55 @@ impl Project { ); self.client_subscriptions.extend([ - self.client + self.collab_client .subscribe_to_entity(project_id)? .set_entity(&cx.entity(), &cx.to_async()), - self.client + self.collab_client .subscribe_to_entity(project_id)? .set_entity(&self.worktree_store, &cx.to_async()), - self.client + self.collab_client .subscribe_to_entity(project_id)? .set_entity(&self.buffer_store, &cx.to_async()), - self.client + self.collab_client .subscribe_to_entity(project_id)? .set_entity(&self.lsp_store, &cx.to_async()), - self.client + self.collab_client .subscribe_to_entity(project_id)? .set_entity(&self.settings_observer, &cx.to_async()), - self.client + self.collab_client .subscribe_to_entity(project_id)? .set_entity(&self.dap_store, &cx.to_async()), - self.client + self.collab_client .subscribe_to_entity(project_id)? .set_entity(&self.breakpoint_store, &cx.to_async()), - self.client + self.collab_client .subscribe_to_entity(project_id)? .set_entity(&self.git_store, &cx.to_async()), ]); self.buffer_store.update(cx, |buffer_store, cx| { - buffer_store.shared(project_id, self.client.clone().into(), cx) + buffer_store.shared(project_id, self.collab_client.clone().into(), cx) }); self.worktree_store.update(cx, |worktree_store, cx| { - worktree_store.shared(project_id, self.client.clone().into(), cx); + worktree_store.shared(project_id, self.collab_client.clone().into(), cx); }); self.lsp_store.update(cx, |lsp_store, cx| { - lsp_store.shared(project_id, self.client.clone().into(), cx) + lsp_store.shared(project_id, self.collab_client.clone().into(), cx) }); self.breakpoint_store.update(cx, |breakpoint_store, _| { - breakpoint_store.shared(project_id, self.client.clone().into()) + breakpoint_store.shared(project_id, self.collab_client.clone().into()) }); self.dap_store.update(cx, |dap_store, cx| { - dap_store.shared(project_id, self.client.clone().into(), cx); + dap_store.shared(project_id, self.collab_client.clone().into(), cx); }); self.task_store.update(cx, |task_store, cx| { - task_store.shared(project_id, self.client.clone().into(), cx); + task_store.shared(project_id, self.collab_client.clone().into(), cx); }); self.settings_observer.update(cx, |settings_observer, cx| { - settings_observer.shared(project_id, self.client.clone().into(), cx) + settings_observer.shared(project_id, self.collab_client.clone().into(), cx) }); self.git_store.update(cx, |git_store, cx| { - git_store.shared(project_id, self.client.clone().into(), cx) + git_store.shared(project_id, self.collab_client.clone().into(), cx) }); self.client_state = ProjectClientState::Shared { @@ -2293,7 +2289,7 @@ impl Project { }); if let Some(remote_id) = self.remote_id() { self.git_store.update(cx, |git_store, cx| { - git_store.shared(remote_id, self.client.clone().into(), cx) + git_store.shared(remote_id, self.collab_client.clone().into(), cx) }); } cx.emit(Event::Reshared); @@ -2370,7 +2366,7 @@ impl Project { git_store.unshared(cx); }); - self.client + self.collab_client .send(proto::UnshareProject { project_id: remote_id, }) @@ -2437,15 +2433,17 @@ impl Project { sharing_has_stopped, .. } => *sharing_has_stopped, - ProjectClientState::Local if self.is_via_ssh() => self.ssh_is_disconnected(cx), + ProjectClientState::Local if self.is_via_remote_server() => { + self.remote_client_is_disconnected(cx) + } _ => false, } } - fn ssh_is_disconnected(&self, cx: &App) -> bool { - self.ssh_client + fn remote_client_is_disconnected(&self, cx: &App) -> bool { + self.remote_client .as_ref() - .map(|ssh| ssh.read(cx).is_disconnected()) + .map(|remote| remote.read(cx).is_disconnected()) .unwrap_or(false) } @@ -2463,16 +2461,16 @@ impl Project { pub fn is_local(&self) -> bool { match &self.client_state { ProjectClientState::Local | ProjectClientState::Shared { .. } => { - self.ssh_client.is_none() + self.remote_client.is_none() } ProjectClientState::Remote { .. } => false, } } - pub fn is_via_ssh(&self) -> bool { + pub fn is_via_remote_server(&self) -> bool { match &self.client_state { ProjectClientState::Local | ProjectClientState::Shared { .. } => { - self.ssh_client.is_some() + self.remote_client.is_some() } ProjectClientState::Remote { .. } => false, } @@ -2496,7 +2494,7 @@ impl Project { language: Option>, cx: &mut Context, ) -> Entity { - if self.is_via_collab() || self.is_via_ssh() { + if self.is_via_collab() || self.is_via_remote_server() { panic!("called create_local_buffer on a remote project") } self.buffer_store.update(cx, |buffer_store, cx| { @@ -2620,10 +2618,10 @@ impl Project { ) -> Task>> { if let Some(buffer) = self.buffer_for_id(id, cx) { Task::ready(Ok(buffer)) - } else if self.is_local() || self.is_via_ssh() { + } else if self.is_local() || self.is_via_remote_server() { Task::ready(Err(anyhow!("buffer {id} does not exist"))) } else if let Some(project_id) = self.remote_id() { - let request = self.client.request(proto::OpenBufferById { + let request = self.collab_client.request(proto::OpenBufferById { project_id, id: id.into(), }); @@ -2741,7 +2739,7 @@ impl Project { for (buffer_id, operations) in operations_by_buffer_id.drain() { let request = this.read_with(cx, |this, _| { let project_id = this.remote_id()?; - Some(this.client.request(proto::UpdateBuffer { + Some(this.collab_client.request(proto::UpdateBuffer { buffer_id: buffer_id.into(), project_id, operations, @@ -2808,7 +2806,7 @@ impl Project { project.read_with(cx, |project, _| { if let Some(project_id) = project.remote_id() { project - .client + .collab_client .send(proto::UpdateLanguageServer { project_id, server_name: name.map(|name| String::from(name.0)), @@ -2846,8 +2844,8 @@ impl Project { self.register_buffer(buffer, cx).log_err(); } BufferStoreEvent::BufferDropped(buffer_id) => { - if let Some(ref ssh_client) = self.ssh_client { - ssh_client + if let Some(ref remote_client) = self.remote_client { + remote_client .read(cx) .proto_client() .send(proto::CloseBuffer { @@ -2995,16 +2993,14 @@ impl Project { } } - fn on_ssh_event( + fn on_remote_client_event( &mut self, - _: Entity, - event: &remote::SshRemoteEvent, + _: Entity, + event: &remote::RemoteClientEvent, cx: &mut Context, ) { match event { - remote::SshRemoteEvent::Disconnected => { - // if self.is_via_ssh() { - // self.collaborators.clear(); + remote::RemoteClientEvent::Disconnected => { self.worktree_store.update(cx, |store, cx| { store.disconnected_from_host(cx); }); @@ -3110,8 +3106,9 @@ impl Project { } fn on_worktree_released(&mut self, id_to_remove: WorktreeId, cx: &mut Context) { - if let Some(ssh) = &self.ssh_client { - ssh.read(cx) + if let Some(remote) = &self.remote_client { + remote + .read(cx) .proto_client() .send(proto::RemoveWorktree { worktree_id: id_to_remove.to_proto(), @@ -3144,8 +3141,9 @@ impl Project { } => { let operation = language::proto::serialize_operation(operation); - if let Some(ssh) = &self.ssh_client { - ssh.read(cx) + if let Some(remote) = &self.remote_client { + remote + .read(cx) .proto_client() .send(proto::UpdateBuffer { project_id: 0, @@ -3552,16 +3550,16 @@ impl Project { pub fn open_server_settings(&mut self, cx: &mut Context) -> Task>> { let guard = self.retain_remotely_created_models(cx); - let Some(ssh_client) = self.ssh_client.as_ref() else { + let Some(remote) = self.remote_client.as_ref() else { return Task::ready(Err(anyhow!("not an ssh project"))); }; - let proto_client = ssh_client.read(cx).proto_client(); + let proto_client = remote.read(cx).proto_client(); cx.spawn(async move |project, cx| { let buffer = proto_client .request(proto::OpenServerSettings { - project_id: SSH_PROJECT_ID, + project_id: REMOTE_SERVER_PROJECT_ID, }) .await?; @@ -3948,10 +3946,11 @@ impl Project { ) -> Receiver> { let (tx, rx) = smol::channel::unbounded(); - let (client, remote_id): (AnyProtoClient, _) = if let Some(ssh_client) = &self.ssh_client { + let (client, remote_id): (AnyProtoClient, _) = if let Some(ssh_client) = &self.remote_client + { (ssh_client.read(cx).proto_client(), 0) } else if let Some(remote_id) = self.remote_id() { - (self.client.clone().into(), remote_id) + (self.collab_client.clone().into(), remote_id) } else { return rx; }; @@ -4095,14 +4094,14 @@ impl Project { is_dir: metadata.is_dir, }) }) - } else if let Some(ssh_client) = self.ssh_client.as_ref() { + } else if let Some(ssh_client) = self.remote_client.as_ref() { let path_style = ssh_client.read(cx).path_style(); let request_path = RemotePathBuf::from_str(path, path_style); let request = ssh_client .read(cx) .proto_client() .request(proto::GetPathMetadata { - project_id: SSH_PROJECT_ID, + project_id: REMOTE_SERVER_PROJECT_ID, path: request_path.to_proto(), }); cx.background_spawn(async move { @@ -4202,10 +4201,10 @@ impl Project { ) -> Task>> { if self.is_local() { DirectoryLister::Local(cx.entity(), self.fs.clone()).list_directory(query, cx) - } else if let Some(session) = self.ssh_client.as_ref() { + } else if let Some(session) = self.remote_client.as_ref() { let path_buf = PathBuf::from(query); let request = proto::ListRemoteDirectory { - dev_server_id: SSH_PROJECT_ID, + dev_server_id: REMOTE_SERVER_PROJECT_ID, path: path_buf.to_proto(), config: Some(proto::ListRemoteDirectoryConfig { is_dir: true }), }; @@ -4420,7 +4419,7 @@ impl Project { mut cx: AsyncApp, ) -> Result<()> { this.update(&mut cx, |this, cx| { - if this.is_local() || this.is_via_ssh() { + if this.is_local() || this.is_via_remote_server() { this.unshare(cx)?; } else { this.disconnected_from_host(cx); @@ -4629,7 +4628,7 @@ impl Project { })? } - async fn handle_update_buffer_from_ssh( + async fn handle_update_buffer_from_remote_server( this: Entity, envelope: TypedEnvelope, cx: AsyncApp, @@ -4638,7 +4637,7 @@ impl Project { if let Some(remote_id) = this.remote_id() { let mut payload = envelope.payload.clone(); payload.project_id = remote_id; - cx.background_spawn(this.client.request(payload)) + cx.background_spawn(this.collab_client.request(payload)) .detach_and_log_err(cx); } this.buffer_store.clone() @@ -4652,9 +4651,9 @@ impl Project { cx: AsyncApp, ) -> Result { let buffer_store = this.read_with(&cx, |this, cx| { - if let Some(ssh) = &this.ssh_client { + if let Some(ssh) = &this.remote_client { let mut payload = envelope.payload.clone(); - payload.project_id = SSH_PROJECT_ID; + payload.project_id = REMOTE_SERVER_PROJECT_ID; cx.background_spawn(ssh.read(cx).proto_client().request(payload)) .detach_and_log_err(cx); } @@ -4704,7 +4703,7 @@ impl Project { mut cx: AsyncApp, ) -> Result { let response = this.update(&mut cx, |this, cx| { - let client = this.client.clone(); + let client = this.collab_client.clone(); this.buffer_store.update(cx, |this, cx| { this.handle_synchronize_buffers(envelope, cx, client) }) @@ -4841,7 +4840,7 @@ impl Project { } }; - let client = self.client.clone(); + let client = self.collab_client.clone(); cx.spawn(async move |this, cx| { let (buffers, incomplete_buffer_ids) = this.update(cx, |this, cx| { this.buffer_store.read(cx).buffer_version_info(cx) diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs index b009b357fe8eef1a7df61117857251178b437659..7e1be67e21380e8b1ae751600b3553a527067e46 100644 --- a/crates/project/src/terminals.rs +++ b/crates/project/src/terminals.rs @@ -2,13 +2,11 @@ use crate::{Project, ProjectPath}; use anyhow::{Context as _, Result}; use collections::HashMap; use gpui::{App, AppContext as _, Context, Entity, Task, WeakEntity}; -use itertools::Itertools; use language::LanguageName; -use remote::{SshInfo, ssh_session::SshArgs}; +use remote::RemoteClient; use settings::{Settings, SettingsLocation}; use smol::channel::bounded; use std::{ - borrow::Cow, env::{self}, path::{Path, PathBuf}, sync::Arc, @@ -18,10 +16,7 @@ use terminal::{ TaskState, TaskStatus, Terminal, TerminalBuilder, terminal_settings::{self, ActivateScript, TerminalSettings, VenvSettings}, }; -use util::{ - ResultExt, - paths::{PathStyle, RemotePathBuf}, -}; +use util::{ResultExt, paths::RemotePathBuf}; /// The directory inside a Python virtual environment that contains executables const PYTHON_VENV_BIN_DIR: &str = if cfg!(target_os = "windows") { @@ -44,29 +39,6 @@ pub enum TerminalKind { Task(SpawnInTerminal), } -/// SshCommand describes how to connect to a remote server -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SshCommand { - pub arguments: Vec, -} - -impl SshCommand { - pub fn add_port_forwarding(&mut self, local_port: u16, host: String, remote_port: u16) { - self.arguments.push("-L".to_string()); - self.arguments - .push(format!("{}:{}:{}", local_port, host, remote_port)); - } -} - -#[derive(Debug)] -pub struct SshDetails { - pub host: String, - pub ssh_command: SshCommand, - pub envs: Option>, - pub path_style: PathStyle, - pub shell: String, -} - impl Project { pub fn active_project_directory(&self, cx: &App) -> Option> { self.active_entry() @@ -86,28 +58,6 @@ impl Project { } } - pub fn ssh_details(&self, cx: &App) -> Option { - if let Some(ssh_client) = &self.ssh_client { - let ssh_client = ssh_client.read(cx); - if let Some(SshInfo { - args: SshArgs { arguments, envs }, - path_style, - shell, - }) = ssh_client.ssh_info() - { - return Some(SshDetails { - host: ssh_client.connection_options().host, - ssh_command: SshCommand { arguments }, - envs, - path_style, - shell, - }); - } - } - - None - } - pub fn create_terminal( &mut self, kind: TerminalKind, @@ -168,14 +118,14 @@ impl Project { TerminalSettings::get(settings_location, cx) } - pub fn exec_in_shell(&self, command: String, cx: &App) -> std::process::Command { + pub fn exec_in_shell(&self, command: String, cx: &App) -> Result { let path = self.first_project_directory(cx); - let ssh_details = self.ssh_details(cx); + let remote_client = self.remote_client.as_ref(); let settings = self.terminal_settings(&path, cx).clone(); - - let builder = - ShellBuilder::new(ssh_details.as_ref().map(|ssh| &*ssh.shell), &settings.shell) - .non_interactive(); + let remote_shell = remote_client + .as_ref() + .and_then(|remote_client| remote_client.read(cx).shell()); + let builder = ShellBuilder::new(remote_shell.as_deref(), &settings.shell).non_interactive(); let (command, args) = builder.build(Some(command), &Vec::new()); let mut env = self @@ -185,29 +135,16 @@ impl Project { .unwrap_or_default(); env.extend(settings.env); - match self.ssh_details(cx) { - Some(SshDetails { - ssh_command, - envs, - path_style, - shell, - .. - }) => { - let (command, args) = wrap_for_ssh( - &shell, - &ssh_command, - Some((&command, &args)), - path.as_deref(), - env, - None, - path_style, - ); - let mut command = std::process::Command::new(command); - command.args(args); - if let Some(envs) = envs { - command.envs(envs); - } - command + match remote_client { + Some(remote_client) => { + let command_template = + remote_client + .read(cx) + .build_command(Some(command), &args, &env, None, None)?; + let mut command = std::process::Command::new(command_template.program); + command.args(command_template.args); + command.envs(command_template.env); + Ok(command) } None => { let mut command = std::process::Command::new(command); @@ -216,7 +153,7 @@ impl Project { if let Some(path) = path { command.current_dir(path); } - command + Ok(command) } } } @@ -227,13 +164,13 @@ impl Project { python_venv_directory: Option, cx: &mut Context, ) -> Result> { - let this = &mut *self; - let ssh_details = this.ssh_details(cx); + let is_via_remote = self.remote_client.is_some(); + let path: Option> = match &kind { TerminalKind::Shell(path) => path.as_ref().map(|path| Arc::from(path.as_ref())), TerminalKind::Task(spawn_task) => { if let Some(cwd) = &spawn_task.cwd { - if ssh_details.is_some() { + if is_via_remote { Some(Arc::from(cwd.as_ref())) } else { let cwd = cwd.to_string_lossy(); @@ -241,16 +178,14 @@ impl Project { Some(Arc::from(Path::new(tilde_substituted.as_ref()))) } } else { - this.active_project_directory(cx) + self.active_project_directory(cx) } } }; - let is_ssh_terminal = ssh_details.is_some(); - let mut settings_location = None; if let Some(path) = path.as_ref() - && let Some((worktree, _)) = this.find_worktree(path, cx) + && let Some((worktree, _)) = self.find_worktree(path, cx) { settings_location = Some(SettingsLocation { worktree_id: worktree.read(cx).id(), @@ -262,7 +197,7 @@ impl Project { let (completion_tx, completion_rx) = bounded(1); // Start with the environment that we might have inherited from the Zed CLI. - let mut env = this + let mut env = self .environment .read(cx) .get_cli_environment() @@ -271,14 +206,17 @@ impl Project { // precedence. env.extend(settings.env); - let local_path = if is_ssh_terminal { None } else { path.clone() }; + let local_path = if is_via_remote { None } else { path.clone() }; let mut python_venv_activate_command = Task::ready(None); - let (spawn_task, shell) = match kind { + let remote_client = self.remote_client.clone(); + let spawn_task; + let shell; + match kind { TerminalKind::Shell(_) => { if let Some(python_venv_directory) = &python_venv_directory { - python_venv_activate_command = this.python_activate_command( + python_venv_activate_command = self.python_activate_command( python_venv_directory, &settings.detect_venv, &settings.shell, @@ -286,63 +224,16 @@ impl Project { ); } - match ssh_details { - Some(SshDetails { - host, - ssh_command, - envs, - path_style, - shell, - }) => { - log::debug!("Connecting to a remote server: {ssh_command:?}"); - - // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed - // to properly display colors. - // We do not have the luxury of assuming the host has it installed, - // so we set it to a default that does not break the highlighting via ssh. - env.entry("TERM".to_string()) - .or_insert_with(|| "xterm-256color".to_string()); - - let (program, args) = wrap_for_ssh( - &shell, - &ssh_command, - None, - path.as_deref(), - env, - None, - path_style, - ); - env = HashMap::default(); - if let Some(envs) = envs { - env.extend(envs); - } - ( - Option::::None, - Shell::WithArguments { - program, - args, - title_override: Some(format!("{} — Terminal", host).into()), - }, - ) + spawn_task = None; + shell = match remote_client { + Some(remote_client) => { + create_remote_shell(None, &mut env, path, remote_client, cx)? } - None => (None, settings.shell), - } + None => settings.shell, + }; } - TerminalKind::Task(spawn_task) => { - let task_state = Some(TaskState { - id: spawn_task.id, - full_label: spawn_task.full_label, - label: spawn_task.label, - command_label: spawn_task.command_label, - hide: spawn_task.hide, - status: TaskStatus::Running, - show_summary: spawn_task.show_summary, - show_command: spawn_task.show_command, - show_rerun: spawn_task.show_rerun, - completion_rx, - }); - - env.extend(spawn_task.env); + TerminalKind::Task(task) => { + env.extend(task.env); if let Some(venv_path) = &python_venv_directory { env.insert( @@ -351,41 +242,38 @@ impl Project { ); } - match ssh_details { - Some(SshDetails { - host, - ssh_command, - envs, - path_style, - shell, - }) => { - log::debug!("Connecting to a remote server: {ssh_command:?}"); - env.entry("TERM".to_string()) - .or_insert_with(|| "xterm-256color".to_string()); - let (program, args) = wrap_for_ssh( - &shell, - &ssh_command, - spawn_task - .command - .as_ref() - .map(|command| (command, &spawn_task.args)), - path.as_deref(), - env, - python_venv_directory.as_deref(), - path_style, - ); - env = HashMap::default(); - if let Some(envs) = envs { - env.extend(envs); + spawn_task = Some(TaskState { + id: task.id, + full_label: task.full_label, + label: task.label, + command_label: task.command_label, + hide: task.hide, + status: TaskStatus::Running, + show_summary: task.show_summary, + show_command: task.show_command, + show_rerun: task.show_rerun, + completion_rx, + }); + shell = match remote_client { + Some(remote_client) => { + let path_style = remote_client.read(cx).path_style(); + if let Some(venv_directory) = &python_venv_directory + && let Ok(str) = + shlex::try_quote(venv_directory.to_string_lossy().as_ref()) + { + let path = + RemotePathBuf::new(PathBuf::from(str.to_string()), path_style) + .to_string(); + env.insert("PATH".into(), format!("{}:$PATH ", path)); } - ( - task_state, - Shell::WithArguments { - program, - args, - title_override: Some(format!("{} — Terminal", host).into()), - }, - ) + + create_remote_shell( + task.command.as_ref().map(|command| (command, &task.args)), + &mut env, + path, + remote_client, + cx, + )? } None => { if let Some(venv_path) = &python_venv_directory { @@ -393,18 +281,17 @@ impl Project { .log_err(); } - let shell = if let Some(program) = spawn_task.command { + if let Some(program) = task.command { Shell::WithArguments { program, - args: spawn_task.args, + args: task.args, title_override: None, } } else { Shell::System - }; - (task_state, shell) + } } - } + }; } }; TerminalBuilder::new( @@ -416,7 +303,7 @@ impl Project { settings.cursor_shape.unwrap_or_default(), settings.alternate_scroll, settings.max_scroll_history_lines, - is_ssh_terminal, + is_via_remote, cx.entity_id().as_u64(), completion_tx, cx, @@ -424,7 +311,7 @@ impl Project { .map(|builder| { let terminal_handle = cx.new(|cx| builder.subscribe(cx)); - this.terminals + self.terminals .local_handles .push(terminal_handle.downgrade()); @@ -442,7 +329,7 @@ impl Project { }) .detach(); - this.activate_python_virtual_environment( + self.activate_python_virtual_environment( python_venv_activate_command, &terminal_handle, cx, @@ -652,62 +539,42 @@ impl Project { } } -pub fn wrap_for_ssh( - shell: &str, - ssh_command: &SshCommand, - command: Option<(&String, &Vec)>, - path: Option<&Path>, - env: HashMap, - venv_directory: Option<&Path>, - path_style: PathStyle, -) -> (String, Vec) { - let to_run = if let Some((command, args)) = command { - let command: Option> = shlex::try_quote(command).ok(); - let args = args.iter().filter_map(|arg| shlex::try_quote(arg).ok()); - command.into_iter().chain(args).join(" ") - } else { - format!("exec {shell} -l") - }; - - let mut env_changes = String::new(); - for (k, v) in env.iter() { - if let Some((k, v)) = shlex::try_quote(k).ok().zip(shlex::try_quote(v).ok()) { - env_changes.push_str(&format!("{}={} ", k, v)); - } - } - if let Some(venv_directory) = venv_directory - && let Ok(str) = shlex::try_quote(venv_directory.to_string_lossy().as_ref()) - { - let path = RemotePathBuf::new(PathBuf::from(str.to_string()), path_style).to_string(); - env_changes.push_str(&format!("PATH={}:$PATH ", path)); - } - - let commands = if let Some(path) = path { - let path = RemotePathBuf::new(path.to_path_buf(), path_style).to_string(); - // shlex will wrap the command in single quotes (''), disabling ~ expansion, - // replace ith with something that works - let tilde_prefix = "~/"; - if path.starts_with(tilde_prefix) { - let trimmed_path = path - .trim_start_matches("/") - .trim_start_matches("~") - .trim_start_matches("/"); - - format!("cd \"$HOME/{trimmed_path}\"; {env_changes} {to_run}") - } else { - format!("cd \"{path}\"; {env_changes} {to_run}") - } - } else { - format!("cd; {env_changes} {to_run}") +fn create_remote_shell( + spawn_command: Option<(&String, &Vec)>, + env: &mut HashMap, + working_directory: Option>, + remote_client: Entity, + cx: &mut App, +) -> Result { + // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed + // to properly display colors. + // We do not have the luxury of assuming the host has it installed, + // so we set it to a default that does not break the highlighting via ssh. + env.entry("TERM".to_string()) + .or_insert_with(|| "xterm-256color".to_string()); + + let (program, args) = match spawn_command { + Some((program, args)) => (Some(program.clone()), args), + None => (None, &Vec::new()), }; - let shell_invocation = format!("{shell} -c {}", shlex::try_quote(&commands).unwrap()); - - let program = "ssh".to_string(); - let mut args = ssh_command.arguments.clone(); - args.push("-t".to_string()); - args.push(shell_invocation); - (program, args) + let command = remote_client.read(cx).build_command( + program, + args.as_slice(), + env, + working_directory.map(|path| path.display().to_string()), + None, + )?; + *env = command.env; + + log::debug!("Connecting to a remote server: {:?}", command.program); + let host = remote_client.read(cx).connection_options().host; + + Ok(Shell::WithArguments { + program: command.program, + args: command.args, + title_override: Some(format!("{} — Terminal", host).into()), + }) } fn add_environment_path(env: &mut HashMap, new_path: &Path) -> Result<()> { diff --git a/crates/project/src/worktree_store.rs b/crates/project/src/worktree_store.rs index b8905c73bc66ab3f9a911785fe812401dfdb6ee4..9033415ca40b4550e5f98bae2de8314be2e1a5fe 100644 --- a/crates/project/src/worktree_store.rs +++ b/crates/project/src/worktree_store.rs @@ -18,7 +18,7 @@ use gpui::{ use postage::oneshot; use rpc::{ AnyProtoClient, ErrorExt, TypedEnvelope, - proto::{self, FromProto, SSH_PROJECT_ID, ToProto}, + proto::{self, FromProto, REMOTE_SERVER_PROJECT_ID, ToProto}, }; use smol::{ channel::{Receiver, Sender}, @@ -278,7 +278,7 @@ impl WorktreeStore { let path = RemotePathBuf::new(abs_path.into(), path_style); let response = client .request(proto::AddWorktree { - project_id: SSH_PROJECT_ID, + project_id: REMOTE_SERVER_PROJECT_ID, path: path.to_proto(), visible, }) @@ -298,7 +298,7 @@ impl WorktreeStore { let worktree = cx.update(|cx| { Worktree::remote( - SSH_PROJECT_ID, + REMOTE_SERVER_PROJECT_ID, 0, proto::WorktreeMetadata { id: response.worktree_id, diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 5a30a3e9bcabc3c2c16f942cf864daa6b28f6b98..eeb2f7a49b82ce35199c8a773d70cf5fd358c034 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -653,7 +653,7 @@ impl ProjectPanel { let file_path = entry.path.clone(); let worktree_id = worktree.read(cx).id(); let entry_id = entry.id; - let is_via_ssh = project.read(cx).is_via_ssh(); + let is_via_ssh = project.read(cx).is_via_remote_server(); workspace .open_path_preview( @@ -5301,7 +5301,7 @@ impl Render for ProjectPanel { .on_action(cx.listener(Self::open_system)) .on_action(cx.listener(Self::open_in_terminal)) }) - .when(project.is_via_ssh(), |el| { + .when(project.is_via_remote_server(), |el| { el.on_action(cx.listener(Self::open_in_terminal)) }) .on_mouse_down( diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index d38e54685ffb78fe8621b12a0dd25bb6d1ab3f6e..e17ec5203bd5b7bcab03c6461c343156116cc563 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -16,8 +16,8 @@ pub use typed_envelope::*; include!(concat!(env!("OUT_DIR"), "/zed.messages.rs")); -pub const SSH_PEER_ID: PeerId = PeerId { owner_id: 0, id: 0 }; -pub const SSH_PROJECT_ID: u64 = 0; +pub const REMOTE_SERVER_PEER_ID: PeerId = PeerId { owner_id: 0, id: 0 }; +pub const REMOTE_SERVER_PROJECT_ID: u64 = 0; messages!( (Ack, Foreground), diff --git a/crates/recent_projects/src/disconnected_overlay.rs b/crates/recent_projects/src/disconnected_overlay.rs index 8ffe0ef07cf2e0c635794383ae08203c87a33f44..36da6897b92e4bc183aa7c0f51d5100e8836931e 100644 --- a/crates/recent_projects/src/disconnected_overlay.rs +++ b/crates/recent_projects/src/disconnected_overlay.rs @@ -64,8 +64,8 @@ impl DisconnectedOverlay { } let handle = cx.entity().downgrade(); - let ssh_connection_options = project.read(cx).ssh_connection_options(cx); - let host = if let Some(ssh_connection_options) = ssh_connection_options { + let remote_connection_options = project.read(cx).remote_connection_options(cx); + let host = if let Some(ssh_connection_options) = remote_connection_options { Host::SshRemoteProject(ssh_connection_options) } else { Host::RemoteProject diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index a9c3284d0bd5d9dc5e279ce317f2280914bd623f..f4fd1f1c1bbb12e2fbf11088baf859b08bfbf310 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -28,8 +28,8 @@ use paths::user_ssh_config_file; use picker::Picker; use project::Fs; use project::Project; -use remote::ssh_session::ConnectionIdentifier; -use remote::{SshConnectionOptions, SshRemoteClient}; +use remote::remote_client::ConnectionIdentifier; +use remote::{RemoteClient, SshConnectionOptions}; use settings::Settings; use settings::SettingsStore; use settings::update_settings_file; @@ -69,7 +69,7 @@ pub struct RemoteServerProjects { mode: Mode, focus_handle: FocusHandle, workspace: WeakEntity, - retained_connections: Vec>, + retained_connections: Vec>, ssh_config_updates: Task<()>, ssh_config_servers: BTreeSet, create_new_window: bool, @@ -597,7 +597,7 @@ impl RemoteServerProjects { let (path_style, project) = cx.update(|_, cx| { ( session.read(cx).path_style(), - project::Project::ssh( + project::Project::remote( session, app_state.client.clone(), app_state.node_runtime.clone(), diff --git a/crates/recent_projects/src/ssh_connections.rs b/crates/recent_projects/src/ssh_connections.rs index d07ea48c7e439651d6612bbea046f7711a6a124a..e3fb249d1632a35d888996da2665d00ea98b2c26 100644 --- a/crates/recent_projects/src/ssh_connections.rs +++ b/crates/recent_projects/src/ssh_connections.rs @@ -15,8 +15,9 @@ use gpui::{ use language::CursorShape; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use release_channel::ReleaseChannel; -use remote::ssh_session::{ConnectionIdentifier, SshPortForwardOption}; -use remote::{SshConnectionOptions, SshPlatform, SshRemoteClient}; +use remote::{ + ConnectionIdentifier, RemoteClient, RemotePlatform, SshConnectionOptions, SshPortForwardOption, +}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsSources}; @@ -451,7 +452,7 @@ pub struct SshClientDelegate { known_password: Option, } -impl remote::SshClientDelegate for SshClientDelegate { +impl remote::RemoteClientDelegate for SshClientDelegate { fn ask_password(&self, prompt: String, tx: oneshot::Sender, cx: &mut AsyncApp) { let mut known_password = self.known_password.clone(); if let Some(password) = known_password.take() { @@ -473,7 +474,7 @@ impl remote::SshClientDelegate for SshClientDelegate { fn download_server_binary_locally( &self, - platform: SshPlatform, + platform: RemotePlatform, release_channel: ReleaseChannel, version: Option, cx: &mut AsyncApp, @@ -503,7 +504,7 @@ impl remote::SshClientDelegate for SshClientDelegate { fn get_download_params( &self, - platform: SshPlatform, + platform: RemotePlatform, release_channel: ReleaseChannel, version: Option, cx: &mut AsyncApp, @@ -543,13 +544,13 @@ pub fn connect_over_ssh( ui: Entity, window: &mut Window, cx: &mut App, -) -> Task>>> { +) -> Task>>> { let window = window.window_handle(); let known_password = connection_options.password.clone(); let (tx, rx) = oneshot::channel(); ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx)); - remote::SshRemoteClient::new( + remote::RemoteClient::ssh( unique_identifier, connection_options, rx, @@ -681,9 +682,9 @@ pub async fn open_ssh_project( window .update(cx, |workspace, _, cx| { - if let Some(client) = workspace.project().read(cx).ssh_client() { + if let Some(client) = workspace.project().read(cx).remote_client() { ExtensionStore::global(cx) - .update(cx, |store, cx| store.register_ssh_client(client, cx)); + .update(cx, |store, cx| store.register_remote_client(client, cx)); } }) .ok(); diff --git a/crates/remote/src/protocol.rs b/crates/remote/src/protocol.rs index e5a9c5b7a55bf7a49d720ba3d761c04cc597e4fd..867a31b1645980ad050d6e9b75fd6cfadb9dc50d 100644 --- a/crates/remote/src/protocol.rs +++ b/crates/remote/src/protocol.rs @@ -51,6 +51,16 @@ pub async fn write_message( Ok(()) } +pub async fn write_size_prefixed_buffer( + stream: &mut S, + buffer: &mut Vec, +) -> Result<()> { + let len = buffer.len() as u32; + stream.write_all(len.to_le_bytes().as_slice()).await?; + stream.write_all(buffer).await?; + Ok(()) +} + pub async fn read_message_raw( stream: &mut S, buffer: &mut Vec, diff --git a/crates/remote/src/remote.rs b/crates/remote/src/remote.rs index 71895f1678c5e71819218f62d3831708c2e4a2bc..c698353d9edfc0d48c7039f321a2c88890e8c098 100644 --- a/crates/remote/src/remote.rs +++ b/crates/remote/src/remote.rs @@ -1,9 +1,11 @@ pub mod json_log; pub mod protocol; pub mod proxy; -pub mod ssh_session; +pub mod remote_client; +mod transport; -pub use ssh_session::{ - ConnectionState, SshClientDelegate, SshConnectionOptions, SshInfo, SshPlatform, - SshRemoteClient, SshRemoteEvent, +pub use remote_client::{ + ConnectionIdentifier, ConnectionState, RemoteClient, RemoteClientDelegate, RemoteClientEvent, + RemotePlatform, }; +pub use transport::ssh::{SshConnectionOptions, SshPortForwardOption}; diff --git a/crates/remote/src/remote_client.rs b/crates/remote/src/remote_client.rs new file mode 100644 index 0000000000000000000000000000000000000000..dd529ca87499b0daf2061fd990f7149828e3fce4 --- /dev/null +++ b/crates/remote/src/remote_client.rs @@ -0,0 +1,1478 @@ +use crate::{ + SshConnectionOptions, protocol::MessageId, proxy::ProxyLaunchError, + transport::ssh::SshRemoteConnection, +}; +use anyhow::{Context as _, Result, anyhow}; +use async_trait::async_trait; +use collections::HashMap; +use futures::{ + Future, FutureExt as _, StreamExt as _, + channel::{ + mpsc::{self, Sender, UnboundedReceiver, UnboundedSender}, + oneshot, + }, + future::{BoxFuture, Shared}, + select, select_biased, +}; +use gpui::{ + App, AppContext as _, AsyncApp, BackgroundExecutor, BorrowAppContext, Context, Entity, + EventEmitter, Global, SemanticVersion, Task, WeakEntity, +}; +use parking_lot::Mutex; + +use release_channel::ReleaseChannel; +use rpc::{ + AnyProtoClient, ErrorExt, ProtoClient, ProtoMessageHandlerSet, RpcError, + proto::{self, Envelope, EnvelopedMessage, PeerId, RequestMessage, build_typed_envelope}, +}; +use std::{ + collections::VecDeque, + fmt, + ops::ControlFlow, + path::PathBuf, + sync::{ + Arc, Weak, + atomic::{AtomicU32, AtomicU64, Ordering::SeqCst}, + }, + time::{Duration, Instant}, +}; +use util::{ + ResultExt, + paths::{PathStyle, RemotePathBuf}, +}; + +#[derive(Copy, Clone, Debug)] +pub struct RemotePlatform { + pub os: &'static str, + pub arch: &'static str, +} + +#[derive(Clone, Debug)] +pub struct CommandTemplate { + pub program: String, + pub args: Vec, + pub env: HashMap, +} + +pub trait RemoteClientDelegate: Send + Sync { + fn ask_password(&self, prompt: String, tx: oneshot::Sender, cx: &mut AsyncApp); + fn get_download_params( + &self, + platform: RemotePlatform, + release_channel: ReleaseChannel, + version: Option, + cx: &mut AsyncApp, + ) -> Task>>; + fn download_server_binary_locally( + &self, + platform: RemotePlatform, + release_channel: ReleaseChannel, + version: Option, + cx: &mut AsyncApp, + ) -> Task>; + fn set_status(&self, status: Option<&str>, cx: &mut AsyncApp); +} + +const MAX_MISSED_HEARTBEATS: usize = 5; +const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); +const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(5); + +const MAX_RECONNECT_ATTEMPTS: usize = 3; + +enum State { + Connecting, + Connected { + ssh_connection: Arc, + delegate: Arc, + + multiplex_task: Task>, + heartbeat_task: Task>, + }, + HeartbeatMissed { + missed_heartbeats: usize, + + ssh_connection: Arc, + delegate: Arc, + + multiplex_task: Task>, + heartbeat_task: Task>, + }, + Reconnecting, + ReconnectFailed { + ssh_connection: Arc, + delegate: Arc, + + error: anyhow::Error, + attempts: usize, + }, + ReconnectExhausted, + ServerNotRunning, +} + +impl fmt::Display for State { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Connecting => write!(f, "connecting"), + Self::Connected { .. } => write!(f, "connected"), + Self::Reconnecting => write!(f, "reconnecting"), + Self::ReconnectFailed { .. } => write!(f, "reconnect failed"), + Self::ReconnectExhausted => write!(f, "reconnect exhausted"), + Self::HeartbeatMissed { .. } => write!(f, "heartbeat missed"), + Self::ServerNotRunning { .. } => write!(f, "server not running"), + } + } +} + +impl State { + fn remote_connection(&self) -> Option> { + match self { + Self::Connected { ssh_connection, .. } => Some(ssh_connection.clone()), + Self::HeartbeatMissed { ssh_connection, .. } => Some(ssh_connection.clone()), + Self::ReconnectFailed { ssh_connection, .. } => Some(ssh_connection.clone()), + _ => None, + } + } + + fn can_reconnect(&self) -> bool { + match self { + Self::Connected { .. } + | Self::HeartbeatMissed { .. } + | Self::ReconnectFailed { .. } => true, + State::Connecting + | State::Reconnecting + | State::ReconnectExhausted + | State::ServerNotRunning => false, + } + } + + fn is_reconnect_failed(&self) -> bool { + matches!(self, Self::ReconnectFailed { .. }) + } + + fn is_reconnect_exhausted(&self) -> bool { + matches!(self, Self::ReconnectExhausted { .. }) + } + + fn is_server_not_running(&self) -> bool { + matches!(self, Self::ServerNotRunning) + } + + fn is_reconnecting(&self) -> bool { + matches!(self, Self::Reconnecting { .. }) + } + + fn heartbeat_recovered(self) -> Self { + match self { + Self::HeartbeatMissed { + ssh_connection, + delegate, + multiplex_task, + heartbeat_task, + .. + } => Self::Connected { + ssh_connection, + delegate, + multiplex_task, + heartbeat_task, + }, + _ => self, + } + } + + fn heartbeat_missed(self) -> Self { + match self { + Self::Connected { + ssh_connection, + delegate, + multiplex_task, + heartbeat_task, + } => Self::HeartbeatMissed { + missed_heartbeats: 1, + ssh_connection, + delegate, + multiplex_task, + heartbeat_task, + }, + Self::HeartbeatMissed { + missed_heartbeats, + ssh_connection, + delegate, + multiplex_task, + heartbeat_task, + } => Self::HeartbeatMissed { + missed_heartbeats: missed_heartbeats + 1, + ssh_connection, + delegate, + multiplex_task, + heartbeat_task, + }, + _ => self, + } + } +} + +/// The state of the ssh connection. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConnectionState { + Connecting, + Connected, + HeartbeatMissed, + Reconnecting, + Disconnected, +} + +impl From<&State> for ConnectionState { + fn from(value: &State) -> Self { + match value { + State::Connecting => Self::Connecting, + State::Connected { .. } => Self::Connected, + State::Reconnecting | State::ReconnectFailed { .. } => Self::Reconnecting, + State::HeartbeatMissed { .. } => Self::HeartbeatMissed, + State::ReconnectExhausted => Self::Disconnected, + State::ServerNotRunning => Self::Disconnected, + } + } +} + +pub struct RemoteClient { + client: Arc, + unique_identifier: String, + connection_options: SshConnectionOptions, + path_style: PathStyle, + state: Option, +} + +#[derive(Debug)] +pub enum RemoteClientEvent { + Disconnected, +} + +impl EventEmitter for RemoteClient {} + +// Identifies the socket on the remote server so that reconnects +// can re-join the same project. +pub enum ConnectionIdentifier { + Setup(u64), + Workspace(i64), +} + +static NEXT_ID: AtomicU64 = AtomicU64::new(1); + +impl ConnectionIdentifier { + pub fn setup() -> Self { + Self::Setup(NEXT_ID.fetch_add(1, SeqCst)) + } + + // This string gets used in a socket name, and so must be relatively short. + // The total length of: + // /home/{username}/.local/share/zed/server_state/{name}/stdout.sock + // Must be less than about 100 characters + // https://unix.stackexchange.com/questions/367008/why-is-socket-path-length-limited-to-a-hundred-chars + // So our strings should be at most 20 characters or so. + fn to_string(&self, cx: &App) -> String { + let identifier_prefix = match ReleaseChannel::global(cx) { + ReleaseChannel::Stable => "".to_string(), + release_channel => format!("{}-", release_channel.dev_name()), + }; + match self { + Self::Setup(setup_id) => format!("{identifier_prefix}setup-{setup_id}"), + Self::Workspace(workspace_id) => { + format!("{identifier_prefix}workspace-{workspace_id}",) + } + } + } +} + +impl RemoteClient { + pub fn ssh( + unique_identifier: ConnectionIdentifier, + connection_options: SshConnectionOptions, + cancellation: oneshot::Receiver<()>, + delegate: Arc, + cx: &mut App, + ) -> Task>>> { + let unique_identifier = unique_identifier.to_string(cx); + cx.spawn(async move |cx| { + let success = Box::pin(async move { + let (outgoing_tx, outgoing_rx) = mpsc::unbounded::(); + let (incoming_tx, incoming_rx) = mpsc::unbounded::(); + let (connection_activity_tx, connection_activity_rx) = mpsc::channel::<()>(1); + + let client = + cx.update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "client"))?; + + let ssh_connection = cx + .update(|cx| { + cx.update_default_global(|pool: &mut ConnectionPool, cx| { + pool.connect(connection_options.clone(), &delegate, cx) + }) + })? + .await + .map_err(|e| e.cloned())?; + + let path_style = ssh_connection.path_style(); + let this = cx.new(|_| Self { + client: client.clone(), + unique_identifier: unique_identifier.clone(), + connection_options, + path_style, + state: Some(State::Connecting), + })?; + + let io_task = ssh_connection.start_proxy( + unique_identifier, + false, + incoming_tx, + outgoing_rx, + connection_activity_tx, + delegate.clone(), + cx, + ); + + let multiplex_task = Self::monitor(this.downgrade(), io_task, cx); + + if let Err(error) = client.ping(HEARTBEAT_TIMEOUT).await { + log::error!("failed to establish connection: {}", error); + return Err(error); + } + + let heartbeat_task = Self::heartbeat(this.downgrade(), connection_activity_rx, cx); + + this.update(cx, |this, _| { + this.state = Some(State::Connected { + ssh_connection, + delegate, + multiplex_task, + heartbeat_task, + }); + })?; + + Ok(Some(this)) + }); + + select! { + _ = cancellation.fuse() => { + Ok(None) + } + result = success.fuse() => result + } + }) + } + + pub fn proto_client_from_channels( + incoming_rx: mpsc::UnboundedReceiver, + outgoing_tx: mpsc::UnboundedSender, + cx: &App, + name: &'static str, + ) -> AnyProtoClient { + ChannelClient::new(incoming_rx, outgoing_tx, cx, name).into() + } + + pub fn shutdown_processes( + &mut self, + shutdown_request: Option, + executor: BackgroundExecutor, + ) -> Option + use> { + let state = self.state.take()?; + log::info!("shutting down ssh processes"); + + let State::Connected { + multiplex_task, + heartbeat_task, + ssh_connection, + delegate, + } = state + else { + return None; + }; + + let client = self.client.clone(); + + Some(async move { + if let Some(shutdown_request) = shutdown_request { + client.send(shutdown_request).log_err(); + // We wait 50ms instead of waiting for a response, because + // waiting for a response would require us to wait on the main thread + // which we want to avoid in an `on_app_quit` callback. + executor.timer(Duration::from_millis(50)).await; + } + + // Drop `multiplex_task` because it owns our ssh_proxy_process, which is a + // child of master_process. + drop(multiplex_task); + // Now drop the rest of state, which kills master process. + drop(heartbeat_task); + drop(ssh_connection); + drop(delegate); + }) + } + + fn reconnect(&mut self, cx: &mut Context) -> Result<()> { + let can_reconnect = self + .state + .as_ref() + .map(|state| state.can_reconnect()) + .unwrap_or(false); + if !can_reconnect { + log::info!("aborting reconnect, because not in state that allows reconnecting"); + let error = if let Some(state) = self.state.as_ref() { + format!("invalid state, cannot reconnect while in state {state}") + } else { + "no state set".to_string() + }; + anyhow::bail!(error); + } + + let state = self.state.take().unwrap(); + let (attempts, ssh_connection, delegate) = match state { + State::Connected { + ssh_connection, + delegate, + multiplex_task, + heartbeat_task, + } + | State::HeartbeatMissed { + ssh_connection, + delegate, + multiplex_task, + heartbeat_task, + .. + } => { + drop(multiplex_task); + drop(heartbeat_task); + (0, ssh_connection, delegate) + } + State::ReconnectFailed { + attempts, + ssh_connection, + delegate, + .. + } => (attempts, ssh_connection, delegate), + State::Connecting + | State::Reconnecting + | State::ReconnectExhausted + | State::ServerNotRunning => unreachable!(), + }; + + let attempts = attempts + 1; + if attempts > MAX_RECONNECT_ATTEMPTS { + log::error!( + "Failed to reconnect to after {} attempts, giving up", + MAX_RECONNECT_ATTEMPTS + ); + self.set_state(State::ReconnectExhausted, cx); + return Ok(()); + } + + self.set_state(State::Reconnecting, cx); + + log::info!("Trying to reconnect to ssh server... Attempt {}", attempts); + + let unique_identifier = self.unique_identifier.clone(); + let client = self.client.clone(); + let reconnect_task = cx.spawn(async move |this, cx| { + macro_rules! failed { + ($error:expr, $attempts:expr, $ssh_connection:expr, $delegate:expr) => { + return State::ReconnectFailed { + error: anyhow!($error), + attempts: $attempts, + ssh_connection: $ssh_connection, + delegate: $delegate, + }; + }; + } + + if let Err(error) = ssh_connection + .kill() + .await + .context("Failed to kill ssh process") + { + failed!(error, attempts, ssh_connection, delegate); + }; + + let connection_options = ssh_connection.connection_options(); + + let (outgoing_tx, outgoing_rx) = mpsc::unbounded::(); + let (incoming_tx, incoming_rx) = mpsc::unbounded::(); + let (connection_activity_tx, connection_activity_rx) = mpsc::channel::<()>(1); + + let (ssh_connection, io_task) = match async { + let ssh_connection = cx + .update_global(|pool: &mut ConnectionPool, cx| { + pool.connect(connection_options, &delegate, cx) + })? + .await + .map_err(|error| error.cloned())?; + + let io_task = ssh_connection.start_proxy( + unique_identifier, + true, + incoming_tx, + outgoing_rx, + connection_activity_tx, + delegate.clone(), + cx, + ); + anyhow::Ok((ssh_connection, io_task)) + } + .await + { + Ok((ssh_connection, io_task)) => (ssh_connection, io_task), + Err(error) => { + failed!(error, attempts, ssh_connection, delegate); + } + }; + + let multiplex_task = Self::monitor(this.clone(), io_task, cx); + client.reconnect(incoming_rx, outgoing_tx, cx); + + if let Err(error) = client.resync(HEARTBEAT_TIMEOUT).await { + failed!(error, attempts, ssh_connection, delegate); + }; + + State::Connected { + ssh_connection, + delegate, + multiplex_task, + heartbeat_task: Self::heartbeat(this.clone(), connection_activity_rx, cx), + } + }); + + cx.spawn(async move |this, cx| { + let new_state = reconnect_task.await; + this.update(cx, |this, cx| { + this.try_set_state(cx, |old_state| { + if old_state.is_reconnecting() { + match &new_state { + State::Connecting + | State::Reconnecting + | State::HeartbeatMissed { .. } + | State::ServerNotRunning => {} + State::Connected { .. } => { + log::info!("Successfully reconnected"); + } + State::ReconnectFailed { + error, attempts, .. + } => { + log::error!( + "Reconnect attempt {} failed: {:?}. Starting new attempt...", + attempts, + error + ); + } + State::ReconnectExhausted => { + log::error!("Reconnect attempt failed and all attempts exhausted"); + } + } + Some(new_state) + } else { + None + } + }); + + if this.state_is(State::is_reconnect_failed) { + this.reconnect(cx) + } else if this.state_is(State::is_reconnect_exhausted) { + Ok(()) + } else { + log::debug!("State has transition from Reconnecting into new state while attempting reconnect."); + Ok(()) + } + }) + }) + .detach_and_log_err(cx); + + Ok(()) + } + + fn heartbeat( + this: WeakEntity, + mut connection_activity_rx: mpsc::Receiver<()>, + cx: &mut AsyncApp, + ) -> Task> { + let Ok(client) = this.read_with(cx, |this, _| this.client.clone()) else { + return Task::ready(Err(anyhow!("SshRemoteClient lost"))); + }; + + cx.spawn(async move |cx| { + let mut missed_heartbeats = 0; + + let keepalive_timer = cx.background_executor().timer(HEARTBEAT_INTERVAL).fuse(); + futures::pin_mut!(keepalive_timer); + + loop { + select_biased! { + result = connection_activity_rx.next().fuse() => { + if result.is_none() { + log::warn!("ssh heartbeat: connection activity channel has been dropped. stopping."); + return Ok(()); + } + + if missed_heartbeats != 0 { + missed_heartbeats = 0; + let _ =this.update(cx, |this, cx| { + this.handle_heartbeat_result(missed_heartbeats, cx) + })?; + } + } + _ = keepalive_timer => { + log::debug!("Sending heartbeat to server..."); + + let result = select_biased! { + _ = connection_activity_rx.next().fuse() => { + Ok(()) + } + ping_result = client.ping(HEARTBEAT_TIMEOUT).fuse() => { + ping_result + } + }; + + if result.is_err() { + missed_heartbeats += 1; + log::warn!( + "No heartbeat from server after {:?}. Missed heartbeat {} out of {}.", + HEARTBEAT_TIMEOUT, + missed_heartbeats, + MAX_MISSED_HEARTBEATS + ); + } else if missed_heartbeats != 0 { + missed_heartbeats = 0; + } else { + continue; + } + + let result = this.update(cx, |this, cx| { + this.handle_heartbeat_result(missed_heartbeats, cx) + })?; + if result.is_break() { + return Ok(()); + } + } + } + + keepalive_timer.set(cx.background_executor().timer(HEARTBEAT_INTERVAL).fuse()); + } + }) + } + + fn handle_heartbeat_result( + &mut self, + missed_heartbeats: usize, + cx: &mut Context, + ) -> ControlFlow<()> { + let state = self.state.take().unwrap(); + let next_state = if missed_heartbeats > 0 { + state.heartbeat_missed() + } else { + state.heartbeat_recovered() + }; + + self.set_state(next_state, cx); + + if missed_heartbeats >= MAX_MISSED_HEARTBEATS { + log::error!( + "Missed last {} heartbeats. Reconnecting...", + missed_heartbeats + ); + + self.reconnect(cx) + .context("failed to start reconnect process after missing heartbeats") + .log_err(); + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + } + + fn monitor( + this: WeakEntity, + io_task: Task>, + cx: &AsyncApp, + ) -> Task> { + cx.spawn(async move |cx| { + let result = io_task.await; + + match result { + Ok(exit_code) => { + if let Some(error) = ProxyLaunchError::from_exit_code(exit_code) { + match error { + ProxyLaunchError::ServerNotRunning => { + log::error!("failed to reconnect because server is not running"); + this.update(cx, |this, cx| { + this.set_state(State::ServerNotRunning, cx); + })?; + } + } + } else if exit_code > 0 { + log::error!("proxy process terminated unexpectedly"); + this.update(cx, |this, cx| { + this.reconnect(cx).ok(); + })?; + } + } + Err(error) => { + log::warn!("ssh io task died with error: {:?}. reconnecting...", error); + this.update(cx, |this, cx| { + this.reconnect(cx).ok(); + })?; + } + } + + Ok(()) + }) + } + + fn state_is(&self, check: impl FnOnce(&State) -> bool) -> bool { + self.state.as_ref().is_some_and(check) + } + + fn try_set_state(&mut self, cx: &mut Context, map: impl FnOnce(&State) -> Option) { + let new_state = self.state.as_ref().and_then(map); + if let Some(new_state) = new_state { + self.state.replace(new_state); + cx.notify(); + } + } + + fn set_state(&mut self, state: State, cx: &mut Context) { + log::info!("setting state to '{}'", &state); + + let is_reconnect_exhausted = state.is_reconnect_exhausted(); + let is_server_not_running = state.is_server_not_running(); + self.state.replace(state); + + if is_reconnect_exhausted || is_server_not_running { + cx.emit(RemoteClientEvent::Disconnected); + } + cx.notify(); + } + + pub fn shell(&self) -> Option { + Some(self.state.as_ref()?.remote_connection()?.shell()) + } + + pub fn build_command( + &self, + program: Option, + args: &[String], + env: &HashMap, + working_dir: Option, + port_forward: Option<(u16, String, u16)>, + ) -> Result { + let Some(connection) = self + .state + .as_ref() + .and_then(|state| state.remote_connection()) + else { + return Err(anyhow!("no connection")); + }; + connection.build_command(program, args, env, working_dir, port_forward) + } + + pub fn upload_directory( + &self, + src_path: PathBuf, + dest_path: RemotePathBuf, + cx: &App, + ) -> Task> { + let Some(connection) = self + .state + .as_ref() + .and_then(|state| state.remote_connection()) + else { + return Task::ready(Err(anyhow!("no ssh connection"))); + }; + connection.upload_directory(src_path, dest_path, cx) + } + + pub fn proto_client(&self) -> AnyProtoClient { + self.client.clone().into() + } + + pub fn host(&self) -> String { + self.connection_options.host.clone() + } + + pub fn connection_options(&self) -> SshConnectionOptions { + self.connection_options.clone() + } + + pub fn connection_state(&self) -> ConnectionState { + self.state + .as_ref() + .map(ConnectionState::from) + .unwrap_or(ConnectionState::Disconnected) + } + + pub fn is_disconnected(&self) -> bool { + self.connection_state() == ConnectionState::Disconnected + } + + pub fn path_style(&self) -> PathStyle { + self.path_style + } + + #[cfg(any(test, feature = "test-support"))] + pub fn simulate_disconnect(&self, client_cx: &mut App) -> Task<()> { + let opts = self.connection_options(); + client_cx.spawn(async move |cx| { + let connection = cx + .update_global(|c: &mut ConnectionPool, _| { + if let Some(ConnectionPoolEntry::Connecting(c)) = c.connections.get(&opts) { + c.clone() + } else { + panic!("missing test connection") + } + }) + .unwrap() + .await + .unwrap(); + + connection.simulate_disconnect(cx); + }) + } + + #[cfg(any(test, feature = "test-support"))] + pub fn fake_server( + client_cx: &mut gpui::TestAppContext, + server_cx: &mut gpui::TestAppContext, + ) -> (SshConnectionOptions, AnyProtoClient) { + let port = client_cx + .update(|cx| cx.default_global::().connections.len() as u16 + 1); + let opts = SshConnectionOptions { + host: "".to_string(), + port: Some(port), + ..Default::default() + }; + let (outgoing_tx, _) = mpsc::unbounded::(); + let (_, incoming_rx) = mpsc::unbounded::(); + let server_client = + server_cx.update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "fake-server")); + let connection: Arc = Arc::new(fake::FakeRemoteConnection { + connection_options: opts.clone(), + server_cx: fake::SendableCx::new(server_cx), + server_channel: server_client.clone(), + }); + + client_cx.update(|cx| { + cx.update_default_global(|c: &mut ConnectionPool, cx| { + c.connections.insert( + opts.clone(), + ConnectionPoolEntry::Connecting( + cx.background_spawn({ + let connection = connection.clone(); + async move { Ok(connection.clone()) } + }) + .shared(), + ), + ); + }) + }); + + (opts, server_client.into()) + } + + #[cfg(any(test, feature = "test-support"))] + pub async fn fake_client( + opts: SshConnectionOptions, + client_cx: &mut gpui::TestAppContext, + ) -> Entity { + let (_tx, rx) = oneshot::channel(); + client_cx + .update(|cx| { + Self::ssh( + ConnectionIdentifier::setup(), + opts, + rx, + Arc::new(fake::Delegate), + cx, + ) + }) + .await + .unwrap() + .unwrap() + } +} + +enum ConnectionPoolEntry { + Connecting(Shared, Arc>>>), + Connected(Weak), +} + +#[derive(Default)] +struct ConnectionPool { + connections: HashMap, +} + +impl Global for ConnectionPool {} + +impl ConnectionPool { + pub fn connect( + &mut self, + opts: SshConnectionOptions, + delegate: &Arc, + cx: &mut App, + ) -> Shared, Arc>>> { + let connection = self.connections.get(&opts); + match connection { + Some(ConnectionPoolEntry::Connecting(task)) => { + let delegate = delegate.clone(); + cx.spawn(async move |cx| { + delegate.set_status(Some("Waiting for existing connection attempt"), cx); + }) + .detach(); + return task.clone(); + } + Some(ConnectionPoolEntry::Connected(ssh)) => { + if let Some(ssh) = ssh.upgrade() + && !ssh.has_been_killed() + { + return Task::ready(Ok(ssh)).shared(); + } + self.connections.remove(&opts); + } + None => {} + } + + let task = cx + .spawn({ + let opts = opts.clone(); + let delegate = delegate.clone(); + async move |cx| { + let connection = SshRemoteConnection::new(opts.clone(), delegate, cx) + .await + .map(|connection| Arc::new(connection) as Arc); + + cx.update_global(|pool: &mut Self, _| { + debug_assert!(matches!( + pool.connections.get(&opts), + Some(ConnectionPoolEntry::Connecting(_)) + )); + match connection { + Ok(connection) => { + pool.connections.insert( + opts.clone(), + ConnectionPoolEntry::Connected(Arc::downgrade(&connection)), + ); + Ok(connection) + } + Err(error) => { + pool.connections.remove(&opts); + Err(Arc::new(error)) + } + } + })? + } + }) + .shared(); + + self.connections + .insert(opts.clone(), ConnectionPoolEntry::Connecting(task.clone())); + task + } +} + +#[async_trait(?Send)] +pub(crate) trait RemoteConnection: Send + Sync { + fn start_proxy( + &self, + unique_identifier: String, + reconnect: bool, + incoming_tx: UnboundedSender, + outgoing_rx: UnboundedReceiver, + connection_activity_tx: Sender<()>, + delegate: Arc, + cx: &mut AsyncApp, + ) -> Task>; + fn upload_directory( + &self, + src_path: PathBuf, + dest_path: RemotePathBuf, + cx: &App, + ) -> Task>; + async fn kill(&self) -> Result<()>; + fn has_been_killed(&self) -> bool; + fn build_command( + &self, + program: Option, + args: &[String], + env: &HashMap, + working_dir: Option, + port_forward: Option<(u16, String, u16)>, + ) -> Result; + fn connection_options(&self) -> SshConnectionOptions; + fn path_style(&self) -> PathStyle; + fn shell(&self) -> String; + + #[cfg(any(test, feature = "test-support"))] + fn simulate_disconnect(&self, _: &AsyncApp) {} +} + +type ResponseChannels = Mutex)>>>; + +struct ChannelClient { + next_message_id: AtomicU32, + outgoing_tx: Mutex>, + buffer: Mutex>, + response_channels: ResponseChannels, + message_handlers: Mutex, + max_received: AtomicU32, + name: &'static str, + task: Mutex>>, +} + +impl ChannelClient { + fn new( + incoming_rx: mpsc::UnboundedReceiver, + outgoing_tx: mpsc::UnboundedSender, + cx: &App, + name: &'static str, + ) -> Arc { + Arc::new_cyclic(|this| Self { + outgoing_tx: Mutex::new(outgoing_tx), + next_message_id: AtomicU32::new(0), + max_received: AtomicU32::new(0), + response_channels: ResponseChannels::default(), + message_handlers: Default::default(), + buffer: Mutex::new(VecDeque::new()), + name, + task: Mutex::new(Self::start_handling_messages( + this.clone(), + incoming_rx, + &cx.to_async(), + )), + }) + } + + fn start_handling_messages( + this: Weak, + mut incoming_rx: mpsc::UnboundedReceiver, + cx: &AsyncApp, + ) -> Task> { + cx.spawn(async move |cx| { + let peer_id = PeerId { owner_id: 0, id: 0 }; + while let Some(incoming) = incoming_rx.next().await { + let Some(this) = this.upgrade() else { + return anyhow::Ok(()); + }; + if let Some(ack_id) = incoming.ack_id { + let mut buffer = this.buffer.lock(); + while buffer.front().is_some_and(|msg| msg.id <= ack_id) { + buffer.pop_front(); + } + } + if let Some(proto::envelope::Payload::FlushBufferedMessages(_)) = &incoming.payload + { + log::debug!( + "{}:ssh message received. name:FlushBufferedMessages", + this.name + ); + { + let buffer = this.buffer.lock(); + for envelope in buffer.iter() { + this.outgoing_tx + .lock() + .unbounded_send(envelope.clone()) + .ok(); + } + } + let mut envelope = proto::Ack {}.into_envelope(0, Some(incoming.id), None); + envelope.id = this.next_message_id.fetch_add(1, SeqCst); + this.outgoing_tx.lock().unbounded_send(envelope).ok(); + continue; + } + + this.max_received.store(incoming.id, SeqCst); + + if let Some(request_id) = incoming.responding_to { + let request_id = MessageId(request_id); + let sender = this.response_channels.lock().remove(&request_id); + if let Some(sender) = sender { + let (tx, rx) = oneshot::channel(); + if incoming.payload.is_some() { + sender.send((incoming, tx)).ok(); + } + rx.await.ok(); + } + } else if let Some(envelope) = + build_typed_envelope(peer_id, Instant::now(), incoming) + { + let type_name = envelope.payload_type_name(); + let message_id = envelope.message_id(); + if let Some(future) = ProtoMessageHandlerSet::handle_message( + &this.message_handlers, + envelope, + this.clone().into(), + cx.clone(), + ) { + log::debug!("{}:ssh message received. name:{type_name}", this.name); + cx.foreground_executor() + .spawn(async move { + match future.await { + Ok(_) => { + log::debug!( + "{}:ssh message handled. name:{type_name}", + this.name + ); + } + Err(error) => { + log::error!( + "{}:error handling message. type:{}, error:{}", + this.name, + type_name, + format!("{error:#}").lines().fold( + String::new(), + |mut message, line| { + if !message.is_empty() { + message.push(' '); + } + message.push_str(line); + message + } + ) + ); + } + } + }) + .detach() + } else { + log::error!("{}:unhandled ssh message name:{type_name}", this.name); + if let Err(e) = AnyProtoClient::from(this.clone()).send_response( + message_id, + anyhow::anyhow!("no handler registered for {type_name}").to_proto(), + ) { + log::error!( + "{}:error sending error response for {type_name}:{e:#}", + this.name + ); + } + } + } + } + anyhow::Ok(()) + }) + } + + fn reconnect( + self: &Arc, + incoming_rx: UnboundedReceiver, + outgoing_tx: UnboundedSender, + cx: &AsyncApp, + ) { + *self.outgoing_tx.lock() = outgoing_tx; + *self.task.lock() = Self::start_handling_messages(Arc::downgrade(self), incoming_rx, cx); + } + + fn request( + &self, + payload: T, + ) -> impl 'static + Future> { + self.request_internal(payload, true) + } + + fn request_internal( + &self, + payload: T, + use_buffer: bool, + ) -> impl 'static + Future> { + log::debug!("ssh request start. name:{}", T::NAME); + let response = + self.request_dynamic(payload.into_envelope(0, None, None), T::NAME, use_buffer); + async move { + let response = response.await?; + log::debug!("ssh request finish. name:{}", T::NAME); + T::Response::from_envelope(response).context("received a response of the wrong type") + } + } + + async fn resync(&self, timeout: Duration) -> Result<()> { + smol::future::or( + async { + self.request_internal(proto::FlushBufferedMessages {}, false) + .await?; + + for envelope in self.buffer.lock().iter() { + self.outgoing_tx + .lock() + .unbounded_send(envelope.clone()) + .ok(); + } + Ok(()) + }, + async { + smol::Timer::after(timeout).await; + anyhow::bail!("Timed out resyncing remote client") + }, + ) + .await + } + + async fn ping(&self, timeout: Duration) -> Result<()> { + smol::future::or( + async { + self.request(proto::Ping {}).await?; + Ok(()) + }, + async { + smol::Timer::after(timeout).await; + anyhow::bail!("Timed out pinging remote client") + }, + ) + .await + } + + fn send(&self, payload: T) -> Result<()> { + log::debug!("ssh send name:{}", T::NAME); + self.send_dynamic(payload.into_envelope(0, None, None)) + } + + fn request_dynamic( + &self, + mut envelope: proto::Envelope, + type_name: &'static str, + use_buffer: bool, + ) -> impl 'static + Future> { + envelope.id = self.next_message_id.fetch_add(1, SeqCst); + let (tx, rx) = oneshot::channel(); + let mut response_channels_lock = self.response_channels.lock(); + response_channels_lock.insert(MessageId(envelope.id), tx); + drop(response_channels_lock); + + let result = if use_buffer { + self.send_buffered(envelope) + } else { + self.send_unbuffered(envelope) + }; + async move { + if let Err(error) = &result { + log::error!("failed to send message: {error}"); + anyhow::bail!("failed to send message: {error}"); + } + + let response = rx.await.context("connection lost")?.0; + if let Some(proto::envelope::Payload::Error(error)) = &response.payload { + return Err(RpcError::from_proto(error, type_name)); + } + Ok(response) + } + } + + pub fn send_dynamic(&self, mut envelope: proto::Envelope) -> Result<()> { + envelope.id = self.next_message_id.fetch_add(1, SeqCst); + self.send_buffered(envelope) + } + + fn send_buffered(&self, mut envelope: proto::Envelope) -> Result<()> { + envelope.ack_id = Some(self.max_received.load(SeqCst)); + self.buffer.lock().push_back(envelope.clone()); + // ignore errors on send (happen while we're reconnecting) + // assume that the global "disconnected" overlay is sufficient. + self.outgoing_tx.lock().unbounded_send(envelope).ok(); + Ok(()) + } + + fn send_unbuffered(&self, mut envelope: proto::Envelope) -> Result<()> { + envelope.ack_id = Some(self.max_received.load(SeqCst)); + self.outgoing_tx.lock().unbounded_send(envelope).ok(); + Ok(()) + } +} + +impl ProtoClient for ChannelClient { + fn request( + &self, + envelope: proto::Envelope, + request_type: &'static str, + ) -> BoxFuture<'static, Result> { + self.request_dynamic(envelope, request_type, true).boxed() + } + + fn send(&self, envelope: proto::Envelope, _message_type: &'static str) -> Result<()> { + self.send_dynamic(envelope) + } + + fn send_response(&self, envelope: Envelope, _message_type: &'static str) -> anyhow::Result<()> { + self.send_dynamic(envelope) + } + + fn message_handler_set(&self) -> &Mutex { + &self.message_handlers + } + + fn is_via_collab(&self) -> bool { + false + } +} + +#[cfg(any(test, feature = "test-support"))] +mod fake { + use super::{ChannelClient, RemoteClientDelegate, RemoteConnection, RemotePlatform}; + use crate::{SshConnectionOptions, remote_client::CommandTemplate}; + use anyhow::Result; + use async_trait::async_trait; + use collections::HashMap; + use futures::{ + FutureExt, SinkExt, StreamExt, + channel::{ + mpsc::{self, Sender}, + oneshot, + }, + select_biased, + }; + use gpui::{App, AppContext as _, AsyncApp, SemanticVersion, Task, TestAppContext}; + use release_channel::ReleaseChannel; + use rpc::proto::Envelope; + use std::{path::PathBuf, sync::Arc}; + use util::paths::{PathStyle, RemotePathBuf}; + + pub(super) struct FakeRemoteConnection { + pub(super) connection_options: SshConnectionOptions, + pub(super) server_channel: Arc, + pub(super) server_cx: SendableCx, + } + + pub(super) struct SendableCx(AsyncApp); + impl SendableCx { + // SAFETY: When run in test mode, GPUI is always single threaded. + pub(super) fn new(cx: &TestAppContext) -> Self { + Self(cx.to_async()) + } + + // SAFETY: Enforce that we're on the main thread by requiring a valid AsyncApp + fn get(&self, _: &AsyncApp) -> AsyncApp { + self.0.clone() + } + } + + // SAFETY: There is no way to access a SendableCx from a different thread, see [`SendableCx::new`] and [`SendableCx::get`] + unsafe impl Send for SendableCx {} + unsafe impl Sync for SendableCx {} + + #[async_trait(?Send)] + impl RemoteConnection for FakeRemoteConnection { + async fn kill(&self) -> Result<()> { + Ok(()) + } + + fn has_been_killed(&self) -> bool { + false + } + + fn build_command( + &self, + program: Option, + args: &[String], + env: &HashMap, + _: Option, + _: Option<(u16, String, u16)>, + ) -> Result { + let ssh_program = program.unwrap_or_else(|| "sh".to_string()); + let mut ssh_args = Vec::new(); + ssh_args.push(ssh_program); + ssh_args.extend(args.iter().cloned()); + Ok(CommandTemplate { + program: "ssh".into(), + args: ssh_args, + env: env.clone(), + }) + } + + fn upload_directory( + &self, + _src_path: PathBuf, + _dest_path: RemotePathBuf, + _cx: &App, + ) -> Task> { + unreachable!() + } + + fn connection_options(&self) -> SshConnectionOptions { + self.connection_options.clone() + } + + fn simulate_disconnect(&self, cx: &AsyncApp) { + let (outgoing_tx, _) = mpsc::unbounded::(); + let (_, incoming_rx) = mpsc::unbounded::(); + self.server_channel + .reconnect(incoming_rx, outgoing_tx, &self.server_cx.get(cx)); + } + + fn start_proxy( + &self, + _unique_identifier: String, + _reconnect: bool, + mut client_incoming_tx: mpsc::UnboundedSender, + mut client_outgoing_rx: mpsc::UnboundedReceiver, + mut connection_activity_tx: Sender<()>, + _delegate: Arc, + cx: &mut AsyncApp, + ) -> Task> { + let (mut server_incoming_tx, server_incoming_rx) = mpsc::unbounded::(); + let (server_outgoing_tx, mut server_outgoing_rx) = mpsc::unbounded::(); + + self.server_channel.reconnect( + server_incoming_rx, + server_outgoing_tx, + &self.server_cx.get(cx), + ); + + cx.background_spawn(async move { + loop { + select_biased! { + server_to_client = server_outgoing_rx.next().fuse() => { + let Some(server_to_client) = server_to_client else { + return Ok(1) + }; + connection_activity_tx.try_send(()).ok(); + client_incoming_tx.send(server_to_client).await.ok(); + } + client_to_server = client_outgoing_rx.next().fuse() => { + let Some(client_to_server) = client_to_server else { + return Ok(1) + }; + server_incoming_tx.send(client_to_server).await.ok(); + } + } + } + }) + } + + fn path_style(&self) -> PathStyle { + PathStyle::current() + } + + fn shell(&self) -> String { + "sh".to_owned() + } + } + + pub(super) struct Delegate; + + impl RemoteClientDelegate for Delegate { + fn ask_password(&self, _: String, _: oneshot::Sender, _: &mut AsyncApp) { + unreachable!() + } + + fn download_server_binary_locally( + &self, + _: RemotePlatform, + _: ReleaseChannel, + _: Option, + _: &mut AsyncApp, + ) -> Task> { + unreachable!() + } + + fn get_download_params( + &self, + _platform: RemotePlatform, + _release_channel: ReleaseChannel, + _version: Option, + _cx: &mut AsyncApp, + ) -> Task>> { + unreachable!() + } + + fn set_status(&self, _: Option<&str>, _: &mut AsyncApp) {} + } +} diff --git a/crates/remote/src/ssh_session.rs b/crates/remote/src/ssh_session.rs deleted file mode 100644 index 67940184705f9fc91aba29997a16e1df592099a1..0000000000000000000000000000000000000000 --- a/crates/remote/src/ssh_session.rs +++ /dev/null @@ -1,2749 +0,0 @@ -use crate::{ - json_log::LogRecord, - protocol::{ - MESSAGE_LEN_SIZE, MessageId, message_len_from_buffer, read_message_with_len, write_message, - }, - proxy::ProxyLaunchError, -}; -use anyhow::{Context as _, Result, anyhow}; -use async_trait::async_trait; -use collections::HashMap; -use futures::{ - AsyncReadExt as _, Future, FutureExt as _, StreamExt as _, - channel::{ - mpsc::{self, Sender, UnboundedReceiver, UnboundedSender}, - oneshot, - }, - future::{BoxFuture, Shared}, - select, select_biased, -}; -use gpui::{ - App, AppContext as _, AsyncApp, BackgroundExecutor, BorrowAppContext, Context, Entity, - EventEmitter, Global, SemanticVersion, Task, WeakEntity, -}; -use itertools::Itertools; -use parking_lot::Mutex; - -use release_channel::{AppCommitSha, AppVersion, ReleaseChannel}; -use rpc::{ - AnyProtoClient, ErrorExt, ProtoClient, ProtoMessageHandlerSet, RpcError, - proto::{self, Envelope, EnvelopedMessage, PeerId, RequestMessage, build_typed_envelope}, -}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use smol::{ - fs, - process::{self, Child, Stdio}, -}; -use std::{ - collections::VecDeque, - fmt, iter, - ops::ControlFlow, - path::{Path, PathBuf}, - sync::{ - Arc, Weak, - atomic::{AtomicU32, AtomicU64, Ordering::SeqCst}, - }, - time::{Duration, Instant}, -}; -use tempfile::TempDir; -use util::{ - ResultExt, - paths::{PathStyle, RemotePathBuf}, -}; - -#[derive(Clone)] -pub struct SshSocket { - connection_options: SshConnectionOptions, - #[cfg(not(target_os = "windows"))] - socket_path: PathBuf, - #[cfg(target_os = "windows")] - envs: HashMap, -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize, JsonSchema)] -pub struct SshPortForwardOption { - #[serde(skip_serializing_if = "Option::is_none")] - pub local_host: Option, - pub local_port: u16, - #[serde(skip_serializing_if = "Option::is_none")] - pub remote_host: Option, - pub remote_port: u16, -} - -#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] -pub struct SshConnectionOptions { - pub host: String, - pub username: Option, - pub port: Option, - pub password: Option, - pub args: Option>, - pub port_forwards: Option>, - - pub nickname: Option, - pub upload_binary_over_ssh: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SshArgs { - pub arguments: Vec, - pub envs: Option>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SshInfo { - pub args: SshArgs, - pub path_style: PathStyle, - pub shell: String, -} - -#[macro_export] -macro_rules! shell_script { - ($fmt:expr, $($name:ident = $arg:expr),+ $(,)?) => {{ - format!( - $fmt, - $( - $name = shlex::try_quote($arg).unwrap() - ),+ - ) - }}; -} - -fn parse_port_number(port_str: &str) -> Result { - port_str - .parse() - .with_context(|| format!("parsing port number: {port_str}")) -} - -fn parse_port_forward_spec(spec: &str) -> Result { - let parts: Vec<&str> = spec.split(':').collect(); - - match parts.len() { - 4 => { - let local_port = parse_port_number(parts[1])?; - let remote_port = parse_port_number(parts[3])?; - - Ok(SshPortForwardOption { - local_host: Some(parts[0].to_string()), - local_port, - remote_host: Some(parts[2].to_string()), - remote_port, - }) - } - 3 => { - let local_port = parse_port_number(parts[0])?; - let remote_port = parse_port_number(parts[2])?; - - Ok(SshPortForwardOption { - local_host: None, - local_port, - remote_host: Some(parts[1].to_string()), - remote_port, - }) - } - _ => anyhow::bail!("Invalid port forward format"), - } -} - -impl SshConnectionOptions { - pub fn parse_command_line(input: &str) -> Result { - let input = input.trim_start_matches("ssh "); - let mut hostname: Option = None; - let mut username: Option = None; - let mut port: Option = None; - let mut args = Vec::new(); - let mut port_forwards: Vec = Vec::new(); - - // disallowed: -E, -e, -F, -f, -G, -g, -M, -N, -n, -O, -q, -S, -s, -T, -t, -V, -v, -W - const ALLOWED_OPTS: &[&str] = &[ - "-4", "-6", "-A", "-a", "-C", "-K", "-k", "-X", "-x", "-Y", "-y", - ]; - const ALLOWED_ARGS: &[&str] = &[ - "-B", "-b", "-c", "-D", "-F", "-I", "-i", "-J", "-l", "-m", "-o", "-P", "-p", "-R", - "-w", - ]; - - let mut tokens = shlex::split(input).context("invalid input")?.into_iter(); - - 'outer: while let Some(arg) = tokens.next() { - if ALLOWED_OPTS.contains(&(&arg as &str)) { - args.push(arg.to_string()); - continue; - } - if arg == "-p" { - port = tokens.next().and_then(|arg| arg.parse().ok()); - continue; - } else if let Some(p) = arg.strip_prefix("-p") { - port = p.parse().ok(); - continue; - } - if arg == "-l" { - username = tokens.next(); - continue; - } else if let Some(l) = arg.strip_prefix("-l") { - username = Some(l.to_string()); - continue; - } - if arg == "-L" || arg.starts_with("-L") { - let forward_spec = if arg == "-L" { - tokens.next() - } else { - Some(arg.strip_prefix("-L").unwrap().to_string()) - }; - - if let Some(spec) = forward_spec { - port_forwards.push(parse_port_forward_spec(&spec)?); - } else { - anyhow::bail!("Missing port forward format"); - } - } - - for a in ALLOWED_ARGS { - if arg == *a { - args.push(arg); - if let Some(next) = tokens.next() { - args.push(next); - } - continue 'outer; - } else if arg.starts_with(a) { - args.push(arg); - continue 'outer; - } - } - if arg.starts_with("-") || hostname.is_some() { - anyhow::bail!("unsupported argument: {:?}", arg); - } - let mut input = &arg as &str; - // Destination might be: username1@username2@ip2@ip1 - if let Some((u, rest)) = input.rsplit_once('@') { - input = rest; - username = Some(u.to_string()); - } - if let Some((rest, p)) = input.split_once(':') { - input = rest; - port = p.parse().ok() - } - hostname = Some(input.to_string()) - } - - let Some(hostname) = hostname else { - anyhow::bail!("missing hostname"); - }; - - let port_forwards = match port_forwards.len() { - 0 => None, - _ => Some(port_forwards), - }; - - Ok(Self { - host: hostname, - username, - port, - port_forwards, - args: Some(args), - password: None, - nickname: None, - upload_binary_over_ssh: false, - }) - } - - pub fn ssh_url(&self) -> String { - let mut result = String::from("ssh://"); - if let Some(username) = &self.username { - // Username might be: username1@username2@ip2 - let username = urlencoding::encode(username); - result.push_str(&username); - result.push('@'); - } - result.push_str(&self.host); - if let Some(port) = self.port { - result.push(':'); - result.push_str(&port.to_string()); - } - result - } - - pub fn additional_args(&self) -> Vec { - let mut args = self.args.iter().flatten().cloned().collect::>(); - - if let Some(forwards) = &self.port_forwards { - args.extend(forwards.iter().map(|pf| { - let local_host = match &pf.local_host { - Some(host) => host, - None => "localhost", - }; - let remote_host = match &pf.remote_host { - Some(host) => host, - None => "localhost", - }; - - format!( - "-L{}:{}:{}:{}", - local_host, pf.local_port, remote_host, pf.remote_port - ) - })); - } - - args - } - - fn scp_url(&self) -> String { - if let Some(username) = &self.username { - format!("{}@{}", username, self.host) - } else { - self.host.clone() - } - } - - pub fn connection_string(&self) -> String { - let host = if let Some(username) = &self.username { - format!("{}@{}", username, self.host) - } else { - self.host.clone() - }; - if let Some(port) = &self.port { - format!("{}:{}", host, port) - } else { - host - } - } -} - -#[derive(Copy, Clone, Debug)] -pub struct SshPlatform { - pub os: &'static str, - pub arch: &'static str, -} - -pub trait SshClientDelegate: Send + Sync { - fn ask_password(&self, prompt: String, tx: oneshot::Sender, cx: &mut AsyncApp); - fn get_download_params( - &self, - platform: SshPlatform, - release_channel: ReleaseChannel, - version: Option, - cx: &mut AsyncApp, - ) -> Task>>; - - fn download_server_binary_locally( - &self, - platform: SshPlatform, - release_channel: ReleaseChannel, - version: Option, - cx: &mut AsyncApp, - ) -> Task>; - fn set_status(&self, status: Option<&str>, cx: &mut AsyncApp); -} - -impl SshSocket { - #[cfg(not(target_os = "windows"))] - fn new(options: SshConnectionOptions, socket_path: PathBuf) -> Result { - Ok(Self { - connection_options: options, - socket_path, - }) - } - - #[cfg(target_os = "windows")] - fn new(options: SshConnectionOptions, temp_dir: &TempDir, secret: String) -> Result { - let askpass_script = temp_dir.path().join("askpass.bat"); - std::fs::write(&askpass_script, "@ECHO OFF\necho %ZED_SSH_ASKPASS%")?; - let mut envs = HashMap::default(); - envs.insert("SSH_ASKPASS_REQUIRE".into(), "force".into()); - envs.insert("SSH_ASKPASS".into(), askpass_script.display().to_string()); - envs.insert("ZED_SSH_ASKPASS".into(), secret); - Ok(Self { - connection_options: options, - envs, - }) - } - - // :WARNING: ssh unquotes arguments when executing on the remote :WARNING: - // e.g. $ ssh host sh -c 'ls -l' is equivalent to $ ssh host sh -c ls -l - // and passes -l as an argument to sh, not to ls. - // Furthermore, some setups (e.g. Coder) will change directory when SSH'ing - // into a machine. You must use `cd` to get back to $HOME. - // You need to do it like this: $ ssh host "cd; sh -c 'ls -l /tmp'" - fn ssh_command(&self, program: &str, args: &[&str]) -> process::Command { - let mut command = util::command::new_smol_command("ssh"); - let to_run = iter::once(&program) - .chain(args.iter()) - .map(|token| { - // We're trying to work with: sh, bash, zsh, fish, tcsh, ...? - debug_assert!( - !token.contains('\n'), - "multiline arguments do not work in all shells" - ); - shlex::try_quote(token).unwrap() - }) - .join(" "); - let to_run = format!("cd; {to_run}"); - log::debug!("ssh {} {:?}", self.connection_options.ssh_url(), to_run); - self.ssh_options(&mut command) - .arg(self.connection_options.ssh_url()) - .arg(to_run); - command - } - - async fn run_command(&self, program: &str, args: &[&str]) -> Result { - let output = self.ssh_command(program, args).output().await?; - anyhow::ensure!( - output.status.success(), - "failed to run command: {}", - String::from_utf8_lossy(&output.stderr) - ); - Ok(String::from_utf8_lossy(&output.stdout).to_string()) - } - - #[cfg(not(target_os = "windows"))] - fn ssh_options<'a>(&self, command: &'a mut process::Command) -> &'a mut process::Command { - command - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .args(self.connection_options.additional_args()) - .args(["-o", "ControlMaster=no", "-o"]) - .arg(format!("ControlPath={}", self.socket_path.display())) - } - - #[cfg(target_os = "windows")] - fn ssh_options<'a>(&self, command: &'a mut process::Command) -> &'a mut process::Command { - command - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .args(self.connection_options.additional_args()) - .envs(self.envs.clone()) - } - - // On Windows, we need to use `SSH_ASKPASS` to provide the password to ssh. - // On Linux, we use the `ControlPath` option to create a socket file that ssh can use to - #[cfg(not(target_os = "windows"))] - fn ssh_args(&self) -> SshArgs { - let mut arguments = self.connection_options.additional_args(); - arguments.extend(vec![ - "-o".to_string(), - "ControlMaster=no".to_string(), - "-o".to_string(), - format!("ControlPath={}", self.socket_path.display()), - self.connection_options.ssh_url(), - ]); - SshArgs { - arguments, - envs: None, - } - } - - #[cfg(target_os = "windows")] - fn ssh_args(&self) -> SshArgs { - let mut arguments = self.connection_options.additional_args(); - arguments.push(self.connection_options.ssh_url()); - SshArgs { - arguments, - envs: Some(self.envs.clone()), - } - } - - async fn platform(&self) -> Result { - let uname = self.run_command("sh", &["-lc", "uname -sm"]).await?; - let Some((os, arch)) = uname.split_once(" ") else { - anyhow::bail!("unknown uname: {uname:?}") - }; - - let os = match os.trim() { - "Darwin" => "macos", - "Linux" => "linux", - _ => anyhow::bail!( - "Prebuilt remote servers are not yet available for {os:?}. See https://zed.dev/docs/remote-development" - ), - }; - // exclude armv5,6,7 as they are 32-bit. - let arch = if arch.starts_with("armv8") - || arch.starts_with("armv9") - || arch.starts_with("arm64") - || arch.starts_with("aarch64") - { - "aarch64" - } else if arch.starts_with("x86") { - "x86_64" - } else { - anyhow::bail!( - "Prebuilt remote servers are not yet available for {arch:?}. See https://zed.dev/docs/remote-development" - ) - }; - - Ok(SshPlatform { os, arch }) - } - - async fn shell(&self) -> String { - match self.run_command("sh", &["-lc", "echo $SHELL"]).await { - Ok(shell) => shell.trim().to_owned(), - Err(e) => { - log::error!("Failed to get shell: {e}"); - "sh".to_owned() - } - } - } -} - -const MAX_MISSED_HEARTBEATS: usize = 5; -const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); -const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(5); - -const MAX_RECONNECT_ATTEMPTS: usize = 3; - -enum State { - Connecting, - Connected { - ssh_connection: Arc, - delegate: Arc, - - multiplex_task: Task>, - heartbeat_task: Task>, - }, - HeartbeatMissed { - missed_heartbeats: usize, - - ssh_connection: Arc, - delegate: Arc, - - multiplex_task: Task>, - heartbeat_task: Task>, - }, - Reconnecting, - ReconnectFailed { - ssh_connection: Arc, - delegate: Arc, - - error: anyhow::Error, - attempts: usize, - }, - ReconnectExhausted, - ServerNotRunning, -} - -impl fmt::Display for State { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Connecting => write!(f, "connecting"), - Self::Connected { .. } => write!(f, "connected"), - Self::Reconnecting => write!(f, "reconnecting"), - Self::ReconnectFailed { .. } => write!(f, "reconnect failed"), - Self::ReconnectExhausted => write!(f, "reconnect exhausted"), - Self::HeartbeatMissed { .. } => write!(f, "heartbeat missed"), - Self::ServerNotRunning { .. } => write!(f, "server not running"), - } - } -} - -impl State { - fn ssh_connection(&self) -> Option<&dyn RemoteConnection> { - match self { - Self::Connected { ssh_connection, .. } => Some(ssh_connection.as_ref()), - Self::HeartbeatMissed { ssh_connection, .. } => Some(ssh_connection.as_ref()), - Self::ReconnectFailed { ssh_connection, .. } => Some(ssh_connection.as_ref()), - _ => None, - } - } - - fn can_reconnect(&self) -> bool { - match self { - Self::Connected { .. } - | Self::HeartbeatMissed { .. } - | Self::ReconnectFailed { .. } => true, - State::Connecting - | State::Reconnecting - | State::ReconnectExhausted - | State::ServerNotRunning => false, - } - } - - fn is_reconnect_failed(&self) -> bool { - matches!(self, Self::ReconnectFailed { .. }) - } - - fn is_reconnect_exhausted(&self) -> bool { - matches!(self, Self::ReconnectExhausted { .. }) - } - - fn is_server_not_running(&self) -> bool { - matches!(self, Self::ServerNotRunning) - } - - fn is_reconnecting(&self) -> bool { - matches!(self, Self::Reconnecting { .. }) - } - - fn heartbeat_recovered(self) -> Self { - match self { - Self::HeartbeatMissed { - ssh_connection, - delegate, - multiplex_task, - heartbeat_task, - .. - } => Self::Connected { - ssh_connection, - delegate, - multiplex_task, - heartbeat_task, - }, - _ => self, - } - } - - fn heartbeat_missed(self) -> Self { - match self { - Self::Connected { - ssh_connection, - delegate, - multiplex_task, - heartbeat_task, - } => Self::HeartbeatMissed { - missed_heartbeats: 1, - ssh_connection, - delegate, - multiplex_task, - heartbeat_task, - }, - Self::HeartbeatMissed { - missed_heartbeats, - ssh_connection, - delegate, - multiplex_task, - heartbeat_task, - } => Self::HeartbeatMissed { - missed_heartbeats: missed_heartbeats + 1, - ssh_connection, - delegate, - multiplex_task, - heartbeat_task, - }, - _ => self, - } - } -} - -/// The state of the ssh connection. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ConnectionState { - Connecting, - Connected, - HeartbeatMissed, - Reconnecting, - Disconnected, -} - -impl From<&State> for ConnectionState { - fn from(value: &State) -> Self { - match value { - State::Connecting => Self::Connecting, - State::Connected { .. } => Self::Connected, - State::Reconnecting | State::ReconnectFailed { .. } => Self::Reconnecting, - State::HeartbeatMissed { .. } => Self::HeartbeatMissed, - State::ReconnectExhausted => Self::Disconnected, - State::ServerNotRunning => Self::Disconnected, - } - } -} - -pub struct SshRemoteClient { - client: Arc, - unique_identifier: String, - connection_options: SshConnectionOptions, - path_style: PathStyle, - state: Arc>>, -} - -#[derive(Debug)] -pub enum SshRemoteEvent { - Disconnected, -} - -impl EventEmitter for SshRemoteClient {} - -// Identifies the socket on the remote server so that reconnects -// can re-join the same project. -pub enum ConnectionIdentifier { - Setup(u64), - Workspace(i64), -} - -static NEXT_ID: AtomicU64 = AtomicU64::new(1); - -impl ConnectionIdentifier { - pub fn setup() -> Self { - Self::Setup(NEXT_ID.fetch_add(1, SeqCst)) - } - - // This string gets used in a socket name, and so must be relatively short. - // The total length of: - // /home/{username}/.local/share/zed/server_state/{name}/stdout.sock - // Must be less than about 100 characters - // https://unix.stackexchange.com/questions/367008/why-is-socket-path-length-limited-to-a-hundred-chars - // So our strings should be at most 20 characters or so. - fn to_string(&self, cx: &App) -> String { - let identifier_prefix = match ReleaseChannel::global(cx) { - ReleaseChannel::Stable => "".to_string(), - release_channel => format!("{}-", release_channel.dev_name()), - }; - match self { - Self::Setup(setup_id) => format!("{identifier_prefix}setup-{setup_id}"), - Self::Workspace(workspace_id) => { - format!("{identifier_prefix}workspace-{workspace_id}",) - } - } - } -} - -impl SshRemoteClient { - pub fn new( - unique_identifier: ConnectionIdentifier, - connection_options: SshConnectionOptions, - cancellation: oneshot::Receiver<()>, - delegate: Arc, - cx: &mut App, - ) -> Task>>> { - let unique_identifier = unique_identifier.to_string(cx); - cx.spawn(async move |cx| { - let success = Box::pin(async move { - let (outgoing_tx, outgoing_rx) = mpsc::unbounded::(); - let (incoming_tx, incoming_rx) = mpsc::unbounded::(); - let (connection_activity_tx, connection_activity_rx) = mpsc::channel::<()>(1); - - let client = - cx.update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "client"))?; - - let ssh_connection = cx - .update(|cx| { - cx.update_default_global(|pool: &mut ConnectionPool, cx| { - pool.connect(connection_options.clone(), &delegate, cx) - }) - })? - .await - .map_err(|e| e.cloned())?; - - let path_style = ssh_connection.path_style(); - let this = cx.new(|_| Self { - client: client.clone(), - unique_identifier: unique_identifier.clone(), - connection_options, - path_style, - state: Arc::new(Mutex::new(Some(State::Connecting))), - })?; - - let io_task = ssh_connection.start_proxy( - unique_identifier, - false, - incoming_tx, - outgoing_rx, - connection_activity_tx, - delegate.clone(), - cx, - ); - - let multiplex_task = Self::monitor(this.downgrade(), io_task, cx); - - if let Err(error) = client.ping(HEARTBEAT_TIMEOUT).await { - log::error!("failed to establish connection: {}", error); - return Err(error); - } - - let heartbeat_task = Self::heartbeat(this.downgrade(), connection_activity_rx, cx); - - this.update(cx, |this, _| { - *this.state.lock() = Some(State::Connected { - ssh_connection, - delegate, - multiplex_task, - heartbeat_task, - }); - })?; - - Ok(Some(this)) - }); - - select! { - _ = cancellation.fuse() => { - Ok(None) - } - result = success.fuse() => result - } - }) - } - - pub fn proto_client_from_channels( - incoming_rx: mpsc::UnboundedReceiver, - outgoing_tx: mpsc::UnboundedSender, - cx: &App, - name: &'static str, - ) -> AnyProtoClient { - ChannelClient::new(incoming_rx, outgoing_tx, cx, name).into() - } - - pub fn shutdown_processes( - &self, - shutdown_request: Option, - executor: BackgroundExecutor, - ) -> Option + use> { - let state = self.state.lock().take()?; - log::info!("shutting down ssh processes"); - - let State::Connected { - multiplex_task, - heartbeat_task, - ssh_connection, - delegate, - } = state - else { - return None; - }; - - let client = self.client.clone(); - - Some(async move { - if let Some(shutdown_request) = shutdown_request { - client.send(shutdown_request).log_err(); - // We wait 50ms instead of waiting for a response, because - // waiting for a response would require us to wait on the main thread - // which we want to avoid in an `on_app_quit` callback. - executor.timer(Duration::from_millis(50)).await; - } - - // Drop `multiplex_task` because it owns our ssh_proxy_process, which is a - // child of master_process. - drop(multiplex_task); - // Now drop the rest of state, which kills master process. - drop(heartbeat_task); - drop(ssh_connection); - drop(delegate); - }) - } - - fn reconnect(&mut self, cx: &mut Context) -> Result<()> { - let mut lock = self.state.lock(); - - let can_reconnect = lock - .as_ref() - .map(|state| state.can_reconnect()) - .unwrap_or(false); - if !can_reconnect { - log::info!("aborting reconnect, because not in state that allows reconnecting"); - let error = if let Some(state) = lock.as_ref() { - format!("invalid state, cannot reconnect while in state {state}") - } else { - "no state set".to_string() - }; - anyhow::bail!(error); - } - - let state = lock.take().unwrap(); - let (attempts, ssh_connection, delegate) = match state { - State::Connected { - ssh_connection, - delegate, - multiplex_task, - heartbeat_task, - } - | State::HeartbeatMissed { - ssh_connection, - delegate, - multiplex_task, - heartbeat_task, - .. - } => { - drop(multiplex_task); - drop(heartbeat_task); - (0, ssh_connection, delegate) - } - State::ReconnectFailed { - attempts, - ssh_connection, - delegate, - .. - } => (attempts, ssh_connection, delegate), - State::Connecting - | State::Reconnecting - | State::ReconnectExhausted - | State::ServerNotRunning => unreachable!(), - }; - - let attempts = attempts + 1; - if attempts > MAX_RECONNECT_ATTEMPTS { - log::error!( - "Failed to reconnect to after {} attempts, giving up", - MAX_RECONNECT_ATTEMPTS - ); - drop(lock); - self.set_state(State::ReconnectExhausted, cx); - return Ok(()); - } - drop(lock); - - self.set_state(State::Reconnecting, cx); - - log::info!("Trying to reconnect to ssh server... Attempt {}", attempts); - - let unique_identifier = self.unique_identifier.clone(); - let client = self.client.clone(); - let reconnect_task = cx.spawn(async move |this, cx| { - macro_rules! failed { - ($error:expr, $attempts:expr, $ssh_connection:expr, $delegate:expr) => { - return State::ReconnectFailed { - error: anyhow!($error), - attempts: $attempts, - ssh_connection: $ssh_connection, - delegate: $delegate, - }; - }; - } - - if let Err(error) = ssh_connection - .kill() - .await - .context("Failed to kill ssh process") - { - failed!(error, attempts, ssh_connection, delegate); - }; - - let connection_options = ssh_connection.connection_options(); - - let (outgoing_tx, outgoing_rx) = mpsc::unbounded::(); - let (incoming_tx, incoming_rx) = mpsc::unbounded::(); - let (connection_activity_tx, connection_activity_rx) = mpsc::channel::<()>(1); - - let (ssh_connection, io_task) = match async { - let ssh_connection = cx - .update_global(|pool: &mut ConnectionPool, cx| { - pool.connect(connection_options, &delegate, cx) - })? - .await - .map_err(|error| error.cloned())?; - - let io_task = ssh_connection.start_proxy( - unique_identifier, - true, - incoming_tx, - outgoing_rx, - connection_activity_tx, - delegate.clone(), - cx, - ); - anyhow::Ok((ssh_connection, io_task)) - } - .await - { - Ok((ssh_connection, io_task)) => (ssh_connection, io_task), - Err(error) => { - failed!(error, attempts, ssh_connection, delegate); - } - }; - - let multiplex_task = Self::monitor(this.clone(), io_task, cx); - client.reconnect(incoming_rx, outgoing_tx, cx); - - if let Err(error) = client.resync(HEARTBEAT_TIMEOUT).await { - failed!(error, attempts, ssh_connection, delegate); - }; - - State::Connected { - ssh_connection, - delegate, - multiplex_task, - heartbeat_task: Self::heartbeat(this.clone(), connection_activity_rx, cx), - } - }); - - cx.spawn(async move |this, cx| { - let new_state = reconnect_task.await; - this.update(cx, |this, cx| { - this.try_set_state(cx, |old_state| { - if old_state.is_reconnecting() { - match &new_state { - State::Connecting - | State::Reconnecting - | State::HeartbeatMissed { .. } - | State::ServerNotRunning => {} - State::Connected { .. } => { - log::info!("Successfully reconnected"); - } - State::ReconnectFailed { - error, attempts, .. - } => { - log::error!( - "Reconnect attempt {} failed: {:?}. Starting new attempt...", - attempts, - error - ); - } - State::ReconnectExhausted => { - log::error!("Reconnect attempt failed and all attempts exhausted"); - } - } - Some(new_state) - } else { - None - } - }); - - if this.state_is(State::is_reconnect_failed) { - this.reconnect(cx) - } else if this.state_is(State::is_reconnect_exhausted) { - Ok(()) - } else { - log::debug!("State has transition from Reconnecting into new state while attempting reconnect."); - Ok(()) - } - }) - }) - .detach_and_log_err(cx); - - Ok(()) - } - - fn heartbeat( - this: WeakEntity, - mut connection_activity_rx: mpsc::Receiver<()>, - cx: &mut AsyncApp, - ) -> Task> { - let Ok(client) = this.read_with(cx, |this, _| this.client.clone()) else { - return Task::ready(Err(anyhow!("SshRemoteClient lost"))); - }; - - cx.spawn(async move |cx| { - let mut missed_heartbeats = 0; - - let keepalive_timer = cx.background_executor().timer(HEARTBEAT_INTERVAL).fuse(); - futures::pin_mut!(keepalive_timer); - - loop { - select_biased! { - result = connection_activity_rx.next().fuse() => { - if result.is_none() { - log::warn!("ssh heartbeat: connection activity channel has been dropped. stopping."); - return Ok(()); - } - - if missed_heartbeats != 0 { - missed_heartbeats = 0; - let _ =this.update(cx, |this, cx| { - this.handle_heartbeat_result(missed_heartbeats, cx) - })?; - } - } - _ = keepalive_timer => { - log::debug!("Sending heartbeat to server..."); - - let result = select_biased! { - _ = connection_activity_rx.next().fuse() => { - Ok(()) - } - ping_result = client.ping(HEARTBEAT_TIMEOUT).fuse() => { - ping_result - } - }; - - if result.is_err() { - missed_heartbeats += 1; - log::warn!( - "No heartbeat from server after {:?}. Missed heartbeat {} out of {}.", - HEARTBEAT_TIMEOUT, - missed_heartbeats, - MAX_MISSED_HEARTBEATS - ); - } else if missed_heartbeats != 0 { - missed_heartbeats = 0; - } else { - continue; - } - - let result = this.update(cx, |this, cx| { - this.handle_heartbeat_result(missed_heartbeats, cx) - })?; - if result.is_break() { - return Ok(()); - } - } - } - - keepalive_timer.set(cx.background_executor().timer(HEARTBEAT_INTERVAL).fuse()); - } - }) - } - - fn handle_heartbeat_result( - &mut self, - missed_heartbeats: usize, - cx: &mut Context, - ) -> ControlFlow<()> { - let state = self.state.lock().take().unwrap(); - let next_state = if missed_heartbeats > 0 { - state.heartbeat_missed() - } else { - state.heartbeat_recovered() - }; - - self.set_state(next_state, cx); - - if missed_heartbeats >= MAX_MISSED_HEARTBEATS { - log::error!( - "Missed last {} heartbeats. Reconnecting...", - missed_heartbeats - ); - - self.reconnect(cx) - .context("failed to start reconnect process after missing heartbeats") - .log_err(); - ControlFlow::Break(()) - } else { - ControlFlow::Continue(()) - } - } - - fn monitor( - this: WeakEntity, - io_task: Task>, - cx: &AsyncApp, - ) -> Task> { - cx.spawn(async move |cx| { - let result = io_task.await; - - match result { - Ok(exit_code) => { - if let Some(error) = ProxyLaunchError::from_exit_code(exit_code) { - match error { - ProxyLaunchError::ServerNotRunning => { - log::error!("failed to reconnect because server is not running"); - this.update(cx, |this, cx| { - this.set_state(State::ServerNotRunning, cx); - })?; - } - } - } else if exit_code > 0 { - log::error!("proxy process terminated unexpectedly"); - this.update(cx, |this, cx| { - this.reconnect(cx).ok(); - })?; - } - } - Err(error) => { - log::warn!("ssh io task died with error: {:?}. reconnecting...", error); - this.update(cx, |this, cx| { - this.reconnect(cx).ok(); - })?; - } - } - - Ok(()) - }) - } - - fn state_is(&self, check: impl FnOnce(&State) -> bool) -> bool { - self.state.lock().as_ref().is_some_and(check) - } - - fn try_set_state(&self, cx: &mut Context, map: impl FnOnce(&State) -> Option) { - let mut lock = self.state.lock(); - let new_state = lock.as_ref().and_then(map); - - if let Some(new_state) = new_state { - lock.replace(new_state); - cx.notify(); - } - } - - fn set_state(&self, state: State, cx: &mut Context) { - log::info!("setting state to '{}'", &state); - - let is_reconnect_exhausted = state.is_reconnect_exhausted(); - let is_server_not_running = state.is_server_not_running(); - self.state.lock().replace(state); - - if is_reconnect_exhausted || is_server_not_running { - cx.emit(SshRemoteEvent::Disconnected); - } - cx.notify(); - } - - pub fn ssh_info(&self) -> Option { - self.state - .lock() - .as_ref() - .and_then(|state| state.ssh_connection()) - .map(|ssh_connection| SshInfo { - args: ssh_connection.ssh_args(), - path_style: ssh_connection.path_style(), - shell: ssh_connection.shell(), - }) - } - - pub fn upload_directory( - &self, - src_path: PathBuf, - dest_path: RemotePathBuf, - cx: &App, - ) -> Task> { - let state = self.state.lock(); - let Some(connection) = state.as_ref().and_then(|state| state.ssh_connection()) else { - return Task::ready(Err(anyhow!("no ssh connection"))); - }; - connection.upload_directory(src_path, dest_path, cx) - } - - pub fn proto_client(&self) -> AnyProtoClient { - self.client.clone().into() - } - - pub fn connection_string(&self) -> String { - self.connection_options.connection_string() - } - - pub fn connection_options(&self) -> SshConnectionOptions { - self.connection_options.clone() - } - - pub fn connection_state(&self) -> ConnectionState { - self.state - .lock() - .as_ref() - .map(ConnectionState::from) - .unwrap_or(ConnectionState::Disconnected) - } - - pub fn is_disconnected(&self) -> bool { - self.connection_state() == ConnectionState::Disconnected - } - - pub fn path_style(&self) -> PathStyle { - self.path_style - } - - #[cfg(any(test, feature = "test-support"))] - pub fn simulate_disconnect(&self, client_cx: &mut App) -> Task<()> { - let opts = self.connection_options(); - client_cx.spawn(async move |cx| { - let connection = cx - .update_global(|c: &mut ConnectionPool, _| { - if let Some(ConnectionPoolEntry::Connecting(c)) = c.connections.get(&opts) { - c.clone() - } else { - panic!("missing test connection") - } - }) - .unwrap() - .await - .unwrap(); - - connection.simulate_disconnect(cx); - }) - } - - #[cfg(any(test, feature = "test-support"))] - pub fn fake_server( - client_cx: &mut gpui::TestAppContext, - server_cx: &mut gpui::TestAppContext, - ) -> (SshConnectionOptions, AnyProtoClient) { - let port = client_cx - .update(|cx| cx.default_global::().connections.len() as u16 + 1); - let opts = SshConnectionOptions { - host: "".to_string(), - port: Some(port), - ..Default::default() - }; - let (outgoing_tx, _) = mpsc::unbounded::(); - let (_, incoming_rx) = mpsc::unbounded::(); - let server_client = - server_cx.update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "fake-server")); - let connection: Arc = Arc::new(fake::FakeRemoteConnection { - connection_options: opts.clone(), - server_cx: fake::SendableCx::new(server_cx), - server_channel: server_client.clone(), - }); - - client_cx.update(|cx| { - cx.update_default_global(|c: &mut ConnectionPool, cx| { - c.connections.insert( - opts.clone(), - ConnectionPoolEntry::Connecting( - cx.background_spawn({ - let connection = connection.clone(); - async move { Ok(connection.clone()) } - }) - .shared(), - ), - ); - }) - }); - - (opts, server_client.into()) - } - - #[cfg(any(test, feature = "test-support"))] - pub async fn fake_client( - opts: SshConnectionOptions, - client_cx: &mut gpui::TestAppContext, - ) -> Entity { - let (_tx, rx) = oneshot::channel(); - client_cx - .update(|cx| { - Self::new( - ConnectionIdentifier::setup(), - opts, - rx, - Arc::new(fake::Delegate), - cx, - ) - }) - .await - .unwrap() - .unwrap() - } -} - -enum ConnectionPoolEntry { - Connecting(Shared, Arc>>>), - Connected(Weak), -} - -#[derive(Default)] -struct ConnectionPool { - connections: HashMap, -} - -impl Global for ConnectionPool {} - -impl ConnectionPool { - pub fn connect( - &mut self, - opts: SshConnectionOptions, - delegate: &Arc, - cx: &mut App, - ) -> Shared, Arc>>> { - let connection = self.connections.get(&opts); - match connection { - Some(ConnectionPoolEntry::Connecting(task)) => { - let delegate = delegate.clone(); - cx.spawn(async move |cx| { - delegate.set_status(Some("Waiting for existing connection attempt"), cx); - }) - .detach(); - return task.clone(); - } - Some(ConnectionPoolEntry::Connected(ssh)) => { - if let Some(ssh) = ssh.upgrade() - && !ssh.has_been_killed() - { - return Task::ready(Ok(ssh)).shared(); - } - self.connections.remove(&opts); - } - None => {} - } - - let task = cx - .spawn({ - let opts = opts.clone(); - let delegate = delegate.clone(); - async move |cx| { - let connection = SshRemoteConnection::new(opts.clone(), delegate, cx) - .await - .map(|connection| Arc::new(connection) as Arc); - - cx.update_global(|pool: &mut Self, _| { - debug_assert!(matches!( - pool.connections.get(&opts), - Some(ConnectionPoolEntry::Connecting(_)) - )); - match connection { - Ok(connection) => { - pool.connections.insert( - opts.clone(), - ConnectionPoolEntry::Connected(Arc::downgrade(&connection)), - ); - Ok(connection) - } - Err(error) => { - pool.connections.remove(&opts); - Err(Arc::new(error)) - } - } - })? - } - }) - .shared(); - - self.connections - .insert(opts.clone(), ConnectionPoolEntry::Connecting(task.clone())); - task - } -} - -impl From for AnyProtoClient { - fn from(client: SshRemoteClient) -> Self { - AnyProtoClient::new(client.client) - } -} - -#[async_trait(?Send)] -trait RemoteConnection: Send + Sync { - fn start_proxy( - &self, - unique_identifier: String, - reconnect: bool, - incoming_tx: UnboundedSender, - outgoing_rx: UnboundedReceiver, - connection_activity_tx: Sender<()>, - delegate: Arc, - cx: &mut AsyncApp, - ) -> Task>; - fn upload_directory( - &self, - src_path: PathBuf, - dest_path: RemotePathBuf, - cx: &App, - ) -> Task>; - async fn kill(&self) -> Result<()>; - fn has_been_killed(&self) -> bool; - /// On Windows, we need to use `SSH_ASKPASS` to provide the password to ssh. - /// On Linux, we use the `ControlPath` option to create a socket file that ssh can use to - fn ssh_args(&self) -> SshArgs; - fn connection_options(&self) -> SshConnectionOptions; - fn path_style(&self) -> PathStyle; - fn shell(&self) -> String; - - #[cfg(any(test, feature = "test-support"))] - fn simulate_disconnect(&self, _: &AsyncApp) {} -} - -struct SshRemoteConnection { - socket: SshSocket, - master_process: Mutex>, - remote_binary_path: Option, - ssh_platform: SshPlatform, - ssh_path_style: PathStyle, - ssh_shell: String, - _temp_dir: TempDir, -} - -#[async_trait(?Send)] -impl RemoteConnection for SshRemoteConnection { - async fn kill(&self) -> Result<()> { - let Some(mut process) = self.master_process.lock().take() else { - return Ok(()); - }; - process.kill().ok(); - process.status().await?; - Ok(()) - } - - fn has_been_killed(&self) -> bool { - self.master_process.lock().is_none() - } - - fn ssh_args(&self) -> SshArgs { - self.socket.ssh_args() - } - - fn connection_options(&self) -> SshConnectionOptions { - self.socket.connection_options.clone() - } - - fn shell(&self) -> String { - self.ssh_shell.clone() - } - - fn upload_directory( - &self, - src_path: PathBuf, - dest_path: RemotePathBuf, - cx: &App, - ) -> Task> { - let mut command = util::command::new_smol_command("scp"); - let output = self - .socket - .ssh_options(&mut command) - .args( - self.socket - .connection_options - .port - .map(|port| vec!["-P".to_string(), port.to_string()]) - .unwrap_or_default(), - ) - .arg("-C") - .arg("-r") - .arg(&src_path) - .arg(format!( - "{}:{}", - self.socket.connection_options.scp_url(), - dest_path - )) - .output(); - - cx.background_spawn(async move { - let output = output.await?; - - anyhow::ensure!( - output.status.success(), - "failed to upload directory {} -> {}: {}", - src_path.display(), - dest_path.to_string(), - String::from_utf8_lossy(&output.stderr) - ); - - Ok(()) - }) - } - - fn start_proxy( - &self, - unique_identifier: String, - reconnect: bool, - incoming_tx: UnboundedSender, - outgoing_rx: UnboundedReceiver, - connection_activity_tx: Sender<()>, - delegate: Arc, - cx: &mut AsyncApp, - ) -> Task> { - delegate.set_status(Some("Starting proxy"), cx); - - let Some(remote_binary_path) = self.remote_binary_path.clone() else { - return Task::ready(Err(anyhow!("Remote binary path not set"))); - }; - - let mut start_proxy_command = shell_script!( - "exec {binary_path} proxy --identifier {identifier}", - binary_path = &remote_binary_path.to_string(), - identifier = &unique_identifier, - ); - - for env_var in ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"] { - if let Some(value) = std::env::var(env_var).ok() { - start_proxy_command = format!( - "{}={} {} ", - env_var, - shlex::try_quote(&value).unwrap(), - start_proxy_command, - ); - } - } - - if reconnect { - start_proxy_command.push_str(" --reconnect"); - } - - let ssh_proxy_process = match self - .socket - .ssh_command("sh", &["-lc", &start_proxy_command]) - // IMPORTANT: we kill this process when we drop the task that uses it. - .kill_on_drop(true) - .spawn() - { - Ok(process) => process, - Err(error) => { - return Task::ready(Err(anyhow!("failed to spawn remote server: {}", error))); - } - }; - - Self::multiplex( - ssh_proxy_process, - incoming_tx, - outgoing_rx, - connection_activity_tx, - cx, - ) - } - - fn path_style(&self) -> PathStyle { - self.ssh_path_style - } -} - -impl SshRemoteConnection { - async fn new( - connection_options: SshConnectionOptions, - delegate: Arc, - cx: &mut AsyncApp, - ) -> Result { - use askpass::AskPassResult; - - delegate.set_status(Some("Connecting"), cx); - - let url = connection_options.ssh_url(); - - let temp_dir = tempfile::Builder::new() - .prefix("zed-ssh-session") - .tempdir()?; - let askpass_delegate = askpass::AskPassDelegate::new(cx, { - let delegate = delegate.clone(); - move |prompt, tx, cx| delegate.ask_password(prompt, tx, cx) - }); - - let mut askpass = - askpass::AskPassSession::new(cx.background_executor(), askpass_delegate).await?; - - // Start the master SSH process, which does not do anything except for establish - // the connection and keep it open, allowing other ssh commands to reuse it - // via a control socket. - #[cfg(not(target_os = "windows"))] - let socket_path = temp_dir.path().join("ssh.sock"); - - let mut master_process = { - #[cfg(not(target_os = "windows"))] - let args = [ - "-N", - "-o", - "ControlPersist=no", - "-o", - "ControlMaster=yes", - "-o", - ]; - // On Windows, `ControlMaster` and `ControlPath` are not supported: - // https://github.com/PowerShell/Win32-OpenSSH/issues/405 - // https://github.com/PowerShell/Win32-OpenSSH/wiki/Project-Scope - #[cfg(target_os = "windows")] - let args = ["-N"]; - let mut master_process = util::command::new_smol_command("ssh"); - master_process - .kill_on_drop(true) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .env("SSH_ASKPASS_REQUIRE", "force") - .env("SSH_ASKPASS", askpass.script_path()) - .args(connection_options.additional_args()) - .args(args); - #[cfg(not(target_os = "windows"))] - master_process.arg(format!("ControlPath={}", socket_path.display())); - master_process.arg(&url).spawn()? - }; - // Wait for this ssh process to close its stdout, indicating that authentication - // has completed. - let mut stdout = master_process.stdout.take().unwrap(); - let mut output = Vec::new(); - - let result = select_biased! { - result = askpass.run().fuse() => { - match result { - AskPassResult::CancelledByUser => { - master_process.kill().ok(); - anyhow::bail!("SSH connection canceled") - } - AskPassResult::Timedout => { - anyhow::bail!("connecting to host timed out") - } - } - } - _ = stdout.read_to_end(&mut output).fuse() => { - anyhow::Ok(()) - } - }; - - if let Err(e) = result { - return Err(e.context("Failed to connect to host")); - } - - if master_process.try_status()?.is_some() { - output.clear(); - let mut stderr = master_process.stderr.take().unwrap(); - stderr.read_to_end(&mut output).await?; - - let error_message = format!( - "failed to connect: {}", - String::from_utf8_lossy(&output).trim() - ); - anyhow::bail!(error_message); - } - - #[cfg(not(target_os = "windows"))] - let socket = SshSocket::new(connection_options, socket_path)?; - #[cfg(target_os = "windows")] - let socket = SshSocket::new(connection_options, &temp_dir, askpass.get_password())?; - drop(askpass); - - let ssh_platform = socket.platform().await?; - let ssh_path_style = match ssh_platform.os { - "windows" => PathStyle::Windows, - _ => PathStyle::Posix, - }; - let ssh_shell = socket.shell().await; - - let mut this = Self { - socket, - master_process: Mutex::new(Some(master_process)), - _temp_dir: temp_dir, - remote_binary_path: None, - ssh_path_style, - ssh_platform, - ssh_shell, - }; - - let (release_channel, version, commit) = cx.update(|cx| { - ( - ReleaseChannel::global(cx), - AppVersion::global(cx), - AppCommitSha::try_global(cx), - ) - })?; - this.remote_binary_path = Some( - this.ensure_server_binary(&delegate, release_channel, version, commit, cx) - .await?, - ); - - Ok(this) - } - - fn multiplex( - mut ssh_proxy_process: Child, - incoming_tx: UnboundedSender, - mut outgoing_rx: UnboundedReceiver, - mut connection_activity_tx: Sender<()>, - cx: &AsyncApp, - ) -> Task> { - let mut child_stderr = ssh_proxy_process.stderr.take().unwrap(); - let mut child_stdout = ssh_proxy_process.stdout.take().unwrap(); - let mut child_stdin = ssh_proxy_process.stdin.take().unwrap(); - - let mut stdin_buffer = Vec::new(); - let mut stdout_buffer = Vec::new(); - let mut stderr_buffer = Vec::new(); - let mut stderr_offset = 0; - - let stdin_task = cx.background_spawn(async move { - while let Some(outgoing) = outgoing_rx.next().await { - write_message(&mut child_stdin, &mut stdin_buffer, outgoing).await?; - } - anyhow::Ok(()) - }); - - let stdout_task = cx.background_spawn({ - let mut connection_activity_tx = connection_activity_tx.clone(); - async move { - loop { - stdout_buffer.resize(MESSAGE_LEN_SIZE, 0); - let len = child_stdout.read(&mut stdout_buffer).await?; - - if len == 0 { - return anyhow::Ok(()); - } - - if len < MESSAGE_LEN_SIZE { - child_stdout.read_exact(&mut stdout_buffer[len..]).await?; - } - - let message_len = message_len_from_buffer(&stdout_buffer); - let envelope = - read_message_with_len(&mut child_stdout, &mut stdout_buffer, message_len) - .await?; - connection_activity_tx.try_send(()).ok(); - incoming_tx.unbounded_send(envelope).ok(); - } - } - }); - - let stderr_task: Task> = cx.background_spawn(async move { - loop { - stderr_buffer.resize(stderr_offset + 1024, 0); - - let len = child_stderr - .read(&mut stderr_buffer[stderr_offset..]) - .await?; - if len == 0 { - return anyhow::Ok(()); - } - - stderr_offset += len; - let mut start_ix = 0; - while let Some(ix) = stderr_buffer[start_ix..stderr_offset] - .iter() - .position(|b| b == &b'\n') - { - let line_ix = start_ix + ix; - let content = &stderr_buffer[start_ix..line_ix]; - start_ix = line_ix + 1; - if let Ok(record) = serde_json::from_slice::(content) { - record.log(log::logger()) - } else { - eprintln!("(remote) {}", String::from_utf8_lossy(content)); - } - } - stderr_buffer.drain(0..start_ix); - stderr_offset -= start_ix; - - connection_activity_tx.try_send(()).ok(); - } - }); - - cx.background_spawn(async move { - let result = futures::select! { - result = stdin_task.fuse() => { - result.context("stdin") - } - result = stdout_task.fuse() => { - result.context("stdout") - } - result = stderr_task.fuse() => { - result.context("stderr") - } - }; - - let status = ssh_proxy_process.status().await?.code().unwrap_or(1); - match result { - Ok(_) => Ok(status), - Err(error) => Err(error), - } - }) - } - - #[allow(unused)] - async fn ensure_server_binary( - &self, - delegate: &Arc, - release_channel: ReleaseChannel, - version: SemanticVersion, - commit: Option, - cx: &mut AsyncApp, - ) -> Result { - let version_str = match release_channel { - ReleaseChannel::Nightly => { - let commit = commit.map(|s| s.full()).unwrap_or_default(); - format!("{}-{}", version, commit) - } - ReleaseChannel::Dev => "build".to_string(), - _ => version.to_string(), - }; - let binary_name = format!( - "zed-remote-server-{}-{}", - release_channel.dev_name(), - version_str - ); - let dst_path = RemotePathBuf::new( - paths::remote_server_dir_relative().join(binary_name), - self.ssh_path_style, - ); - - let build_remote_server = std::env::var("ZED_BUILD_REMOTE_SERVER").ok(); - #[cfg(debug_assertions)] - if let Some(build_remote_server) = build_remote_server { - let src_path = self.build_local(build_remote_server, delegate, cx).await?; - let tmp_path = RemotePathBuf::new( - paths::remote_server_dir_relative().join(format!( - "download-{}-{}", - std::process::id(), - src_path.file_name().unwrap().to_string_lossy() - )), - self.ssh_path_style, - ); - self.upload_local_server_binary(&src_path, &tmp_path, delegate, cx) - .await?; - self.extract_server_binary(&dst_path, &tmp_path, delegate, cx) - .await?; - return Ok(dst_path); - } - - if self - .socket - .run_command(&dst_path.to_string(), &["version"]) - .await - .is_ok() - { - return Ok(dst_path); - } - - let wanted_version = cx.update(|cx| match release_channel { - ReleaseChannel::Nightly => Ok(None), - ReleaseChannel::Dev => { - anyhow::bail!( - "ZED_BUILD_REMOTE_SERVER is not set and no remote server exists at ({:?})", - dst_path - ) - } - _ => Ok(Some(AppVersion::global(cx))), - })??; - - let tmp_path_gz = RemotePathBuf::new( - PathBuf::from(format!("{}-download-{}.gz", dst_path, std::process::id())), - self.ssh_path_style, - ); - if !self.socket.connection_options.upload_binary_over_ssh - && let Some((url, body)) = delegate - .get_download_params(self.ssh_platform, release_channel, wanted_version, cx) - .await? - { - match self - .download_binary_on_server(&url, &body, &tmp_path_gz, delegate, cx) - .await - { - Ok(_) => { - self.extract_server_binary(&dst_path, &tmp_path_gz, delegate, cx) - .await?; - return Ok(dst_path); - } - Err(e) => { - log::error!( - "Failed to download binary on server, attempting to upload server: {}", - e - ) - } - } - } - - let src_path = delegate - .download_server_binary_locally(self.ssh_platform, release_channel, wanted_version, cx) - .await?; - self.upload_local_server_binary(&src_path, &tmp_path_gz, delegate, cx) - .await?; - self.extract_server_binary(&dst_path, &tmp_path_gz, delegate, cx) - .await?; - Ok(dst_path) - } - - async fn download_binary_on_server( - &self, - url: &str, - body: &str, - tmp_path_gz: &RemotePathBuf, - delegate: &Arc, - cx: &mut AsyncApp, - ) -> Result<()> { - if let Some(parent) = tmp_path_gz.parent() { - self.socket - .run_command( - "sh", - &[ - "-lc", - &shell_script!("mkdir -p {parent}", parent = parent.to_string().as_ref()), - ], - ) - .await?; - } - - delegate.set_status(Some("Downloading remote development server on host"), cx); - - match self - .socket - .run_command( - "curl", - &[ - "-f", - "-L", - "-X", - "GET", - "-H", - "Content-Type: application/json", - "-d", - body, - url, - "-o", - &tmp_path_gz.to_string(), - ], - ) - .await - { - Ok(_) => {} - Err(e) => { - if self.socket.run_command("which", &["curl"]).await.is_ok() { - return Err(e); - } - - match self - .socket - .run_command( - "wget", - &[ - "--method=GET", - "--header=Content-Type: application/json", - "--body-data", - body, - url, - "-O", - &tmp_path_gz.to_string(), - ], - ) - .await - { - Ok(_) => {} - Err(e) => { - if self.socket.run_command("which", &["wget"]).await.is_ok() { - return Err(e); - } else { - anyhow::bail!("Neither curl nor wget is available"); - } - } - } - } - } - - Ok(()) - } - - async fn upload_local_server_binary( - &self, - src_path: &Path, - tmp_path_gz: &RemotePathBuf, - delegate: &Arc, - cx: &mut AsyncApp, - ) -> Result<()> { - if let Some(parent) = tmp_path_gz.parent() { - self.socket - .run_command( - "sh", - &[ - "-lc", - &shell_script!("mkdir -p {parent}", parent = parent.to_string().as_ref()), - ], - ) - .await?; - } - - let src_stat = fs::metadata(&src_path).await?; - let size = src_stat.len(); - - let t0 = Instant::now(); - delegate.set_status(Some("Uploading remote development server"), cx); - log::info!( - "uploading remote development server to {:?} ({}kb)", - tmp_path_gz, - size / 1024 - ); - self.upload_file(src_path, tmp_path_gz) - .await - .context("failed to upload server binary")?; - log::info!("uploaded remote development server in {:?}", t0.elapsed()); - Ok(()) - } - - async fn extract_server_binary( - &self, - dst_path: &RemotePathBuf, - tmp_path: &RemotePathBuf, - delegate: &Arc, - cx: &mut AsyncApp, - ) -> Result<()> { - delegate.set_status(Some("Extracting remote development server"), cx); - let server_mode = 0o755; - - let orig_tmp_path = tmp_path.to_string(); - let script = if let Some(tmp_path) = orig_tmp_path.strip_suffix(".gz") { - shell_script!( - "gunzip -f {orig_tmp_path} && chmod {server_mode} {tmp_path} && mv {tmp_path} {dst_path}", - server_mode = &format!("{:o}", server_mode), - dst_path = &dst_path.to_string(), - ) - } else { - shell_script!( - "chmod {server_mode} {orig_tmp_path} && mv {orig_tmp_path} {dst_path}", - server_mode = &format!("{:o}", server_mode), - dst_path = &dst_path.to_string() - ) - }; - self.socket.run_command("sh", &["-lc", &script]).await?; - Ok(()) - } - - async fn upload_file(&self, src_path: &Path, dest_path: &RemotePathBuf) -> Result<()> { - log::debug!("uploading file {:?} to {:?}", src_path, dest_path); - let mut command = util::command::new_smol_command("scp"); - let output = self - .socket - .ssh_options(&mut command) - .args( - self.socket - .connection_options - .port - .map(|port| vec!["-P".to_string(), port.to_string()]) - .unwrap_or_default(), - ) - .arg(src_path) - .arg(format!( - "{}:{}", - self.socket.connection_options.scp_url(), - dest_path - )) - .output() - .await?; - - anyhow::ensure!( - output.status.success(), - "failed to upload file {} -> {}: {}", - src_path.display(), - dest_path.to_string(), - String::from_utf8_lossy(&output.stderr) - ); - Ok(()) - } - - #[cfg(debug_assertions)] - async fn build_local( - &self, - build_remote_server: String, - delegate: &Arc, - cx: &mut AsyncApp, - ) -> Result { - use smol::process::{Command, Stdio}; - use std::env::VarError; - - async fn run_cmd(command: &mut Command) -> Result<()> { - let output = command - .kill_on_drop(true) - .stderr(Stdio::inherit()) - .output() - .await?; - anyhow::ensure!( - output.status.success(), - "Failed to run command: {command:?}" - ); - Ok(()) - } - - let use_musl = !build_remote_server.contains("nomusl"); - let triple = format!( - "{}-{}", - self.ssh_platform.arch, - match self.ssh_platform.os { - "linux" => - if use_musl { - "unknown-linux-musl" - } else { - "unknown-linux-gnu" - }, - "macos" => "apple-darwin", - _ => anyhow::bail!("can't cross compile for: {:?}", self.ssh_platform), - } - ); - let mut rust_flags = match std::env::var("RUSTFLAGS") { - Ok(val) => val, - Err(VarError::NotPresent) => String::new(), - Err(e) => { - log::error!("Failed to get env var `RUSTFLAGS` value: {e}"); - String::new() - } - }; - if self.ssh_platform.os == "linux" && use_musl { - rust_flags.push_str(" -C target-feature=+crt-static"); - } - if build_remote_server.contains("mold") { - rust_flags.push_str(" -C link-arg=-fuse-ld=mold"); - } - - if self.ssh_platform.arch == std::env::consts::ARCH - && self.ssh_platform.os == std::env::consts::OS - { - delegate.set_status(Some("Building remote server binary from source"), cx); - log::info!("building remote server binary from source"); - run_cmd( - Command::new("cargo") - .args([ - "build", - "--package", - "remote_server", - "--features", - "debug-embed", - "--target-dir", - "target/remote_server", - "--target", - &triple, - ]) - .env("RUSTFLAGS", &rust_flags), - ) - .await?; - } else if build_remote_server.contains("cross") { - #[cfg(target_os = "windows")] - use util::paths::SanitizedPath; - - delegate.set_status(Some("Installing cross.rs for cross-compilation"), cx); - log::info!("installing cross"); - run_cmd(Command::new("cargo").args([ - "install", - "cross", - "--git", - "https://github.com/cross-rs/cross", - ])) - .await?; - - delegate.set_status( - Some(&format!( - "Building remote server binary from source for {} with Docker", - &triple - )), - cx, - ); - log::info!("building remote server binary from source for {}", &triple); - - // On Windows, the binding needs to be set to the canonical path - #[cfg(target_os = "windows")] - let src = - SanitizedPath::from(smol::fs::canonicalize("./target").await?).to_glob_string(); - #[cfg(not(target_os = "windows"))] - let src = "./target"; - run_cmd( - Command::new("cross") - .args([ - "build", - "--package", - "remote_server", - "--features", - "debug-embed", - "--target-dir", - "target/remote_server", - "--target", - &triple, - ]) - .env( - "CROSS_CONTAINER_OPTS", - format!("--mount type=bind,src={src},dst=/app/target"), - ) - .env("RUSTFLAGS", &rust_flags), - ) - .await?; - } else { - let which = cx - .background_spawn(async move { which::which("zig") }) - .await; - - if which.is_err() { - #[cfg(not(target_os = "windows"))] - { - anyhow::bail!( - "zig not found on $PATH, install zig (see https://ziglang.org/learn/getting-started or use zigup) or pass ZED_BUILD_REMOTE_SERVER=cross to use cross" - ) - } - #[cfg(target_os = "windows")] - { - anyhow::bail!( - "zig not found on $PATH, install zig (use `winget install -e --id zig.zig` or see https://ziglang.org/learn/getting-started or use zigup) or pass ZED_BUILD_REMOTE_SERVER=cross to use cross" - ) - } - } - - delegate.set_status(Some("Adding rustup target for cross-compilation"), cx); - log::info!("adding rustup target"); - run_cmd(Command::new("rustup").args(["target", "add"]).arg(&triple)).await?; - - delegate.set_status(Some("Installing cargo-zigbuild for cross-compilation"), cx); - log::info!("installing cargo-zigbuild"); - run_cmd(Command::new("cargo").args(["install", "--locked", "cargo-zigbuild"])).await?; - - delegate.set_status( - Some(&format!( - "Building remote binary from source for {triple} with Zig" - )), - cx, - ); - log::info!("building remote binary from source for {triple} with Zig"); - run_cmd( - Command::new("cargo") - .args([ - "zigbuild", - "--package", - "remote_server", - "--features", - "debug-embed", - "--target-dir", - "target/remote_server", - "--target", - &triple, - ]) - .env("RUSTFLAGS", &rust_flags), - ) - .await?; - }; - let bin_path = Path::new("target") - .join("remote_server") - .join(&triple) - .join("debug") - .join("remote_server"); - - let path = if !build_remote_server.contains("nocompress") { - delegate.set_status(Some("Compressing binary"), cx); - - #[cfg(not(target_os = "windows"))] - { - run_cmd(Command::new("gzip").args(["-f", &bin_path.to_string_lossy()])).await?; - } - #[cfg(target_os = "windows")] - { - // On Windows, we use 7z to compress the binary - let seven_zip = which::which("7z.exe").context("7z.exe not found on $PATH, install it (e.g. with `winget install -e --id 7zip.7zip`) or, if you don't want this behaviour, set $env:ZED_BUILD_REMOTE_SERVER=\"nocompress\"")?; - let gz_path = format!("target/remote_server/{}/debug/remote_server.gz", triple); - if smol::fs::metadata(&gz_path).await.is_ok() { - smol::fs::remove_file(&gz_path).await?; - } - run_cmd(Command::new(seven_zip).args([ - "a", - "-tgzip", - &gz_path, - &bin_path.to_string_lossy(), - ])) - .await?; - } - - let mut archive_path = bin_path; - archive_path.set_extension("gz"); - std::env::current_dir()?.join(archive_path) - } else { - bin_path - }; - - Ok(path) - } -} - -type ResponseChannels = Mutex)>>>; - -struct ChannelClient { - next_message_id: AtomicU32, - outgoing_tx: Mutex>, - buffer: Mutex>, - response_channels: ResponseChannels, - message_handlers: Mutex, - max_received: AtomicU32, - name: &'static str, - task: Mutex>>, -} - -impl ChannelClient { - fn new( - incoming_rx: mpsc::UnboundedReceiver, - outgoing_tx: mpsc::UnboundedSender, - cx: &App, - name: &'static str, - ) -> Arc { - Arc::new_cyclic(|this| Self { - outgoing_tx: Mutex::new(outgoing_tx), - next_message_id: AtomicU32::new(0), - max_received: AtomicU32::new(0), - response_channels: ResponseChannels::default(), - message_handlers: Default::default(), - buffer: Mutex::new(VecDeque::new()), - name, - task: Mutex::new(Self::start_handling_messages( - this.clone(), - incoming_rx, - &cx.to_async(), - )), - }) - } - - fn start_handling_messages( - this: Weak, - mut incoming_rx: mpsc::UnboundedReceiver, - cx: &AsyncApp, - ) -> Task> { - cx.spawn(async move |cx| { - let peer_id = PeerId { owner_id: 0, id: 0 }; - while let Some(incoming) = incoming_rx.next().await { - let Some(this) = this.upgrade() else { - return anyhow::Ok(()); - }; - if let Some(ack_id) = incoming.ack_id { - let mut buffer = this.buffer.lock(); - while buffer.front().is_some_and(|msg| msg.id <= ack_id) { - buffer.pop_front(); - } - } - if let Some(proto::envelope::Payload::FlushBufferedMessages(_)) = &incoming.payload - { - log::debug!( - "{}:ssh message received. name:FlushBufferedMessages", - this.name - ); - { - let buffer = this.buffer.lock(); - for envelope in buffer.iter() { - this.outgoing_tx - .lock() - .unbounded_send(envelope.clone()) - .ok(); - } - } - let mut envelope = proto::Ack {}.into_envelope(0, Some(incoming.id), None); - envelope.id = this.next_message_id.fetch_add(1, SeqCst); - this.outgoing_tx.lock().unbounded_send(envelope).ok(); - continue; - } - - this.max_received.store(incoming.id, SeqCst); - - if let Some(request_id) = incoming.responding_to { - let request_id = MessageId(request_id); - let sender = this.response_channels.lock().remove(&request_id); - if let Some(sender) = sender { - let (tx, rx) = oneshot::channel(); - if incoming.payload.is_some() { - sender.send((incoming, tx)).ok(); - } - rx.await.ok(); - } - } else if let Some(envelope) = - build_typed_envelope(peer_id, Instant::now(), incoming) - { - let type_name = envelope.payload_type_name(); - let message_id = envelope.message_id(); - if let Some(future) = ProtoMessageHandlerSet::handle_message( - &this.message_handlers, - envelope, - this.clone().into(), - cx.clone(), - ) { - log::debug!("{}:ssh message received. name:{type_name}", this.name); - cx.foreground_executor() - .spawn(async move { - match future.await { - Ok(_) => { - log::debug!( - "{}:ssh message handled. name:{type_name}", - this.name - ); - } - Err(error) => { - log::error!( - "{}:error handling message. type:{}, error:{}", - this.name, - type_name, - format!("{error:#}").lines().fold( - String::new(), - |mut message, line| { - if !message.is_empty() { - message.push(' '); - } - message.push_str(line); - message - } - ) - ); - } - } - }) - .detach() - } else { - log::error!("{}:unhandled ssh message name:{type_name}", this.name); - if let Err(e) = AnyProtoClient::from(this.clone()).send_response( - message_id, - anyhow::anyhow!("no handler registered for {type_name}").to_proto(), - ) { - log::error!( - "{}:error sending error response for {type_name}:{e:#}", - this.name - ); - } - } - } - } - anyhow::Ok(()) - }) - } - - fn reconnect( - self: &Arc, - incoming_rx: UnboundedReceiver, - outgoing_tx: UnboundedSender, - cx: &AsyncApp, - ) { - *self.outgoing_tx.lock() = outgoing_tx; - *self.task.lock() = Self::start_handling_messages(Arc::downgrade(self), incoming_rx, cx); - } - - fn request( - &self, - payload: T, - ) -> impl 'static + Future> { - self.request_internal(payload, true) - } - - fn request_internal( - &self, - payload: T, - use_buffer: bool, - ) -> impl 'static + Future> { - log::debug!("ssh request start. name:{}", T::NAME); - let response = - self.request_dynamic(payload.into_envelope(0, None, None), T::NAME, use_buffer); - async move { - let response = response.await?; - log::debug!("ssh request finish. name:{}", T::NAME); - T::Response::from_envelope(response).context("received a response of the wrong type") - } - } - - async fn resync(&self, timeout: Duration) -> Result<()> { - smol::future::or( - async { - self.request_internal(proto::FlushBufferedMessages {}, false) - .await?; - - for envelope in self.buffer.lock().iter() { - self.outgoing_tx - .lock() - .unbounded_send(envelope.clone()) - .ok(); - } - Ok(()) - }, - async { - smol::Timer::after(timeout).await; - anyhow::bail!("Timed out resyncing remote client") - }, - ) - .await - } - - async fn ping(&self, timeout: Duration) -> Result<()> { - smol::future::or( - async { - self.request(proto::Ping {}).await?; - Ok(()) - }, - async { - smol::Timer::after(timeout).await; - anyhow::bail!("Timed out pinging remote client") - }, - ) - .await - } - - pub fn send(&self, payload: T) -> Result<()> { - log::debug!("ssh send name:{}", T::NAME); - self.send_dynamic(payload.into_envelope(0, None, None)) - } - - fn request_dynamic( - &self, - mut envelope: proto::Envelope, - type_name: &'static str, - use_buffer: bool, - ) -> impl 'static + Future> { - envelope.id = self.next_message_id.fetch_add(1, SeqCst); - let (tx, rx) = oneshot::channel(); - let mut response_channels_lock = self.response_channels.lock(); - response_channels_lock.insert(MessageId(envelope.id), tx); - drop(response_channels_lock); - - let result = if use_buffer { - self.send_buffered(envelope) - } else { - self.send_unbuffered(envelope) - }; - async move { - if let Err(error) = &result { - log::error!("failed to send message: {error}"); - anyhow::bail!("failed to send message: {error}"); - } - - let response = rx.await.context("connection lost")?.0; - if let Some(proto::envelope::Payload::Error(error)) = &response.payload { - return Err(RpcError::from_proto(error, type_name)); - } - Ok(response) - } - } - - pub fn send_dynamic(&self, mut envelope: proto::Envelope) -> Result<()> { - envelope.id = self.next_message_id.fetch_add(1, SeqCst); - self.send_buffered(envelope) - } - - fn send_buffered(&self, mut envelope: proto::Envelope) -> Result<()> { - envelope.ack_id = Some(self.max_received.load(SeqCst)); - self.buffer.lock().push_back(envelope.clone()); - // ignore errors on send (happen while we're reconnecting) - // assume that the global "disconnected" overlay is sufficient. - self.outgoing_tx.lock().unbounded_send(envelope).ok(); - Ok(()) - } - - fn send_unbuffered(&self, mut envelope: proto::Envelope) -> Result<()> { - envelope.ack_id = Some(self.max_received.load(SeqCst)); - self.outgoing_tx.lock().unbounded_send(envelope).ok(); - Ok(()) - } -} - -impl ProtoClient for ChannelClient { - fn request( - &self, - envelope: proto::Envelope, - request_type: &'static str, - ) -> BoxFuture<'static, Result> { - self.request_dynamic(envelope, request_type, true).boxed() - } - - fn send(&self, envelope: proto::Envelope, _message_type: &'static str) -> Result<()> { - self.send_dynamic(envelope) - } - - fn send_response(&self, envelope: Envelope, _message_type: &'static str) -> anyhow::Result<()> { - self.send_dynamic(envelope) - } - - fn message_handler_set(&self) -> &Mutex { - &self.message_handlers - } - - fn is_via_collab(&self) -> bool { - false - } -} - -#[cfg(any(test, feature = "test-support"))] -mod fake { - use std::{path::PathBuf, sync::Arc}; - - use anyhow::Result; - use async_trait::async_trait; - use futures::{ - FutureExt, SinkExt, StreamExt, - channel::{ - mpsc::{self, Sender}, - oneshot, - }, - select_biased, - }; - use gpui::{App, AppContext as _, AsyncApp, SemanticVersion, Task, TestAppContext}; - use release_channel::ReleaseChannel; - use rpc::proto::Envelope; - use util::paths::{PathStyle, RemotePathBuf}; - - use super::{ - ChannelClient, RemoteConnection, SshArgs, SshClientDelegate, SshConnectionOptions, - SshPlatform, - }; - - pub(super) struct FakeRemoteConnection { - pub(super) connection_options: SshConnectionOptions, - pub(super) server_channel: Arc, - pub(super) server_cx: SendableCx, - } - - pub(super) struct SendableCx(AsyncApp); - impl SendableCx { - // SAFETY: When run in test mode, GPUI is always single threaded. - pub(super) fn new(cx: &TestAppContext) -> Self { - Self(cx.to_async()) - } - - // SAFETY: Enforce that we're on the main thread by requiring a valid AsyncApp - fn get(&self, _: &AsyncApp) -> AsyncApp { - self.0.clone() - } - } - - // SAFETY: There is no way to access a SendableCx from a different thread, see [`SendableCx::new`] and [`SendableCx::get`] - unsafe impl Send for SendableCx {} - unsafe impl Sync for SendableCx {} - - #[async_trait(?Send)] - impl RemoteConnection for FakeRemoteConnection { - async fn kill(&self) -> Result<()> { - Ok(()) - } - - fn has_been_killed(&self) -> bool { - false - } - - fn ssh_args(&self) -> SshArgs { - SshArgs { - arguments: Vec::new(), - envs: None, - } - } - - fn upload_directory( - &self, - _src_path: PathBuf, - _dest_path: RemotePathBuf, - _cx: &App, - ) -> Task> { - unreachable!() - } - - fn connection_options(&self) -> SshConnectionOptions { - self.connection_options.clone() - } - - fn simulate_disconnect(&self, cx: &AsyncApp) { - let (outgoing_tx, _) = mpsc::unbounded::(); - let (_, incoming_rx) = mpsc::unbounded::(); - self.server_channel - .reconnect(incoming_rx, outgoing_tx, &self.server_cx.get(cx)); - } - - fn start_proxy( - &self, - _unique_identifier: String, - _reconnect: bool, - mut client_incoming_tx: mpsc::UnboundedSender, - mut client_outgoing_rx: mpsc::UnboundedReceiver, - mut connection_activity_tx: Sender<()>, - _delegate: Arc, - cx: &mut AsyncApp, - ) -> Task> { - let (mut server_incoming_tx, server_incoming_rx) = mpsc::unbounded::(); - let (server_outgoing_tx, mut server_outgoing_rx) = mpsc::unbounded::(); - - self.server_channel.reconnect( - server_incoming_rx, - server_outgoing_tx, - &self.server_cx.get(cx), - ); - - cx.background_spawn(async move { - loop { - select_biased! { - server_to_client = server_outgoing_rx.next().fuse() => { - let Some(server_to_client) = server_to_client else { - return Ok(1) - }; - connection_activity_tx.try_send(()).ok(); - client_incoming_tx.send(server_to_client).await.ok(); - } - client_to_server = client_outgoing_rx.next().fuse() => { - let Some(client_to_server) = client_to_server else { - return Ok(1) - }; - server_incoming_tx.send(client_to_server).await.ok(); - } - } - } - }) - } - - fn path_style(&self) -> PathStyle { - PathStyle::current() - } - - fn shell(&self) -> String { - "sh".to_owned() - } - } - - pub(super) struct Delegate; - - impl SshClientDelegate for Delegate { - fn ask_password(&self, _: String, _: oneshot::Sender, _: &mut AsyncApp) { - unreachable!() - } - - fn download_server_binary_locally( - &self, - _: SshPlatform, - _: ReleaseChannel, - _: Option, - _: &mut AsyncApp, - ) -> Task> { - unreachable!() - } - - fn get_download_params( - &self, - _platform: SshPlatform, - _release_channel: ReleaseChannel, - _version: Option, - _cx: &mut AsyncApp, - ) -> Task>> { - unreachable!() - } - - fn set_status(&self, _: Option<&str>, _: &mut AsyncApp) {} - } -} diff --git a/crates/remote/src/transport.rs b/crates/remote/src/transport.rs new file mode 100644 index 0000000000000000000000000000000000000000..aa086fd3f56196e71224ef346c9810e8638c5c47 --- /dev/null +++ b/crates/remote/src/transport.rs @@ -0,0 +1 @@ +pub mod ssh; diff --git a/crates/remote/src/transport/ssh.rs b/crates/remote/src/transport/ssh.rs new file mode 100644 index 0000000000000000000000000000000000000000..193b96497375e5dee19aacaff114ba79d78a7191 --- /dev/null +++ b/crates/remote/src/transport/ssh.rs @@ -0,0 +1,1358 @@ +use crate::{ + RemoteClientDelegate, RemotePlatform, + json_log::LogRecord, + protocol::{MESSAGE_LEN_SIZE, message_len_from_buffer, read_message_with_len, write_message}, + remote_client::{CommandTemplate, RemoteConnection}, +}; +use anyhow::{Context as _, Result, anyhow}; +use async_trait::async_trait; +use collections::HashMap; +use futures::{ + AsyncReadExt as _, FutureExt as _, StreamExt as _, + channel::mpsc::{Sender, UnboundedReceiver, UnboundedSender}, + select_biased, +}; +use gpui::{App, AppContext as _, AsyncApp, SemanticVersion, Task}; +use itertools::Itertools; +use parking_lot::Mutex; +use release_channel::{AppCommitSha, AppVersion, ReleaseChannel}; +use rpc::proto::Envelope; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use smol::{ + fs, + process::{self, Child, Stdio}, +}; +use std::{ + iter, + path::{Path, PathBuf}, + sync::Arc, + time::Instant, +}; +use tempfile::TempDir; +use util::paths::{PathStyle, RemotePathBuf}; + +pub(crate) struct SshRemoteConnection { + socket: SshSocket, + master_process: Mutex>, + remote_binary_path: Option, + ssh_platform: RemotePlatform, + ssh_path_style: PathStyle, + ssh_shell: String, + _temp_dir: TempDir, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] +pub struct SshConnectionOptions { + pub host: String, + pub username: Option, + pub port: Option, + pub password: Option, + pub args: Option>, + pub port_forwards: Option>, + + pub nickname: Option, + pub upload_binary_over_ssh: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize, JsonSchema)] +pub struct SshPortForwardOption { + #[serde(skip_serializing_if = "Option::is_none")] + pub local_host: Option, + pub local_port: u16, + #[serde(skip_serializing_if = "Option::is_none")] + pub remote_host: Option, + pub remote_port: u16, +} + +#[derive(Clone)] +struct SshSocket { + connection_options: SshConnectionOptions, + #[cfg(not(target_os = "windows"))] + socket_path: PathBuf, + envs: HashMap, +} + +macro_rules! shell_script { + ($fmt:expr, $($name:ident = $arg:expr),+ $(,)?) => {{ + format!( + $fmt, + $( + $name = shlex::try_quote($arg).unwrap() + ),+ + ) + }}; +} + +#[async_trait(?Send)] +impl RemoteConnection for SshRemoteConnection { + async fn kill(&self) -> Result<()> { + let Some(mut process) = self.master_process.lock().take() else { + return Ok(()); + }; + process.kill().ok(); + process.status().await?; + Ok(()) + } + + fn has_been_killed(&self) -> bool { + self.master_process.lock().is_none() + } + + fn connection_options(&self) -> SshConnectionOptions { + self.socket.connection_options.clone() + } + + fn shell(&self) -> String { + self.ssh_shell.clone() + } + + fn build_command( + &self, + input_program: Option, + input_args: &[String], + input_env: &HashMap, + working_dir: Option, + port_forward: Option<(u16, String, u16)>, + ) -> Result { + use std::fmt::Write as _; + + let mut script = String::new(); + if let Some(working_dir) = working_dir { + let working_dir = + RemotePathBuf::new(working_dir.into(), self.ssh_path_style).to_string(); + + // shlex will wrap the command in single quotes (''), disabling ~ expansion, + // replace ith with something that works + const TILDE_PREFIX: &'static str = "~/"; + if working_dir.starts_with(TILDE_PREFIX) { + let working_dir = working_dir.trim_start_matches("~").trim_start_matches("/"); + write!(&mut script, "cd \"$HOME/{working_dir}\"; ").unwrap(); + } else { + write!(&mut script, "cd \"{working_dir}\"; ").unwrap(); + } + } else { + write!(&mut script, "cd; ").unwrap(); + }; + + for (k, v) in input_env.iter() { + if let Some((k, v)) = shlex::try_quote(k).ok().zip(shlex::try_quote(v).ok()) { + write!(&mut script, "{}={} ", k, v).unwrap(); + } + } + + let shell = &self.ssh_shell; + + if let Some(input_program) = input_program { + let command = shlex::try_quote(&input_program)?; + script.push_str(&command); + for arg in input_args { + let arg = shlex::try_quote(&arg)?; + script.push_str(" "); + script.push_str(&arg); + } + } else { + write!(&mut script, "exec {shell} -l").unwrap(); + }; + + let shell_invocation = format!("{shell} -c {}", shlex::try_quote(&script).unwrap()); + + let mut args = Vec::new(); + args.extend(self.socket.ssh_args()); + + if let Some((local_port, host, remote_port)) = port_forward { + args.push("-L".into()); + args.push(format!("{local_port}:{host}:{remote_port}")); + } + + args.push("-t".into()); + args.push(shell_invocation); + + Ok(CommandTemplate { + program: "ssh".into(), + args, + env: self.socket.envs.clone(), + }) + } + + fn upload_directory( + &self, + src_path: PathBuf, + dest_path: RemotePathBuf, + cx: &App, + ) -> Task> { + let mut command = util::command::new_smol_command("scp"); + let output = self + .socket + .ssh_options(&mut command) + .args( + self.socket + .connection_options + .port + .map(|port| vec!["-P".to_string(), port.to_string()]) + .unwrap_or_default(), + ) + .arg("-C") + .arg("-r") + .arg(&src_path) + .arg(format!( + "{}:{}", + self.socket.connection_options.scp_url(), + dest_path + )) + .output(); + + cx.background_spawn(async move { + let output = output.await?; + + anyhow::ensure!( + output.status.success(), + "failed to upload directory {} -> {}: {}", + src_path.display(), + dest_path.to_string(), + String::from_utf8_lossy(&output.stderr) + ); + + Ok(()) + }) + } + + fn start_proxy( + &self, + unique_identifier: String, + reconnect: bool, + incoming_tx: UnboundedSender, + outgoing_rx: UnboundedReceiver, + connection_activity_tx: Sender<()>, + delegate: Arc, + cx: &mut AsyncApp, + ) -> Task> { + delegate.set_status(Some("Starting proxy"), cx); + + let Some(remote_binary_path) = self.remote_binary_path.clone() else { + return Task::ready(Err(anyhow!("Remote binary path not set"))); + }; + + let mut start_proxy_command = shell_script!( + "exec {binary_path} proxy --identifier {identifier}", + binary_path = &remote_binary_path.to_string(), + identifier = &unique_identifier, + ); + + for env_var in ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"] { + if let Some(value) = std::env::var(env_var).ok() { + start_proxy_command = format!( + "{}={} {} ", + env_var, + shlex::try_quote(&value).unwrap(), + start_proxy_command, + ); + } + } + + if reconnect { + start_proxy_command.push_str(" --reconnect"); + } + + let ssh_proxy_process = match self + .socket + .ssh_command("sh", &["-lc", &start_proxy_command]) + // IMPORTANT: we kill this process when we drop the task that uses it. + .kill_on_drop(true) + .spawn() + { + Ok(process) => process, + Err(error) => { + return Task::ready(Err(anyhow!("failed to spawn remote server: {}", error))); + } + }; + + Self::multiplex( + ssh_proxy_process, + incoming_tx, + outgoing_rx, + connection_activity_tx, + cx, + ) + } + + fn path_style(&self) -> PathStyle { + self.ssh_path_style + } +} + +impl SshRemoteConnection { + pub(crate) async fn new( + connection_options: SshConnectionOptions, + delegate: Arc, + cx: &mut AsyncApp, + ) -> Result { + use askpass::AskPassResult; + + delegate.set_status(Some("Connecting"), cx); + + let url = connection_options.ssh_url(); + + let temp_dir = tempfile::Builder::new() + .prefix("zed-ssh-session") + .tempdir()?; + let askpass_delegate = askpass::AskPassDelegate::new(cx, { + let delegate = delegate.clone(); + move |prompt, tx, cx| delegate.ask_password(prompt, tx, cx) + }); + + let mut askpass = + askpass::AskPassSession::new(cx.background_executor(), askpass_delegate).await?; + + // Start the master SSH process, which does not do anything except for establish + // the connection and keep it open, allowing other ssh commands to reuse it + // via a control socket. + #[cfg(not(target_os = "windows"))] + let socket_path = temp_dir.path().join("ssh.sock"); + + let mut master_process = { + #[cfg(not(target_os = "windows"))] + let args = [ + "-N", + "-o", + "ControlPersist=no", + "-o", + "ControlMaster=yes", + "-o", + ]; + // On Windows, `ControlMaster` and `ControlPath` are not supported: + // https://github.com/PowerShell/Win32-OpenSSH/issues/405 + // https://github.com/PowerShell/Win32-OpenSSH/wiki/Project-Scope + #[cfg(target_os = "windows")] + let args = ["-N"]; + let mut master_process = util::command::new_smol_command("ssh"); + master_process + .kill_on_drop(true) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .env("SSH_ASKPASS_REQUIRE", "force") + .env("SSH_ASKPASS", askpass.script_path()) + .args(connection_options.additional_args()) + .args(args); + #[cfg(not(target_os = "windows"))] + master_process.arg(format!("ControlPath={}", socket_path.display())); + master_process.arg(&url).spawn()? + }; + // Wait for this ssh process to close its stdout, indicating that authentication + // has completed. + let mut stdout = master_process.stdout.take().unwrap(); + let mut output = Vec::new(); + + let result = select_biased! { + result = askpass.run().fuse() => { + match result { + AskPassResult::CancelledByUser => { + master_process.kill().ok(); + anyhow::bail!("SSH connection canceled") + } + AskPassResult::Timedout => { + anyhow::bail!("connecting to host timed out") + } + } + } + _ = stdout.read_to_end(&mut output).fuse() => { + anyhow::Ok(()) + } + }; + + if let Err(e) = result { + return Err(e.context("Failed to connect to host")); + } + + if master_process.try_status()?.is_some() { + output.clear(); + let mut stderr = master_process.stderr.take().unwrap(); + stderr.read_to_end(&mut output).await?; + + let error_message = format!( + "failed to connect: {}", + String::from_utf8_lossy(&output).trim() + ); + anyhow::bail!(error_message); + } + + #[cfg(not(target_os = "windows"))] + let socket = SshSocket::new(connection_options, socket_path)?; + #[cfg(target_os = "windows")] + let socket = SshSocket::new(connection_options, &temp_dir, askpass.get_password())?; + drop(askpass); + + let ssh_platform = socket.platform().await?; + let ssh_path_style = match ssh_platform.os { + "windows" => PathStyle::Windows, + _ => PathStyle::Posix, + }; + let ssh_shell = socket.shell().await; + + let mut this = Self { + socket, + master_process: Mutex::new(Some(master_process)), + _temp_dir: temp_dir, + remote_binary_path: None, + ssh_path_style, + ssh_platform, + ssh_shell, + }; + + let (release_channel, version, commit) = cx.update(|cx| { + ( + ReleaseChannel::global(cx), + AppVersion::global(cx), + AppCommitSha::try_global(cx), + ) + })?; + this.remote_binary_path = Some( + this.ensure_server_binary(&delegate, release_channel, version, commit, cx) + .await?, + ); + + Ok(this) + } + + fn multiplex( + mut ssh_proxy_process: Child, + incoming_tx: UnboundedSender, + mut outgoing_rx: UnboundedReceiver, + mut connection_activity_tx: Sender<()>, + cx: &AsyncApp, + ) -> Task> { + let mut child_stderr = ssh_proxy_process.stderr.take().unwrap(); + let mut child_stdout = ssh_proxy_process.stdout.take().unwrap(); + let mut child_stdin = ssh_proxy_process.stdin.take().unwrap(); + + let mut stdin_buffer = Vec::new(); + let mut stdout_buffer = Vec::new(); + let mut stderr_buffer = Vec::new(); + let mut stderr_offset = 0; + + let stdin_task = cx.background_spawn(async move { + while let Some(outgoing) = outgoing_rx.next().await { + write_message(&mut child_stdin, &mut stdin_buffer, outgoing).await?; + } + anyhow::Ok(()) + }); + + let stdout_task = cx.background_spawn({ + let mut connection_activity_tx = connection_activity_tx.clone(); + async move { + loop { + stdout_buffer.resize(MESSAGE_LEN_SIZE, 0); + let len = child_stdout.read(&mut stdout_buffer).await?; + + if len == 0 { + return anyhow::Ok(()); + } + + if len < MESSAGE_LEN_SIZE { + child_stdout.read_exact(&mut stdout_buffer[len..]).await?; + } + + let message_len = message_len_from_buffer(&stdout_buffer); + let envelope = + read_message_with_len(&mut child_stdout, &mut stdout_buffer, message_len) + .await?; + connection_activity_tx.try_send(()).ok(); + incoming_tx.unbounded_send(envelope).ok(); + } + } + }); + + let stderr_task: Task> = cx.background_spawn(async move { + loop { + stderr_buffer.resize(stderr_offset + 1024, 0); + + let len = child_stderr + .read(&mut stderr_buffer[stderr_offset..]) + .await?; + if len == 0 { + return anyhow::Ok(()); + } + + stderr_offset += len; + let mut start_ix = 0; + while let Some(ix) = stderr_buffer[start_ix..stderr_offset] + .iter() + .position(|b| b == &b'\n') + { + let line_ix = start_ix + ix; + let content = &stderr_buffer[start_ix..line_ix]; + start_ix = line_ix + 1; + if let Ok(record) = serde_json::from_slice::(content) { + record.log(log::logger()) + } else { + eprintln!("(remote) {}", String::from_utf8_lossy(content)); + } + } + stderr_buffer.drain(0..start_ix); + stderr_offset -= start_ix; + + connection_activity_tx.try_send(()).ok(); + } + }); + + cx.background_spawn(async move { + let result = futures::select! { + result = stdin_task.fuse() => { + result.context("stdin") + } + result = stdout_task.fuse() => { + result.context("stdout") + } + result = stderr_task.fuse() => { + result.context("stderr") + } + }; + + let status = ssh_proxy_process.status().await?.code().unwrap_or(1); + match result { + Ok(_) => Ok(status), + Err(error) => Err(error), + } + }) + } + + #[allow(unused)] + async fn ensure_server_binary( + &self, + delegate: &Arc, + release_channel: ReleaseChannel, + version: SemanticVersion, + commit: Option, + cx: &mut AsyncApp, + ) -> Result { + let version_str = match release_channel { + ReleaseChannel::Nightly => { + let commit = commit.map(|s| s.full()).unwrap_or_default(); + format!("{}-{}", version, commit) + } + ReleaseChannel::Dev => "build".to_string(), + _ => version.to_string(), + }; + let binary_name = format!( + "zed-remote-server-{}-{}", + release_channel.dev_name(), + version_str + ); + let dst_path = RemotePathBuf::new( + paths::remote_server_dir_relative().join(binary_name), + self.ssh_path_style, + ); + + let build_remote_server = std::env::var("ZED_BUILD_REMOTE_SERVER").ok(); + #[cfg(debug_assertions)] + if let Some(build_remote_server) = build_remote_server { + let src_path = self.build_local(build_remote_server, delegate, cx).await?; + let tmp_path = RemotePathBuf::new( + paths::remote_server_dir_relative().join(format!( + "download-{}-{}", + std::process::id(), + src_path.file_name().unwrap().to_string_lossy() + )), + self.ssh_path_style, + ); + self.upload_local_server_binary(&src_path, &tmp_path, delegate, cx) + .await?; + self.extract_server_binary(&dst_path, &tmp_path, delegate, cx) + .await?; + return Ok(dst_path); + } + + if self + .socket + .run_command(&dst_path.to_string(), &["version"]) + .await + .is_ok() + { + return Ok(dst_path); + } + + let wanted_version = cx.update(|cx| match release_channel { + ReleaseChannel::Nightly => Ok(None), + ReleaseChannel::Dev => { + anyhow::bail!( + "ZED_BUILD_REMOTE_SERVER is not set and no remote server exists at ({:?})", + dst_path + ) + } + _ => Ok(Some(AppVersion::global(cx))), + })??; + + let tmp_path_gz = RemotePathBuf::new( + PathBuf::from(format!("{}-download-{}.gz", dst_path, std::process::id())), + self.ssh_path_style, + ); + if !self.socket.connection_options.upload_binary_over_ssh + && let Some((url, body)) = delegate + .get_download_params(self.ssh_platform, release_channel, wanted_version, cx) + .await? + { + match self + .download_binary_on_server(&url, &body, &tmp_path_gz, delegate, cx) + .await + { + Ok(_) => { + self.extract_server_binary(&dst_path, &tmp_path_gz, delegate, cx) + .await?; + return Ok(dst_path); + } + Err(e) => { + log::error!( + "Failed to download binary on server, attempting to upload server: {}", + e + ) + } + } + } + + let src_path = delegate + .download_server_binary_locally(self.ssh_platform, release_channel, wanted_version, cx) + .await?; + self.upload_local_server_binary(&src_path, &tmp_path_gz, delegate, cx) + .await?; + self.extract_server_binary(&dst_path, &tmp_path_gz, delegate, cx) + .await?; + Ok(dst_path) + } + + async fn download_binary_on_server( + &self, + url: &str, + body: &str, + tmp_path_gz: &RemotePathBuf, + delegate: &Arc, + cx: &mut AsyncApp, + ) -> Result<()> { + if let Some(parent) = tmp_path_gz.parent() { + self.socket + .run_command( + "sh", + &[ + "-lc", + &shell_script!("mkdir -p {parent}", parent = parent.to_string().as_ref()), + ], + ) + .await?; + } + + delegate.set_status(Some("Downloading remote development server on host"), cx); + + match self + .socket + .run_command( + "curl", + &[ + "-f", + "-L", + "-X", + "GET", + "-H", + "Content-Type: application/json", + "-d", + body, + url, + "-o", + &tmp_path_gz.to_string(), + ], + ) + .await + { + Ok(_) => {} + Err(e) => { + if self.socket.run_command("which", &["curl"]).await.is_ok() { + return Err(e); + } + + match self + .socket + .run_command( + "wget", + &[ + "--method=GET", + "--header=Content-Type: application/json", + "--body-data", + body, + url, + "-O", + &tmp_path_gz.to_string(), + ], + ) + .await + { + Ok(_) => {} + Err(e) => { + if self.socket.run_command("which", &["wget"]).await.is_ok() { + return Err(e); + } else { + anyhow::bail!("Neither curl nor wget is available"); + } + } + } + } + } + + Ok(()) + } + + async fn upload_local_server_binary( + &self, + src_path: &Path, + tmp_path_gz: &RemotePathBuf, + delegate: &Arc, + cx: &mut AsyncApp, + ) -> Result<()> { + if let Some(parent) = tmp_path_gz.parent() { + self.socket + .run_command( + "sh", + &[ + "-lc", + &shell_script!("mkdir -p {parent}", parent = parent.to_string().as_ref()), + ], + ) + .await?; + } + + let src_stat = fs::metadata(&src_path).await?; + let size = src_stat.len(); + + let t0 = Instant::now(); + delegate.set_status(Some("Uploading remote development server"), cx); + log::info!( + "uploading remote development server to {:?} ({}kb)", + tmp_path_gz, + size / 1024 + ); + self.upload_file(src_path, tmp_path_gz) + .await + .context("failed to upload server binary")?; + log::info!("uploaded remote development server in {:?}", t0.elapsed()); + Ok(()) + } + + async fn extract_server_binary( + &self, + dst_path: &RemotePathBuf, + tmp_path: &RemotePathBuf, + delegate: &Arc, + cx: &mut AsyncApp, + ) -> Result<()> { + delegate.set_status(Some("Extracting remote development server"), cx); + let server_mode = 0o755; + + let orig_tmp_path = tmp_path.to_string(); + let script = if let Some(tmp_path) = orig_tmp_path.strip_suffix(".gz") { + shell_script!( + "gunzip -f {orig_tmp_path} && chmod {server_mode} {tmp_path} && mv {tmp_path} {dst_path}", + server_mode = &format!("{:o}", server_mode), + dst_path = &dst_path.to_string(), + ) + } else { + shell_script!( + "chmod {server_mode} {orig_tmp_path} && mv {orig_tmp_path} {dst_path}", + server_mode = &format!("{:o}", server_mode), + dst_path = &dst_path.to_string() + ) + }; + self.socket.run_command("sh", &["-lc", &script]).await?; + Ok(()) + } + + async fn upload_file(&self, src_path: &Path, dest_path: &RemotePathBuf) -> Result<()> { + log::debug!("uploading file {:?} to {:?}", src_path, dest_path); + let mut command = util::command::new_smol_command("scp"); + let output = self + .socket + .ssh_options(&mut command) + .args( + self.socket + .connection_options + .port + .map(|port| vec!["-P".to_string(), port.to_string()]) + .unwrap_or_default(), + ) + .arg(src_path) + .arg(format!( + "{}:{}", + self.socket.connection_options.scp_url(), + dest_path + )) + .output() + .await?; + + anyhow::ensure!( + output.status.success(), + "failed to upload file {} -> {}: {}", + src_path.display(), + dest_path.to_string(), + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) + } + + #[cfg(debug_assertions)] + async fn build_local( + &self, + build_remote_server: String, + delegate: &Arc, + cx: &mut AsyncApp, + ) -> Result { + use smol::process::{Command, Stdio}; + use std::env::VarError; + + async fn run_cmd(command: &mut Command) -> Result<()> { + let output = command + .kill_on_drop(true) + .stderr(Stdio::inherit()) + .output() + .await?; + anyhow::ensure!( + output.status.success(), + "Failed to run command: {command:?}" + ); + Ok(()) + } + + let use_musl = !build_remote_server.contains("nomusl"); + let triple = format!( + "{}-{}", + self.ssh_platform.arch, + match self.ssh_platform.os { + "linux" => + if use_musl { + "unknown-linux-musl" + } else { + "unknown-linux-gnu" + }, + "macos" => "apple-darwin", + _ => anyhow::bail!("can't cross compile for: {:?}", self.ssh_platform), + } + ); + let mut rust_flags = match std::env::var("RUSTFLAGS") { + Ok(val) => val, + Err(VarError::NotPresent) => String::new(), + Err(e) => { + log::error!("Failed to get env var `RUSTFLAGS` value: {e}"); + String::new() + } + }; + if self.ssh_platform.os == "linux" && use_musl { + rust_flags.push_str(" -C target-feature=+crt-static"); + } + if build_remote_server.contains("mold") { + rust_flags.push_str(" -C link-arg=-fuse-ld=mold"); + } + + if self.ssh_platform.arch == std::env::consts::ARCH + && self.ssh_platform.os == std::env::consts::OS + { + delegate.set_status(Some("Building remote server binary from source"), cx); + log::info!("building remote server binary from source"); + run_cmd( + Command::new("cargo") + .args([ + "build", + "--package", + "remote_server", + "--features", + "debug-embed", + "--target-dir", + "target/remote_server", + "--target", + &triple, + ]) + .env("RUSTFLAGS", &rust_flags), + ) + .await?; + } else if build_remote_server.contains("cross") { + #[cfg(target_os = "windows")] + use util::paths::SanitizedPath; + + delegate.set_status(Some("Installing cross.rs for cross-compilation"), cx); + log::info!("installing cross"); + run_cmd(Command::new("cargo").args([ + "install", + "cross", + "--git", + "https://github.com/cross-rs/cross", + ])) + .await?; + + delegate.set_status( + Some(&format!( + "Building remote server binary from source for {} with Docker", + &triple + )), + cx, + ); + log::info!("building remote server binary from source for {}", &triple); + + // On Windows, the binding needs to be set to the canonical path + #[cfg(target_os = "windows")] + let src = + SanitizedPath::from(smol::fs::canonicalize("./target").await?).to_glob_string(); + #[cfg(not(target_os = "windows"))] + let src = "./target"; + run_cmd( + Command::new("cross") + .args([ + "build", + "--package", + "remote_server", + "--features", + "debug-embed", + "--target-dir", + "target/remote_server", + "--target", + &triple, + ]) + .env( + "CROSS_CONTAINER_OPTS", + format!("--mount type=bind,src={src},dst=/app/target"), + ) + .env("RUSTFLAGS", &rust_flags), + ) + .await?; + } else { + let which = cx + .background_spawn(async move { which::which("zig") }) + .await; + + if which.is_err() { + #[cfg(not(target_os = "windows"))] + { + anyhow::bail!( + "zig not found on $PATH, install zig (see https://ziglang.org/learn/getting-started or use zigup) or pass ZED_BUILD_REMOTE_SERVER=cross to use cross" + ) + } + #[cfg(target_os = "windows")] + { + anyhow::bail!( + "zig not found on $PATH, install zig (use `winget install -e --id zig.zig` or see https://ziglang.org/learn/getting-started or use zigup) or pass ZED_BUILD_REMOTE_SERVER=cross to use cross" + ) + } + } + + delegate.set_status(Some("Adding rustup target for cross-compilation"), cx); + log::info!("adding rustup target"); + run_cmd(Command::new("rustup").args(["target", "add"]).arg(&triple)).await?; + + delegate.set_status(Some("Installing cargo-zigbuild for cross-compilation"), cx); + log::info!("installing cargo-zigbuild"); + run_cmd(Command::new("cargo").args(["install", "--locked", "cargo-zigbuild"])).await?; + + delegate.set_status( + Some(&format!( + "Building remote binary from source for {triple} with Zig" + )), + cx, + ); + log::info!("building remote binary from source for {triple} with Zig"); + run_cmd( + Command::new("cargo") + .args([ + "zigbuild", + "--package", + "remote_server", + "--features", + "debug-embed", + "--target-dir", + "target/remote_server", + "--target", + &triple, + ]) + .env("RUSTFLAGS", &rust_flags), + ) + .await?; + }; + let bin_path = Path::new("target") + .join("remote_server") + .join(&triple) + .join("debug") + .join("remote_server"); + + let path = if !build_remote_server.contains("nocompress") { + delegate.set_status(Some("Compressing binary"), cx); + + #[cfg(not(target_os = "windows"))] + { + run_cmd(Command::new("gzip").args(["-f", &bin_path.to_string_lossy()])).await?; + } + #[cfg(target_os = "windows")] + { + // On Windows, we use 7z to compress the binary + let seven_zip = which::which("7z.exe").context("7z.exe not found on $PATH, install it (e.g. with `winget install -e --id 7zip.7zip`) or, if you don't want this behaviour, set $env:ZED_BUILD_REMOTE_SERVER=\"nocompress\"")?; + let gz_path = format!("target/remote_server/{}/debug/remote_server.gz", triple); + if smol::fs::metadata(&gz_path).await.is_ok() { + smol::fs::remove_file(&gz_path).await?; + } + run_cmd(Command::new(seven_zip).args([ + "a", + "-tgzip", + &gz_path, + &bin_path.to_string_lossy(), + ])) + .await?; + } + + let mut archive_path = bin_path; + archive_path.set_extension("gz"); + std::env::current_dir()?.join(archive_path) + } else { + bin_path + }; + + Ok(path) + } +} + +impl SshSocket { + #[cfg(not(target_os = "windows"))] + fn new(options: SshConnectionOptions, socket_path: PathBuf) -> Result { + Ok(Self { + connection_options: options, + envs: HashMap::default(), + socket_path, + }) + } + + #[cfg(target_os = "windows")] + fn new(options: SshConnectionOptions, temp_dir: &TempDir, secret: String) -> Result { + let askpass_script = temp_dir.path().join("askpass.bat"); + std::fs::write(&askpass_script, "@ECHO OFF\necho %ZED_SSH_ASKPASS%")?; + let mut envs = HashMap::default(); + envs.insert("SSH_ASKPASS_REQUIRE".into(), "force".into()); + envs.insert("SSH_ASKPASS".into(), askpass_script.display().to_string()); + envs.insert("ZED_SSH_ASKPASS".into(), secret); + Ok(Self { + connection_options: options, + envs, + }) + } + + // :WARNING: ssh unquotes arguments when executing on the remote :WARNING: + // e.g. $ ssh host sh -c 'ls -l' is equivalent to $ ssh host sh -c ls -l + // and passes -l as an argument to sh, not to ls. + // Furthermore, some setups (e.g. Coder) will change directory when SSH'ing + // into a machine. You must use `cd` to get back to $HOME. + // You need to do it like this: $ ssh host "cd; sh -c 'ls -l /tmp'" + fn ssh_command(&self, program: &str, args: &[&str]) -> process::Command { + let mut command = util::command::new_smol_command("ssh"); + let to_run = iter::once(&program) + .chain(args.iter()) + .map(|token| { + // We're trying to work with: sh, bash, zsh, fish, tcsh, ...? + debug_assert!( + !token.contains('\n'), + "multiline arguments do not work in all shells" + ); + shlex::try_quote(token).unwrap() + }) + .join(" "); + let to_run = format!("cd; {to_run}"); + log::debug!("ssh {} {:?}", self.connection_options.ssh_url(), to_run); + self.ssh_options(&mut command) + .arg(self.connection_options.ssh_url()) + .arg(to_run); + command + } + + async fn run_command(&self, program: &str, args: &[&str]) -> Result { + let output = self.ssh_command(program, args).output().await?; + anyhow::ensure!( + output.status.success(), + "failed to run command: {}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } + + #[cfg(not(target_os = "windows"))] + fn ssh_options<'a>(&self, command: &'a mut process::Command) -> &'a mut process::Command { + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .args(self.connection_options.additional_args()) + .args(["-o", "ControlMaster=no", "-o"]) + .arg(format!("ControlPath={}", self.socket_path.display())) + } + + #[cfg(target_os = "windows")] + fn ssh_options<'a>(&self, command: &'a mut process::Command) -> &'a mut process::Command { + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .args(self.connection_options.additional_args()) + .envs(self.envs.clone()) + } + + // On Windows, we need to use `SSH_ASKPASS` to provide the password to ssh. + // On Linux, we use the `ControlPath` option to create a socket file that ssh can use to + #[cfg(not(target_os = "windows"))] + fn ssh_args(&self) -> Vec { + let mut arguments = self.connection_options.additional_args(); + arguments.extend(vec![ + "-o".to_string(), + "ControlMaster=no".to_string(), + "-o".to_string(), + format!("ControlPath={}", self.socket_path.display()), + self.connection_options.ssh_url(), + ]); + arguments + } + + #[cfg(target_os = "windows")] + fn ssh_args(&self) -> Vec { + let mut arguments = self.connection_options.additional_args(); + arguments.push(self.connection_options.ssh_url()); + arguments + } + + async fn platform(&self) -> Result { + let uname = self.run_command("sh", &["-lc", "uname -sm"]).await?; + let Some((os, arch)) = uname.split_once(" ") else { + anyhow::bail!("unknown uname: {uname:?}") + }; + + let os = match os.trim() { + "Darwin" => "macos", + "Linux" => "linux", + _ => anyhow::bail!( + "Prebuilt remote servers are not yet available for {os:?}. See https://zed.dev/docs/remote-development" + ), + }; + // exclude armv5,6,7 as they are 32-bit. + let arch = if arch.starts_with("armv8") + || arch.starts_with("armv9") + || arch.starts_with("arm64") + || arch.starts_with("aarch64") + { + "aarch64" + } else if arch.starts_with("x86") { + "x86_64" + } else { + anyhow::bail!( + "Prebuilt remote servers are not yet available for {arch:?}. See https://zed.dev/docs/remote-development" + ) + }; + + Ok(RemotePlatform { os, arch }) + } + + async fn shell(&self) -> String { + match self.run_command("sh", &["-lc", "echo $SHELL"]).await { + Ok(shell) => shell.trim().to_owned(), + Err(e) => { + log::error!("Failed to get shell: {e}"); + "sh".to_owned() + } + } + } +} + +fn parse_port_number(port_str: &str) -> Result { + port_str + .parse() + .with_context(|| format!("parsing port number: {port_str}")) +} + +fn parse_port_forward_spec(spec: &str) -> Result { + let parts: Vec<&str> = spec.split(':').collect(); + + match parts.len() { + 4 => { + let local_port = parse_port_number(parts[1])?; + let remote_port = parse_port_number(parts[3])?; + + Ok(SshPortForwardOption { + local_host: Some(parts[0].to_string()), + local_port, + remote_host: Some(parts[2].to_string()), + remote_port, + }) + } + 3 => { + let local_port = parse_port_number(parts[0])?; + let remote_port = parse_port_number(parts[2])?; + + Ok(SshPortForwardOption { + local_host: None, + local_port, + remote_host: Some(parts[1].to_string()), + remote_port, + }) + } + _ => anyhow::bail!("Invalid port forward format"), + } +} + +impl SshConnectionOptions { + pub fn parse_command_line(input: &str) -> Result { + let input = input.trim_start_matches("ssh "); + let mut hostname: Option = None; + let mut username: Option = None; + let mut port: Option = None; + let mut args = Vec::new(); + let mut port_forwards: Vec = Vec::new(); + + // disallowed: -E, -e, -F, -f, -G, -g, -M, -N, -n, -O, -q, -S, -s, -T, -t, -V, -v, -W + const ALLOWED_OPTS: &[&str] = &[ + "-4", "-6", "-A", "-a", "-C", "-K", "-k", "-X", "-x", "-Y", "-y", + ]; + const ALLOWED_ARGS: &[&str] = &[ + "-B", "-b", "-c", "-D", "-F", "-I", "-i", "-J", "-l", "-m", "-o", "-P", "-p", "-R", + "-w", + ]; + + let mut tokens = shlex::split(input).context("invalid input")?.into_iter(); + + 'outer: while let Some(arg) = tokens.next() { + if ALLOWED_OPTS.contains(&(&arg as &str)) { + args.push(arg.to_string()); + continue; + } + if arg == "-p" { + port = tokens.next().and_then(|arg| arg.parse().ok()); + continue; + } else if let Some(p) = arg.strip_prefix("-p") { + port = p.parse().ok(); + continue; + } + if arg == "-l" { + username = tokens.next(); + continue; + } else if let Some(l) = arg.strip_prefix("-l") { + username = Some(l.to_string()); + continue; + } + if arg == "-L" || arg.starts_with("-L") { + let forward_spec = if arg == "-L" { + tokens.next() + } else { + Some(arg.strip_prefix("-L").unwrap().to_string()) + }; + + if let Some(spec) = forward_spec { + port_forwards.push(parse_port_forward_spec(&spec)?); + } else { + anyhow::bail!("Missing port forward format"); + } + } + + for a in ALLOWED_ARGS { + if arg == *a { + args.push(arg); + if let Some(next) = tokens.next() { + args.push(next); + } + continue 'outer; + } else if arg.starts_with(a) { + args.push(arg); + continue 'outer; + } + } + if arg.starts_with("-") || hostname.is_some() { + anyhow::bail!("unsupported argument: {:?}", arg); + } + let mut input = &arg as &str; + // Destination might be: username1@username2@ip2@ip1 + if let Some((u, rest)) = input.rsplit_once('@') { + input = rest; + username = Some(u.to_string()); + } + if let Some((rest, p)) = input.split_once(':') { + input = rest; + port = p.parse().ok() + } + hostname = Some(input.to_string()) + } + + let Some(hostname) = hostname else { + anyhow::bail!("missing hostname"); + }; + + let port_forwards = match port_forwards.len() { + 0 => None, + _ => Some(port_forwards), + }; + + Ok(Self { + host: hostname, + username, + port, + port_forwards, + args: Some(args), + password: None, + nickname: None, + upload_binary_over_ssh: false, + }) + } + + pub fn ssh_url(&self) -> String { + let mut result = String::from("ssh://"); + if let Some(username) = &self.username { + // Username might be: username1@username2@ip2 + let username = urlencoding::encode(username); + result.push_str(&username); + result.push('@'); + } + result.push_str(&self.host); + if let Some(port) = self.port { + result.push(':'); + result.push_str(&port.to_string()); + } + result + } + + pub fn additional_args(&self) -> Vec { + let mut args = self.args.iter().flatten().cloned().collect::>(); + + if let Some(forwards) = &self.port_forwards { + args.extend(forwards.iter().map(|pf| { + let local_host = match &pf.local_host { + Some(host) => host, + None => "localhost", + }; + let remote_host = match &pf.remote_host { + Some(host) => host, + None => "localhost", + }; + + format!( + "-L{}:{}:{}:{}", + local_host, pf.local_port, remote_host, pf.remote_port + ) + })); + } + + args + } + + fn scp_url(&self) -> String { + if let Some(username) = &self.username { + format!("{}@{}", username, self.host) + } else { + self.host.clone() + } + } + + pub fn connection_string(&self) -> String { + let host = if let Some(username) = &self.username { + format!("{}@{}", username, self.host) + } else { + self.host.clone() + }; + if let Some(port) = &self.port { + format!("{}:{}", host, port) + } else { + host + } + } +} diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index 6216ff77288938f7e4d424101c572f5bea13b69a..04028ebcac82f814652f32ad7439e32d650f5ad0 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -21,7 +21,7 @@ use project::{ }; use rpc::{ AnyProtoClient, TypedEnvelope, - proto::{self, SSH_PEER_ID, SSH_PROJECT_ID}, + proto::{self, REMOTE_SERVER_PEER_ID, REMOTE_SERVER_PROJECT_ID}, }; use settings::initial_server_settings_content; @@ -83,7 +83,7 @@ impl HeadlessProject { let worktree_store = cx.new(|cx| { let mut store = WorktreeStore::local(true, fs.clone()); - store.shared(SSH_PROJECT_ID, session.clone(), cx); + store.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx); store }); @@ -101,7 +101,7 @@ impl HeadlessProject { let buffer_store = cx.new(|cx| { let mut buffer_store = BufferStore::local(worktree_store.clone(), cx); - buffer_store.shared(SSH_PROJECT_ID, session.clone(), cx); + buffer_store.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx); buffer_store }); @@ -119,7 +119,7 @@ impl HeadlessProject { breakpoint_store.clone(), cx, ); - dap_store.shared(SSH_PROJECT_ID, session.clone(), cx); + dap_store.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx); dap_store }); @@ -131,7 +131,7 @@ impl HeadlessProject { fs.clone(), cx, ); - store.shared(SSH_PROJECT_ID, session.clone(), cx); + store.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx); store }); @@ -154,7 +154,7 @@ impl HeadlessProject { environment.clone(), cx, ); - task_store.shared(SSH_PROJECT_ID, session.clone(), cx); + task_store.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx); task_store }); let settings_observer = cx.new(|cx| { @@ -164,7 +164,7 @@ impl HeadlessProject { task_store.clone(), cx, ); - observer.shared(SSH_PROJECT_ID, session.clone(), cx); + observer.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx); observer }); @@ -185,7 +185,7 @@ impl HeadlessProject { fs.clone(), cx, ); - lsp_store.shared(SSH_PROJECT_ID, session.clone(), cx); + lsp_store.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx); lsp_store }); @@ -213,15 +213,15 @@ impl HeadlessProject { ); // local_machine -> ssh handlers - session.subscribe_to_entity(SSH_PROJECT_ID, &worktree_store); - session.subscribe_to_entity(SSH_PROJECT_ID, &buffer_store); - session.subscribe_to_entity(SSH_PROJECT_ID, &cx.entity()); - session.subscribe_to_entity(SSH_PROJECT_ID, &lsp_store); - session.subscribe_to_entity(SSH_PROJECT_ID, &task_store); - session.subscribe_to_entity(SSH_PROJECT_ID, &toolchain_store); - session.subscribe_to_entity(SSH_PROJECT_ID, &dap_store); - session.subscribe_to_entity(SSH_PROJECT_ID, &settings_observer); - session.subscribe_to_entity(SSH_PROJECT_ID, &git_store); + session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &worktree_store); + session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &buffer_store); + session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &cx.entity()); + session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &lsp_store); + session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &task_store); + session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &toolchain_store); + session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &dap_store); + session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &settings_observer); + session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &git_store); session.add_request_handler(cx.weak_entity(), Self::handle_list_remote_directory); session.add_request_handler(cx.weak_entity(), Self::handle_get_path_metadata); @@ -288,7 +288,7 @@ impl HeadlessProject { } = event { cx.background_spawn(self.session.request(proto::UpdateBuffer { - project_id: SSH_PROJECT_ID, + project_id: REMOTE_SERVER_PROJECT_ID, buffer_id: buffer.read(cx).remote_id().to_proto(), operations: vec![serialize_operation(operation)], })) @@ -310,7 +310,7 @@ impl HeadlessProject { } => { self.session .send(proto::UpdateLanguageServer { - project_id: SSH_PROJECT_ID, + project_id: REMOTE_SERVER_PROJECT_ID, server_name: name.as_ref().map(|name| name.to_string()), language_server_id: language_server_id.to_proto(), variant: Some(message.clone()), @@ -320,7 +320,7 @@ impl HeadlessProject { LspStoreEvent::Notification(message) => { self.session .send(proto::Toast { - project_id: SSH_PROJECT_ID, + project_id: REMOTE_SERVER_PROJECT_ID, notification_id: "lsp".to_string(), message: message.clone(), }) @@ -329,7 +329,7 @@ impl HeadlessProject { LspStoreEvent::LanguageServerLog(language_server_id, log_type, message) => { self.session .send(proto::LanguageServerLog { - project_id: SSH_PROJECT_ID, + project_id: REMOTE_SERVER_PROJECT_ID, language_server_id: language_server_id.to_proto(), message: message.clone(), log_type: Some(log_type.to_proto()), @@ -338,7 +338,7 @@ impl HeadlessProject { } LspStoreEvent::LanguageServerPrompt(prompt) => { let request = self.session.request(proto::LanguageServerPromptRequest { - project_id: SSH_PROJECT_ID, + project_id: REMOTE_SERVER_PROJECT_ID, actions: prompt .actions .iter() @@ -474,7 +474,7 @@ impl HeadlessProject { let buffer_id = buffer.read_with(&cx, |b, _| b.remote_id())?; buffer_store.update(&mut cx, |buffer_store, cx| { buffer_store - .create_buffer_for_peer(&buffer, SSH_PEER_ID, cx) + .create_buffer_for_peer(&buffer, REMOTE_SERVER_PEER_ID, cx) .detach_and_log_err(cx); })?; @@ -500,7 +500,7 @@ impl HeadlessProject { let buffer_id = buffer.read_with(&cx, |b, _| b.remote_id())?; buffer_store.update(&mut cx, |buffer_store, cx| { buffer_store - .create_buffer_for_peer(&buffer, SSH_PEER_ID, cx) + .create_buffer_for_peer(&buffer, REMOTE_SERVER_PEER_ID, cx) .detach_and_log_err(cx); })?; @@ -550,7 +550,7 @@ impl HeadlessProject { buffer_store.update(cx, |buffer_store, cx| { buffer_store - .create_buffer_for_peer(&buffer, SSH_PEER_ID, cx) + .create_buffer_for_peer(&buffer, REMOTE_SERVER_PEER_ID, cx) .detach_and_log_err(cx); }); @@ -586,7 +586,7 @@ impl HeadlessProject { response.buffer_ids.push(buffer_id.to_proto()); buffer_store .update(&mut cx, |buffer_store, cx| { - buffer_store.create_buffer_for_peer(&buffer, SSH_PEER_ID, cx) + buffer_store.create_buffer_for_peer(&buffer, REMOTE_SERVER_PEER_ID, cx) })? .await?; } diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index 69fae7f399e506b26f005d30fdda6b7240df3e0b..e106a5ef18d59ebeb942564f24600635f78f89c7 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -22,7 +22,7 @@ use project::{ Project, ProjectPath, search::{SearchQuery, SearchResult}, }; -use remote::SshRemoteClient; +use remote::RemoteClient; use serde_json::json; use settings::{Settings, SettingsLocation, SettingsStore, initial_server_settings_content}; use smol::stream::StreamExt; @@ -1119,7 +1119,7 @@ async fn test_reconnect(cx: &mut TestAppContext, server_cx: &mut TestAppContext) buffer.edit([(ix..ix + 1, "100")], None, cx); }); - let client = cx.read(|cx| project.read(cx).ssh_client().unwrap()); + let client = cx.read(|cx| project.read(cx).remote_client().unwrap()); client .update(cx, |client, cx| client.simulate_disconnect(cx)) .detach(); @@ -1782,7 +1782,7 @@ pub async fn init_test( }); init_logger(); - let (opts, ssh_server_client) = SshRemoteClient::fake_server(cx, server_cx); + let (opts, ssh_server_client) = RemoteClient::fake_server(cx, server_cx); let http_client = Arc::new(BlockedHttpClient); let node_runtime = NodeRuntime::unavailable(); let languages = Arc::new(LanguageRegistry::new(cx.executor())); @@ -1804,7 +1804,7 @@ pub async fn init_test( ) }); - let ssh = SshRemoteClient::fake_client(opts, cx).await; + let ssh = RemoteClient::fake_client(opts, cx).await; let project = build_project(ssh, cx); project .update(cx, { @@ -1819,7 +1819,7 @@ fn init_logger() { zlog::init_test(); } -fn build_project(ssh: Entity, cx: &mut TestAppContext) -> Entity { +fn build_project(ssh: Entity, cx: &mut TestAppContext) -> Entity { cx.update(|cx| { if !cx.has_global::() { let settings_store = SettingsStore::test(cx); @@ -1845,5 +1845,5 @@ fn build_project(ssh: Entity, cx: &mut TestAppContext) -> Entit language::init(cx); }); - cx.update(|cx| Project::ssh(ssh, client, node, user_store, languages, fs, cx)) + cx.update(|cx| Project::remote(ssh, client, node, user_store, languages, fs, cx)) } diff --git a/crates/remote_server/src/unix.rs b/crates/remote_server/src/unix.rs index c6d1566d6038bb1b908c0b28eb8d24b85e5cc86d..cb671a72d9beab0983536571e81fcd78f3df21c8 100644 --- a/crates/remote_server/src/unix.rs +++ b/crates/remote_server/src/unix.rs @@ -19,14 +19,14 @@ use project::project_settings::ProjectSettings; use proto::CrashReport; use release_channel::{AppVersion, RELEASE_CHANNEL, ReleaseChannel}; -use remote::SshRemoteClient; +use remote::RemoteClient; use remote::{ json_log::LogRecord, protocol::{read_message, write_message}, proxy::ProxyLaunchError, }; use reqwest_client::ReqwestClient; -use rpc::proto::{self, Envelope, SSH_PROJECT_ID}; +use rpc::proto::{self, Envelope, REMOTE_SERVER_PROJECT_ID}; use rpc::{AnyProtoClient, TypedEnvelope}; use settings::{Settings, SettingsStore, watch_config_file}; use smol::channel::{Receiver, Sender}; @@ -396,7 +396,7 @@ fn start_server( }) .detach(); - SshRemoteClient::proto_client_from_channels(incoming_rx, outgoing_tx, cx, "server") + RemoteClient::proto_client_from_channels(incoming_rx, outgoing_tx, cx, "server") } fn init_paths() -> anyhow::Result<()> { @@ -867,34 +867,21 @@ where R: AsyncRead + Unpin, W: AsyncWrite + Unpin, { - use remote::protocol::read_message_raw; + use remote::protocol::{read_message_raw, write_size_prefixed_buffer}; let mut buffer = Vec::new(); loop { read_message_raw(&mut reader, &mut buffer) .await .with_context(|| format!("failed to read message from {}", socket_name))?; - write_size_prefixed_buffer(&mut writer, &mut buffer) .await .with_context(|| format!("failed to write message to {}", socket_name))?; - writer.flush().await?; - buffer.clear(); } } -async fn write_size_prefixed_buffer( - stream: &mut S, - buffer: &mut Vec, -) -> Result<()> { - let len = buffer.len() as u32; - stream.write_all(len.to_le_bytes().as_slice()).await?; - stream.write_all(buffer).await?; - Ok(()) -} - fn initialize_settings( session: AnyProtoClient, fs: Arc, @@ -910,7 +897,7 @@ fn initialize_settings( session .send(proto::Toast { - project_id: SSH_PROJECT_ID, + project_id: REMOTE_SERVER_PROJECT_ID, notification_id: "server-settings-failed".to_string(), message: format!( "Error in settings on remote host {:?}: {}", @@ -922,7 +909,7 @@ fn initialize_settings( } else { session .send(proto::HideToast { - project_id: SSH_PROJECT_ID, + project_id: REMOTE_SERVER_PROJECT_ID, notification_id: "server-settings-failed".to_string(), }) .log_err(); diff --git a/crates/task/src/shell_builder.rs b/crates/task/src/shell_builder.rs index de4ddc00f49eded4ba64faa6d94baa1cc6ecf3aa..f907bd1d9f3f02869b2eb4b6ea4d65663e0d00af 100644 --- a/crates/task/src/shell_builder.rs +++ b/crates/task/src/shell_builder.rs @@ -36,7 +36,7 @@ impl ShellKind { } else if program == "csh" { ShellKind::Csh } else { - // Someother shell detected, the user might install and use a + // Some other shell detected, the user might install and use a // unix-like shell. ShellKind::Posix } @@ -203,14 +203,15 @@ pub struct ShellBuilder { impl ShellBuilder { /// Create a new ShellBuilder as configured. pub fn new(remote_system_shell: Option<&str>, shell: &Shell) -> Self { - let (program, args) = match shell { - Shell::System => match remote_system_shell { - Some(remote_shell) => (remote_shell.to_string(), Vec::new()), - None => (system_shell(), Vec::new()), + let (program, args) = match remote_system_shell { + Some(program) => (program.to_string(), Vec::new()), + None => match shell { + Shell::System => (system_shell(), Vec::new()), + Shell::Program(shell) => (shell.clone(), Vec::new()), + Shell::WithArguments { program, args, .. } => (program.clone(), args.clone()), }, - Shell::Program(shell) => (shell.clone(), Vec::new()), - Shell::WithArguments { program, args, .. } => (program.clone(), args.clone()), }; + let kind = ShellKind::new(&program); Self { program, diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index fe3301fb89095a7cf9f1a841395b47e9caf1475b..56715b604eeffe0b42302adcdf0d6fdd93919879 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -1403,7 +1403,7 @@ impl InputHandler for TerminalInputHandler { window.invalidate_character_coordinates(); let project = this.project().read(cx); let telemetry = project.client().telemetry().clone(); - telemetry.log_edit_event("terminal", project.is_via_ssh()); + telemetry.log_edit_event("terminal", project.is_via_remote_server()); }) .ok(); } diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 6b17911487261a254cd5e7a5e9256358c0b2e696..c3d7c4f793ed612a4dd819aca7552b48ccf6a3db 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -481,17 +481,28 @@ impl TerminalPanel { window: &mut Window, cx: &mut Context, ) -> Task>> { - let Ok((ssh_client, false)) = self.workspace.update(cx, |workspace, cx| { - let project = workspace.project().read(cx); - ( - project.ssh_client().and_then(|it| it.read(cx).ssh_info()), - project.is_via_collab(), - ) - }) else { - return Task::ready(Err(anyhow!("Project is not local"))); + let remote_client = self + .workspace + .update(cx, |workspace, cx| { + let project = workspace.project().read(cx); + if project.is_via_collab() { + Err(anyhow!("cannot spawn tasks as a guest")) + } else { + Ok(project.remote_client()) + } + }) + .flatten(); + + let remote_client = match remote_client { + Ok(remote_client) => remote_client, + Err(e) => return Task::ready(Err(e)), }; - let builder = ShellBuilder::new(ssh_client.as_ref().map(|info| &*info.shell), &task.shell); + let remote_shell = remote_client + .as_ref() + .and_then(|remote_client| remote_client.read(cx).shell()); + + let builder = ShellBuilder::new(remote_shell.as_deref(), &task.shell); let command_label = builder.command_label(&task.command_label); let (command, args) = builder.build(task.command.clone(), &task.args); diff --git a/crates/title_bar/src/collab.rs b/crates/title_bar/src/collab.rs index c667edb509b6e7c5f906f038f19a1e50b5c65032..78f22faa13900f05fbd0cea9877858857aac626e 100644 --- a/crates/title_bar/src/collab.rs +++ b/crates/title_bar/src/collab.rs @@ -337,7 +337,7 @@ impl TitleBar { let room = room.read(cx); let project = self.project.read(cx); - let is_local = project.is_local() || project.is_via_ssh(); + let is_local = project.is_local() || project.is_via_remote_server(); let is_shared = is_local && project.is_shared(); let is_muted = room.is_muted(); let muted_by_user = room.muted_by_user(); diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index ad64dac9c69863222506bbca83d3d7379fc097ab..b08f139b25e53ef7e4761e55ad2686b3425969e9 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -299,8 +299,8 @@ impl TitleBar { } } - fn render_ssh_project_host(&self, cx: &mut Context) -> Option { - let options = self.project.read(cx).ssh_connection_options(cx)?; + fn render_remote_project_connection(&self, cx: &mut Context) -> Option { + let options = self.project.read(cx).remote_connection_options(cx)?; let host: SharedString = options.connection_string().into(); let nickname = options @@ -308,7 +308,7 @@ impl TitleBar { .map(|nick| nick.into()) .unwrap_or_else(|| host.clone()); - let (indicator_color, meta) = match self.project.read(cx).ssh_connection_state(cx)? { + let (indicator_color, meta) = match self.project.read(cx).remote_connection_state(cx)? { remote::ConnectionState::Connecting => (Color::Info, format!("Connecting to: {host}")), remote::ConnectionState::Connected => (Color::Success, format!("Connected to: {host}")), remote::ConnectionState::HeartbeatMissed => ( @@ -324,7 +324,7 @@ impl TitleBar { } }; - let icon_color = match self.project.read(cx).ssh_connection_state(cx)? { + let icon_color = match self.project.read(cx).remote_connection_state(cx)? { remote::ConnectionState::Connecting => Color::Info, remote::ConnectionState::Connected => Color::Default, remote::ConnectionState::HeartbeatMissed => Color::Warning, @@ -379,8 +379,8 @@ impl TitleBar { } pub fn render_project_host(&self, cx: &mut Context) -> Option { - if self.project.read(cx).is_via_ssh() { - return self.render_ssh_project_host(cx); + if self.project.read(cx).is_via_remote_server() { + return self.render_remote_project_connection(cx); } if self.project.read(cx).is_disconnected(cx) { diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index b57c916db988f683cef6bae15ec562392a488be6..29fe6aae0252bcc1ca5767f71b7c668ecae1b9a8 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -1924,7 +1924,9 @@ impl ShellExec { let Some(range) = input_range else { return }; - let mut process = project.read(cx).exec_in_shell(command, cx); + let Some(mut process) = project.read(cx).exec_in_shell(command, cx).log_err() else { + return; + }; process.stdout(Stdio::piped()); process.stderr(Stdio::piped()); diff --git a/crates/workspace/src/tasks.rs b/crates/workspace/src/tasks.rs index 32d066c7eb74f9019348d3bcac9402ebb7216a4e..71394c874ae988d7b8fef3e3a224d25e1c290640 100644 --- a/crates/workspace/src/tasks.rs +++ b/crates/workspace/src/tasks.rs @@ -20,7 +20,7 @@ impl Workspace { window: &mut Window, cx: &mut Context, ) { - match self.project.read(cx).ssh_connection_state(cx) { + match self.project.read(cx).remote_connection_state(cx) { None | Some(ConnectionState::Connected) => {} Some( ConnectionState::Connecting diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 044601df97840bc95a821cd4f2ccfc2f8b0abbbf..25e2cb1cfe934a88ec4cc3811bf3216e0765c0af 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -74,7 +74,7 @@ use project::{ DirectoryLister, Project, ProjectEntryId, ProjectPath, ResolvedPath, Worktree, WorktreeId, debugger::{breakpoint_store::BreakpointStoreEvent, session::ThreadStatus}, }; -use remote::{SshClientDelegate, SshConnectionOptions, ssh_session::ConnectionIdentifier}; +use remote::{RemoteClientDelegate, SshConnectionOptions, remote_client::ConnectionIdentifier}; use schemars::JsonSchema; use serde::Deserialize; use session::AppSession; @@ -2084,7 +2084,7 @@ impl Workspace { cx: &mut Context, ) -> oneshot::Receiver>> { if self.project.read(cx).is_via_collab() - || self.project.read(cx).is_via_ssh() + || self.project.read(cx).is_via_remote_server() || !WorkspaceSettings::get_global(cx).use_system_path_prompts { let prompt = self.on_prompt_for_new_path.take().unwrap(); @@ -5249,7 +5249,7 @@ impl Workspace { fn serialize_workspace_location(&self, cx: &App) -> WorkspaceLocation { let paths = PathList::new(&self.root_paths(cx)); - if let Some(connection) = self.project.read(cx).ssh_connection_options(cx) { + if let Some(connection) = self.project.read(cx).remote_connection_options(cx) { WorkspaceLocation::Location( SerializedWorkspaceLocation::Ssh(SerializedSshConnection { host: connection.host, @@ -6917,7 +6917,7 @@ async fn join_channel_internal( return None; } - if (project.is_local() || project.is_via_ssh()) + if (project.is_local() || project.is_via_remote_server()) && project.visible_worktrees(cx).any(|tree| { tree.read(cx) .root_entry() @@ -7263,7 +7263,7 @@ pub fn open_ssh_project_with_new_connection( window: WindowHandle, connection_options: SshConnectionOptions, cancel_rx: oneshot::Receiver<()>, - delegate: Arc, + delegate: Arc, app_state: Arc, paths: Vec, cx: &mut App, @@ -7274,7 +7274,7 @@ pub fn open_ssh_project_with_new_connection( let session = match cx .update(|cx| { - remote::SshRemoteClient::new( + remote::RemoteClient::ssh( ConnectionIdentifier::Workspace(workspace_id.0), connection_options, cancel_rx, @@ -7289,7 +7289,7 @@ pub fn open_ssh_project_with_new_connection( }; let project = cx.update(|cx| { - project::Project::ssh( + project::Project::remote( session, app_state.client.clone(), app_state.node_runtime.clone(), diff --git a/crates/zed/src/reliability.rs b/crates/zed/src/reliability.rs index ac06f1fd9f4d9842eb34a67abeef2432074ecc91..9c12a5f1466323cf22233156d1e9bde741f10fa6 100644 --- a/crates/zed/src/reliability.rs +++ b/crates/zed/src/reliability.rs @@ -220,10 +220,10 @@ pub fn init( let installation_id = installation_id.clone(); let system_id = system_id.clone(); - let Some(ssh_client) = project.ssh_client() else { + let Some(remote_client) = project.remote_client() else { return; }; - ssh_client.update(cx, |client, cx| { + remote_client.update(cx, |client, cx| { if !TelemetrySettings::get_global(cx).diagnostics { return; } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 553444ebdbc2fe018f1cad39d0776c4c113eba6f..a3116971b494cc9d110edc58b9c9d6b50ff86c25 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -918,7 +918,7 @@ fn register_actions( capture_audio(workspace, window, cx); }); - if workspace.project().read(cx).is_via_ssh() { + if workspace.project().read(cx).is_via_remote_server() { workspace.register_action({ move |workspace, _: &OpenServerSettings, window, cx| { let open_server_settings = workspace @@ -1543,7 +1543,7 @@ pub fn open_new_ssh_project_from_project( cx: &mut Context, ) -> Task> { let app_state = workspace.app_state().clone(); - let Some(ssh_client) = workspace.project().read(cx).ssh_client() else { + let Some(ssh_client) = workspace.project().read(cx).remote_client() else { return Task::ready(Err(anyhow::anyhow!("Not an ssh project"))); }; let connection_options = ssh_client.read(cx).connection_options();