diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 03539577..5701ac0a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -156,7 +156,13 @@ jobs: - name: musl toolchain if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y musl-tools - - run: cargo build --release --locked -p ide-host --bin openidea-server --target ${{ matrix.target }} + - name: Build + run: | + # musl-tools only ships `musl-gcc`; cc-rs would look for `-linux-musl-gcc`. + if [ "$RUNNER_OS" = Linux ]; then export CC_${TARGET//-/_}=musl-gcc; fi + cargo build --release --locked -p ide-host --bin openidea-server --target "$TARGET" + env: + TARGET: ${{ matrix.target }} - name: Upload env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/crates/ide-host/src/bin/server.rs b/crates/ide-host/src/bin/server.rs index 9802fca9..a647ba69 100644 --- a/crates/ide-host/src/bin/server.rs +++ b/crates/ide-host/src/bin/server.rs @@ -66,6 +66,10 @@ async fn run(base: PathBuf) -> i32 { runtime: tokio::runtime::Handle::current(), })); tracing::info!("openidea-server {} ready", env!("CARGO_PKG_VERSION")); - rpc::serve(host, Dispatcher::standard(), peer, tokio::io::stdin(), tokio::io::stdout()).await; + rpc::serve(host.clone(), Dispatcher::standard(), peer, tokio::io::stdin(), tokio::io::stdout()).await; + // `process::exit` skips destructors: nothing started for the client may outlive it. + if tokio::time::timeout(std::time::Duration::from_secs(10), host.shutdown()).await.is_err() { + tracing::warn!("language servers did not shut down in time"); + } 0 } diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index 6a9e67a3..fcf599d1 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -124,6 +124,18 @@ impl Host { } } + /// Stops what this host started: language servers, agents, runs and terminals. + /// For a process that exits without dropping the host (the remote server). + pub async fn shutdown(&self) { + self.processes.kill_all(); + self.terminals.kill_all(); + let projects: Vec<_> = self.projects.write().drain().map(|(_, p)| p).collect(); + for project in projects { + project.agents.shutdown(); + project.lsp.shutdown_all().await; + } + } + // ---- cancellable requests: the UI allocates ids, `cancel_request` cancels ---- pub fn begin_request(&self, id: RequestId) -> CancellationToken { diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs index 933055e3..1366b9db 100644 --- a/crates/ide-host/src/ops/build.rs +++ b/crates/ide-host/src/ops/build.rs @@ -125,7 +125,11 @@ pub async fn build_sync( /// The POM declaring `dependency` for the Maven module `pom` (project-relative): the /// module's own, else its nearest parent in the project; its path, text and declaration. -fn declaring_pom(project: &Project, pom: &str, dependency: &ArtifactKey) -> IdeResult<(std::path::PathBuf, String, Range)> { +fn declaring_pom( + project: &Project, + pom: &str, + dependency: &ArtifactKey, +) -> IdeResult<(std::path::PathBuf, String, Range)> { let model = ide_build::maven::load(&project.root); for rel in ide_build::pom_edit::declaring_poms(&model, pom) { let path = project.root.join(&rel); diff --git a/crates/ide-jsonrpc/src/lib.rs b/crates/ide-jsonrpc/src/lib.rs index 1086e892..b2a0096d 100644 --- a/crates/ide-jsonrpc/src/lib.rs +++ b/crates/ide-jsonrpc/src/lib.rs @@ -194,6 +194,14 @@ impl Handler for NoopHandler { type Pending = HashMap>>; +/// What the delivery thread hands on, in arrival order: a response is not seen +/// before the notifications the peer sent ahead of it (a streamed result's last +/// values before its request completes). +enum Incoming { + Notification(String, Value), + Response(oneshot::Sender>, Result), +} + struct Inner { out: mpsc::UnboundedSender>, pending: Mutex, @@ -451,14 +459,21 @@ impl Peer { }); } - // Notifications are delivered in order on a dedicated thread. - let (notif_tx, notif_rx) = std::sync::mpsc::channel::<(String, Value)>(); + // Notifications (and the responses after them) are delivered in order on a dedicated thread. + let (notif_tx, notif_rx) = std::sync::mpsc::channel::(); { let handler = handler.clone(); std::thread::Builder::new() .name("jsonrpc-notify".into()) .spawn(move || { - while let Ok((method, params)) = notif_rx.recv() { + while let Ok(incoming) = notif_rx.recv() { + let (method, params) = match incoming { + Incoming::Notification(method, params) => (method, params), + Incoming::Response(tx, result) => { + let _ = tx.send(result); + continue; + } + }; let h = handler.clone(); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { h.on_notification(&method, params) @@ -614,12 +629,7 @@ fn parse_id(id: &Value) -> Option { } } -fn dispatch( - inner: &Arc, - handler: &Arc, - notif_tx: &std::sync::mpsc::Sender<(String, Value)>, - body: &[u8], -) { +fn dispatch(inner: &Arc, handler: &Arc, notif_tx: &std::sync::mpsc::Sender, body: &[u8]) { tracing::trace!(target: "ide_jsonrpc::wire", "<-- {}", String::from_utf8_lossy(body)); let msg: Value = match serde_json::from_slice(body) { Ok(v) => v, @@ -657,7 +667,7 @@ fn dispatch( }); } _ => { - let _ = notif_tx.send((method, params)); + let _ = notif_tx.send(Incoming::Notification(method, params)); } } } @@ -688,7 +698,7 @@ fn dispatch( } _ => Ok(obj.remove("result").unwrap_or(Value::Null)), }; - let _ = tx.send(result); + let _ = notif_tx.send(Incoming::Response(tx, result)); } } } diff --git a/crates/ide-remote/src/deploy.rs b/crates/ide-remote/src/deploy.rs index 2bbac24e..81887a01 100644 --- a/crates/ide-remote/src/deploy.rs +++ b/crates/ide-remote/src/deploy.rs @@ -238,7 +238,8 @@ impl Deployment<'_> { pub async fn delete_remote(&self, paths: &[PathBuf]) -> TransferReport { let mut report = TransferReport::default(); for p in paths { - let Some(remote) = self.map.remote(p).filter(|r| !self.exclude.remote(r)) else { + // Never a whole mapping (the project folder went away, or a mapped folder did). + let Some(remote) = self.map.remote(p).filter(|r| !self.exclude.remote(r) && !self.map.is_root(r)) else { report.skipped += 1; continue; }; @@ -318,7 +319,9 @@ impl Deployment<'_> { break; } let local = step.path.split('/').filter(|s| !s.is_empty()).fold(root.clone(), |p, s| p.join(s)); - let Some(remote) = self.map.remote(&local) else { + let Some(remote) = + self.map.remote(&local).filter(|r| !self.exclude.remote(r) && !self.excluded_local(&local)) + else { report.skipped += 1; continue; }; diff --git a/crates/ide-remote/src/mapping.rs b/crates/ide-remote/src/mapping.rs index ec2bbece..0fbcb0ca 100644 --- a/crates/ide-remote/src/mapping.rs +++ b/crates/ide-remote/src/mapping.rs @@ -61,11 +61,21 @@ impl Mappings { pub fn remote(&self, local: &Path) -> Option { self.entries.iter().find_map(|(l, r, _)| { let rel = local.strip_prefix(l).ok()?; + // `..` would leave the mapping (`posix` drops it). + if rel.components().any(|c| !matches!(c, Component::Normal(_))) { + return None; + } let rel = posix(rel); Some(if rel.is_empty() { r.clone() } else { join(r, &rel) }) }) } + /// `remote` is the remote folder of a mapping (deleting it would wipe the deployment). + pub fn is_root(&self, remote: &str) -> bool { + let remote = remote.trim_end_matches('/'); + self.entries.iter().any(|(_, r, _)| r.trim_end_matches('/') == remote) + } + /// The local path of a remote file or folder (`None`: not under any mapping). pub fn local(&self, remote: &str) -> Option { let remote = remote.trim_end_matches('/'); @@ -85,6 +95,10 @@ impl Mappings { } } let (l, rest, _) = best?; + // A `..` from the server must not leave the local folder. + if rest.split('/').any(|s| s == ".." || s == ".") { + return None; + } Some(rest.split('/').filter(|s| !s.is_empty()).fold(l.clone(), |p, s| p.join(s))) } @@ -126,7 +140,7 @@ impl Excludes { .iter() .map(|p| p.trim()) .filter(|p| !p.is_empty()) - .map(|p| join(remote_root, p).trim_end_matches('/').to_owned()) + .map(|p| join(remote_root, p.trim_start_matches('/')).trim_end_matches('/').to_owned()) .collect(); Self { names: b.build().unwrap_or_else(|_| GlobSet::empty()), remote } } diff --git a/crates/ide-remote/src/target.rs b/crates/ide-remote/src/target.rs index 8a2fbf7a..de85c3e7 100644 --- a/crates/ide-remote/src/target.rs +++ b/crates/ide-remote/src/target.rs @@ -33,7 +33,14 @@ pub struct RemoteCommand { impl RemoteCommand { pub fn line(&self) -> String { match &self.shell_line { - Some(line) => format!("cd {} && {line}", ide_ssh::shell::quote(&self.cwd)), + Some(line) => { + let exports: String = self + .env + .iter() + .map(|(k, v)| format!("export {}; ", ide_ssh::shell::quote(&format!("{k}={v}")))) + .collect(); + format!("cd {} && {exports}{line}", ide_ssh::shell::quote(&self.cwd)) + } None => ide_ssh::shell::command_line(Some(&self.cwd), &self.env, &self.program, &self.args), } } @@ -51,6 +58,22 @@ impl RemoteCommand { } } +/// `s` with the path `root` replaced by `remote` where it appears as a whole path +/// (followed by a separator or the end of a path), not as the prefix of a longer name. +fn replace_root(s: &str, root: &str, remote: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(i) = rest.find(root) { + let after = &rest[i + root.len()..]; + let whole = after.chars().next().is_none_or(|c| !(c.is_alphanumeric() || matches!(c, '_' | '-' | '.'))); + out.push_str(&rest[..i]); + out.push_str(if whole { remote } else { root }); + rest = after; + } + out.push_str(rest); + out +} + fn file_name(program: &str) -> &str { program.rsplit(['/', '\\']).next().unwrap_or(program) } @@ -79,10 +102,10 @@ pub fn remote_command(config: &RunConfig, target: &RunTarget, root: &Path, remot if root_str.is_empty() { return s.to_owned(); } - s.replace(&root_str, remote_root.trim_end_matches('/')).replace('\\', "/") + replace_root(s, &root_str, remote_root.trim_end_matches('/')).replace('\\', "/") }; let map_text = |s: &str| -> String { - if root_str.is_empty() { s.to_owned() } else { s.replace(&root_str, remote_root.trim_end_matches('/')) } + if root_str.is_empty() { s.to_owned() } else { replace_root(s, &root_str, remote_root.trim_end_matches('/')) } }; let cwd = match config.cwd.as_ref().map(|p| p.to_path_buf()) { Some(dir) if dir.is_absolute() => match dir.strip_prefix(root) { @@ -314,10 +337,16 @@ mod tests { ); let c = remote_command(&config("npm run dev", &[], None), &target, root, "/home/me/proj"); - assert_eq!(c.line(), "cd /home/me/proj && npm run dev"); + assert_eq!(c.line(), "cd /home/me/proj && export DATA=/home/me/proj/data; npm run dev"); let c = remote_command(&config("/usr/local/bin/node", &["app.js"], None), &RunTarget::default(), root, "/srv"); assert_eq!((c.program.as_str(), c.cwd.as_str()), ("node", "/srv")); let c = remote_command(&config("/usr/bin/make", &["test"], None), &RunTarget::default(), root, "/srv"); assert_eq!(c.program, "make"); } + + #[test] + fn maps_the_root_only_as_a_whole_path() { + assert_eq!(replace_root("/work/proj2/x:/work/proj/y", "/work/proj", "/srv"), "/work/proj2/x:/srv/y"); + assert_eq!(replace_root("/work/proj", "/work/proj", "/srv"), "/srv"); + } } diff --git a/crates/ide-run/src/process.rs b/crates/ide-run/src/process.rs index 246d3dbf..729f757f 100644 --- a/crates/ide-run/src/process.rs +++ b/crates/ide-run/src/process.rs @@ -575,10 +575,9 @@ impl ProcessManager { pub fn list(&self) -> Vec { self.runs.lock().values().map(|e| e.info()).collect() } -} -impl Drop for ProcessManager { - fn drop(&mut self) { + /// Kills every running process tree at once (shutting down). + pub fn kill_all(&self) { for entry in self.runs.lock().values() { if !entry.exited.load(Ordering::Acquire) && let Some(pid) = entry.pid @@ -589,6 +588,12 @@ impl Drop for ProcessManager { } } +impl Drop for ProcessManager { + fn drop(&mut self) { + self.kill_all(); + } +} + /// The environment a configuration's process gets on top of the inherited one: `.env` /// files, then the configured variables (later wins). fn launch_env(root: &Path, config: &RunConfig) -> IdeResult { diff --git a/crates/ide-ssh/src/askpass.rs b/crates/ide-ssh/src/askpass.rs index 0c391212..8e84d7d3 100644 --- a/crates/ide-ssh/src/askpass.rs +++ b/crates/ide-ssh/src/askpass.rs @@ -113,6 +113,8 @@ fn serve( on_prompt: &(dyn Fn(AskpassPrompt) + Send + Sync), ) { let mut line = String::new(); + // The helper writes its request at once: an idle connection must not hold a thread. + let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(10))); let Ok(mut reader) = stream.try_clone().map(BufReader::new) else { return }; if reader.read_line(&mut line).is_err() { return; diff --git a/crates/ide-ssh/src/manager.rs b/crates/ide-ssh/src/manager.rs index c7a34796..07abb555 100644 --- a/crates/ide-ssh/src/manager.rs +++ b/crates/ide-ssh/src/manager.rs @@ -18,8 +18,9 @@ pub struct SshManager { entered: Mutex>, configs: RwLock>, sessions: Mutex>>, - /// Serialises connecting so concurrent users share one session. - opening: tokio::sync::Mutex<()>, + /// Serialises connecting per configuration so concurrent users share one + /// session (an unreachable host doesn't hold up the others). + opening: Mutex>>>, /// Host keys awaiting the user's trust, by key (configuration id or a caller's own). unknown: Mutex>, } @@ -37,7 +38,7 @@ impl SshManager { entered: Mutex::default(), configs: RwLock::default(), sessions: Mutex::default(), - opening: tokio::sync::Mutex::new(()), + opening: Mutex::default(), unknown: Mutex::default(), } } @@ -117,7 +118,8 @@ impl SshManager { if let Some(s) = self.live(id) { return Ok(s); } - let _opening = self.opening.lock().await; + let lock = self.opening.lock().entry(id.to_owned()).or_default().clone(); + let _opening = lock.lock().await; if let Some(s) = self.live(id) { return Ok(s); } diff --git a/crates/ide-ssh/src/sftp.rs b/crates/ide-ssh/src/sftp.rs index 7ee9a9ed..c14c1446 100644 --- a/crates/ide-ssh/src/sftp.rs +++ b/crates/ide-ssh/src/sftp.rs @@ -124,7 +124,8 @@ impl RemoteFs { let mut out = Vec::new(); for e in read { let name = e.file_name(); - if name == "." || name == ".." { + // A name with `/` (from a hostile server) would point elsewhere once joined. + if name == "." || name == ".." || name.contains('/') { continue; } let path = join(dir, &name); diff --git a/crates/ide-ssh/src/target.rs b/crates/ide-ssh/src/target.rs index 702e643e..18ef6721 100644 --- a/crates/ide-ssh/src/target.rs +++ b/crates/ide-ssh/src/target.rs @@ -137,6 +137,8 @@ pub fn openssh_args(target: &SshTarget) -> Vec { args.extend(["-o".into(), "PreferredAuthentications=password,keyboard-interactive".into()]); } let host = target.host.trim(); + // A host starting with `-` must not be read as an option. + args.push("--".into()); args.push(if target.user.trim().is_empty() { host.to_owned() } else { format!("{}@{host}", target.user.trim()) }); args } @@ -174,9 +176,9 @@ mod tests { #[test] fn builds_openssh_arguments() { let t = SshTarget { host: "box".into(), user: "me".into(), port: Some(2200), ..Default::default() }; - assert_eq!(openssh_args(&t), ["-p", "2200", "me@box"]); + assert_eq!(openssh_args(&t), ["-p", "2200", "--", "me@box"]); let t = SshTarget { host: "box".into(), auth: SshAuth::KeyPair, key_file: "/k/id".into(), ..Default::default() }; - assert_eq!(openssh_args(&t), ["-i", "/k/id", "-o", "IdentitiesOnly=yes", "box"]); + assert_eq!(openssh_args(&t), ["-i", "/k/id", "-o", "IdentitiesOnly=yes", "--", "box"]); } } diff --git a/crates/ide-terminal/src/lib.rs b/crates/ide-terminal/src/lib.rs index 1ab4b72e..18814cb4 100644 --- a/crates/ide-terminal/src/lib.rs +++ b/crates/ide-terminal/src/lib.rs @@ -189,10 +189,9 @@ impl TerminalManager { fn session(&self, id: TerminalId) -> IdeResult> { self.sessions.lock().get(&id).cloned().ok_or_else(|| not_found(id)) } -} -impl Drop for TerminalManager { - fn drop(&mut self) { + /// Terminates every session (shutting down). + pub fn kill_all(&self) { let sessions: Vec<_> = self.sessions.lock().drain().map(|(_, s)| s).collect(); for s in sessions { terminate(&s); @@ -200,6 +199,12 @@ impl Drop for TerminalManager { } } +impl Drop for TerminalManager { + fn drop(&mut self) { + self.kill_all(); + } +} + fn not_found(id: TerminalId) -> IdeError { IdeError::not_found(format!("no such terminal: {id}")) } diff --git a/crates/ide-tools/src/server.rs b/crates/ide-tools/src/server.rs index 7f8f8384..3e3481c4 100644 --- a/crates/ide-tools/src/server.rs +++ b/crates/ide-tools/src/server.rs @@ -42,19 +42,23 @@ pub fn fetch( } let name = asset_name(os, arch); let url = format!("{RELEASES}/v{version}/{name}"); - let dl = sources::Download { - version: version.to_owned(), - checksum_url: Some(format!("{url}.sha256")), - name, - url, - size: None, - sha256: None, - }; let dir = dest.parent().expect("cached path has a parent"); std::fs::create_dir_all(dir)?; let staging = tempfile_dir(dir)?; let result = (|| { let http = Http::new(&default_search_path())?; + // The binary runs on the remote host: never install it unverified. + let checksum_url = format!("{url}.sha256"); + let sha256 = sources::parse_checksum(&http.get_text(&checksum_url, cancel)?, &name) + .ok_or_else(|| IdeError::unavailable(format!("no SHA-256 for {name} at {checksum_url}")))?; + let dl = sources::Download { + version: version.to_owned(), + checksum_url: None, + name, + url, + size: None, + sha256: Some(sha256), + }; let archive = fetch_verified(&http, &dl, &staging, cancel, emit)?; let unpacked = staging.join("openidea-server"); let mut gz = flate2::read::GzDecoder::new(std::fs::File::open(&archive)?); @@ -72,7 +76,10 @@ pub fn fetch( } fn tempfile_dir(parent: &Path) -> IdeResult { - let dir = parent.join(format!(".download-{}", std::process::id())); + // Unique per call: two windows of this process may download at once. + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = parent.join(format!(".download-{}-{n}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir)?; Ok(dir) diff --git a/src-tauri/src/commands/build.rs b/src-tauri/src/commands/build.rs index c5da56c5..9f661d5b 100644 --- a/src-tauri/src/commands/build.rs +++ b/src-tauri/src/commands/build.rs @@ -2,8 +2,8 @@ use crate::state::AppState; use ide_build::compile::{BuildEvent, BuildKind, BuildResult}; -use ide_build::{BuildModel, BuildTool, SyncProgress}; use ide_build::pom_edit::ArtifactKey; +use ide_build::{BuildModel, BuildTool, SyncProgress}; use ide_core::{IdeResult, Location, RequestId}; #[allow(unused_imports)] pub use ide_host::ops::build::*; diff --git a/src-tauri/src/commands/lsp.rs b/src-tauri/src/commands/lsp.rs index 489091b6..462119c1 100644 --- a/src-tauri/src/commands/lsp.rs +++ b/src-tauri/src/commands/lsp.rs @@ -106,8 +106,16 @@ pub async fn lsp_references( include_declaration: bool, project_wide: bool, ) -> IdeResult { - ide_host::ops::lsp::lsp_references(&state.host, window.label(), request, doc, pos, include_declaration, project_wide) - .await + ide_host::ops::lsp::lsp_references( + &state.host, + window.label(), + request, + doc, + pos, + include_declaration, + project_wide, + ) + .await } #[tauri::command] diff --git a/src-tauri/src/remote_dev.rs b/src-tauri/src/remote_dev.rs index 9560eabf..2df33293 100644 --- a/src-tauri/src/remote_dev.rs +++ b/src-tauri/src/remote_dev.rs @@ -29,11 +29,16 @@ pub struct Connection { pub root: String, pub host: String, pub commands: Vec, - /// Streaming arguments by id: the frontend channel and the id it expects. - channels: Arc>>>, + /// Streaming arguments by connection-wide id: the frontend channel and the id + /// it expects (each webview numbers its own, and a settings window shares this + /// connection with its parent). + channels: Channels, + next_channel: std::sync::atomic::AtomicU64, _session: Arc, } +type Channels = Arc, u64)>>>; + #[derive(Default)] pub struct Connections(Mutex>>); @@ -74,7 +79,7 @@ pub struct RemoteProject { struct ClientHandler { app: AppHandle, window: String, - channels: Arc>>>, + channels: Channels, } impl Handler for ClientHandler { @@ -91,8 +96,8 @@ impl Handler for ClientHandler { "channel" => match serde_json::from_value::(params) { Ok(m) => { let channel = self.channels.lock().get(&m.id).cloned(); - if let Some(c) = channel { - let _ = c.send(ChannelFrame { id: m.id, data: m.data.to_string() }); + if let Some((c, id)) = channel { + let _ = c.send(ChannelFrame { id, data: m.data.to_string() }); } } Err(e) => tracing::warn!("remote channel: {e}"), @@ -162,9 +167,14 @@ async fn ensure_server(state: &AppState, session: &ide_ssh::Session) -> IdeResul let version = env!("CARGO_PKG_VERSION"); let dir = install_dir(version); let q = ide_ssh::shell::tilde_quote; - let probe = - session.exec(&format!("uname -sm; ls -d {}/*/openidea-server 2>/dev/null", q(&dir)), None).await?.stdout_str(); - let mut lines = probe.lines(); + // Between markers: shell start-up files may print to stdout too. + const MARK: &str = "__OPENIDEA_PROBE__"; + let probe = session + .exec(&format!("echo {MARK}; uname -sm; ls -d {}/*/openidea-server 2>/dev/null", q(&dir)), None) + .await? + .stdout_str(); + let probe = probe.split_once(MARK).map_or(probe.as_str(), |(_, rest)| rest); + let mut lines = probe.lines().map(str::trim).filter(|l| !l.is_empty()); let (os, arch) = platform(lines.next().unwrap_or_default()); let installed: Vec<&str> = lines.collect(); let local = match local_server_binary(state, &os, &arch) { @@ -200,7 +210,12 @@ async fn ensure_server(state: &AppState, session: &ide_ssh::Session) -> IdeResul fs.close().await; let remote_dir = remote_dir?; let remote_bin = ide_ssh::sftp::join(&ide_ssh::sftp::join(&remote_dir, &build), "openidea-server"); - let tmp = format!("{remote_bin}.part"); + // Per install: another window may be installing the same build right now. + let tmp = format!( + "{remote_bin}.{}-{}.part", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos() + ); let started = std::time::Instant::now(); session.put(&tmp, &tokio::fs::read(&local).await?).await?; tracing::info!("installed openidea-server on {} in {:?}", session.label(), started.elapsed()); @@ -260,7 +275,7 @@ pub async fn remote_connect( .await .map_err(|e| IdeError::unavailable(format!("cannot start openidea-server: {e}")))?; let (reader, writer) = tokio::io::split(stream); - let channels: Arc>>> = Arc::default(); + let channels: Channels = Arc::default(); let handler = Arc::new(ClientHandler { app: window.app_handle().clone(), window: label.clone(), channels: channels.clone() }); let peer = Peer::connect(reader, writer, handler); @@ -288,6 +303,8 @@ pub async fn remote_connect( let call = Invoke { window: label.clone(), cmd: "open_project".into(), args: serde_json::json!({ "path": root }) }; let project: ide_host::ops::project::ProjectInfo = peer.request("invoke", call, None).await.map_err(ide_host::rpc::error_from_reply)?; + // As the server resolved it (symlinks, a trailing `/`): the frontend compares it with the project's. + let root = serde_json::to_value(&project.root).ok().and_then(|v| v.as_str().map(str::to_owned)).unwrap_or(root); let _ = window.set_title(&format!("{} [{}]", project.name, config.label())); let connection = Arc::new(Connection { peer, @@ -296,6 +313,7 @@ pub async fn remote_connect( host: config.label(), commands: commands.clone(), channels, + next_channel: std::sync::atomic::AtomicU64::new(1), _session: session, }); state.remote_dev.0.lock().insert(label, connection); @@ -350,16 +368,18 @@ pub async fn remote_invoke( args: String, on_channel: Channel, ) -> IdeResult { - let args: Value = serde_json::from_str(&args).map_err(|e| IdeError::invalid(format!("arguments: {e}")))?; + let mut args: Value = serde_json::from_str(&args).map_err(|e| IdeError::invalid(format!("arguments: {e}")))?; let label = AppState::owner_window(window.label()).to_owned(); let c = state .remote_dev .get(&label) .ok_or_else(|| IdeError::unavailable("this window is not connected to a remote host"))?; - if let Value::Object(map) = &args { - for v in map.values() { + if let Value::Object(map) = &mut args { + for v in map.values_mut() { if let Some(id) = v.get("$channel").and_then(Value::as_u64) { - c.channels.lock().insert(id, on_channel.clone()); + let own = c.next_channel.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + c.channels.lock().insert(own, (on_channel.clone(), id)); + *v = serde_json::json!({ "$channel": own }); } } }