diff --git a/Cargo.lock b/Cargo.lock index 498c29afd67aa6d2b915d1e237f8af12aa96c253..ea528b0cc670fd9d846ec4787f904f4ffa8f74eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12065,7 +12065,6 @@ dependencies = [ "aho-corasick", "anyhow", "askpass", - "async-compat", "async-trait", "base64 0.22.1", "buffer_diff", @@ -12086,7 +12085,6 @@ dependencies = [ "git_hosting_providers", "globset", "gpui", - "gpui_tokio", "http_client", "image", "indexmap 2.9.0", @@ -12123,8 +12121,6 @@ dependencies = [ "tempfile", "terminal", "text", - "tokio", - "tokio-tungstenite 0.26.2", "toml 0.8.20", "unindent", "url", diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index 8318ca0182d9c1fd7ebd2cfdac5909c03ef77a58..39dc0621732bfd42b3a24735ad803915fbf2885c 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -31,13 +31,6 @@ aho-corasick.workspace = true anyhow.workspace = true askpass.workspace = true async-trait.workspace = true - -# FIXME -tokio-tungstenite.workspace = true -gpui_tokio.workspace = true -tokio.workspace = true -async-compat.workspace = true - base64.workspace = true buffer_diff.workspace = true circular-buffer.workspace = true diff --git a/crates/project/src/debugger/breakpoint_store.rs b/crates/project/src/debugger/breakpoint_store.rs index 42663ab9852a5dc2e9850d20dd20940c6723d03c..b7f5360d189489415032be6e5271b3880a421e57 100644 --- a/crates/project/src/debugger/breakpoint_store.rs +++ b/crates/project/src/debugger/breakpoint_store.rs @@ -164,6 +164,7 @@ pub struct BreakpointStore { impl BreakpointStore { pub fn init(client: &AnyProtoClient) { + log::error!("breakpoint store init"); client.add_entity_request_handler(Self::handle_toggle_breakpoint); client.add_entity_message_handler(Self::handle_breakpoints_for_file); } diff --git a/crates/project/src/debugger/dap_store.rs b/crates/project/src/debugger/dap_store.rs index 1ff42bb9c125c759fe5a2f9456fe6cd33f3a0b1b..c6fc1ddf73ec7e619bf9c13a60db6fe024fa20f1 100644 --- a/crates/project/src/debugger/dap_store.rs +++ b/crates/project/src/debugger/dap_store.rs @@ -22,9 +22,9 @@ use dap::{ inline_value::VariableLookupKind, messages::Message, }; -use fs::Fs; +use fs::{Fs, RemoveOptions}; use futures::{ - StreamExt, + StreamExt, TryStreamExt as _, channel::mpsc::{self, UnboundedSender}, future::{Shared, join_all}, }; @@ -78,12 +78,15 @@ pub struct LocalDapStore { http_client: Arc, environment: Entity, toolchain_store: Arc, + is_headless: bool, } pub struct RemoteDapStore { remote_client: Entity, upstream_client: AnyProtoClient, upstream_project_id: u64, + node_runtime: NodeRuntime, + http_client: Arc, } pub struct DapStore { @@ -134,17 +137,19 @@ impl DapStore { toolchain_store: Arc, worktree_store: Entity, breakpoint_store: Entity, + is_headless: bool, cx: &mut Context, ) -> Self { let mode = DapStoreMode::Local(LocalDapStore { - fs, + fs: fs.clone(), environment, http_client, node_runtime, toolchain_store, + is_headless, }); - Self::new(mode, breakpoint_store, worktree_store, cx) + Self::new(mode, breakpoint_store, worktree_store, fs, cx) } pub fn new_remote( @@ -152,15 +157,20 @@ impl DapStore { remote_client: Entity, breakpoint_store: Entity, worktree_store: Entity, + node_runtime: NodeRuntime, + http_client: Arc, + fs: Arc, cx: &mut Context, ) -> Self { let mode = DapStoreMode::Remote(RemoteDapStore { upstream_client: remote_client.read(cx).proto_client(), remote_client, upstream_project_id: project_id, + node_runtime, + http_client, }); - Self::new(mode, breakpoint_store, worktree_store, cx) + Self::new(mode, breakpoint_store, worktree_store, fs, cx) } pub fn new_collab( @@ -168,17 +178,55 @@ impl DapStore { _upstream_client: AnyProtoClient, breakpoint_store: Entity, worktree_store: Entity, + fs: Arc, cx: &mut Context, ) -> Self { - Self::new(DapStoreMode::Collab, breakpoint_store, worktree_store, cx) + Self::new( + DapStoreMode::Collab, + breakpoint_store, + worktree_store, + fs, + cx, + ) } fn new( mode: DapStoreMode, breakpoint_store: Entity, worktree_store: Entity, - _cx: &mut Context, + fs: Arc, + cx: &mut Context, ) -> Self { + cx.background_spawn(async move { + let dir = paths::debug_adapters_dir().join("js-debug-companion"); + + let mut children = fs.read_dir(&dir).await?.try_collect::>().await?; + children.sort_by_key(|child| semver::Version::parse(child.file_name()?.to_str()?).ok()); + + if let Some(child) = children.last() + && let Some(name) = child.file_name() + && let Some(name) = name.to_str() + && semver::Version::parse(name).is_ok() + { + children.pop(); + } + + for child in children { + fs.remove_dir( + &child, + RemoveOptions { + recursive: true, + ignore_if_not_exists: true, + }, + ) + .await + .ok(); + } + + anyhow::Ok(()) + }) + .detach(); + Self { mode, next_session_id: 0, @@ -401,12 +449,15 @@ impl DapStore { }); } - let remote_client = match &self.mode { - DapStoreMode::Local(_) => None, - DapStoreMode::Remote(remote_dap_store) => Some(remote_dap_store.remote_client.clone()), - DapStoreMode::Collab => None, + let (remote_client, node_runtime, http_client) = match &self.mode { + DapStoreMode::Local(_) => (None, None, None), + DapStoreMode::Remote(remote_dap_store) => ( + Some(remote_dap_store.remote_client.clone()), + Some(remote_dap_store.node_runtime.clone()), + Some(remote_dap_store.http_client.clone()), + ), + DapStoreMode::Collab => (None, None, None), }; - let session = Session::new( self.breakpoint_store.clone(), session_id, @@ -416,6 +467,8 @@ impl DapStore { task_context, quirks, remote_client, + node_runtime, + http_client, cx, ); @@ -545,8 +598,7 @@ impl DapStore { local_store.environment.update(cx, |env, cx| { env.get_worktree_environment(worktree.clone(), cx) }), - // FIXME not quite right (collab) - self.downstream_client.is_some(), + local_store.is_headless, )) } diff --git a/crates/project/src/debugger/session.rs b/crates/project/src/debugger/session.rs index 8566695a1be3063db72dc069d752b317a6778988..e3709c4aaff035a18516dc7cf666db9238167075 100644 --- a/crates/project/src/debugger/session.rs +++ b/crates/project/src/debugger/session.rs @@ -14,7 +14,7 @@ use super::dap_command::{ TerminateCommand, TerminateThreadsCommand, ThreadsCommand, VariablesCommand, }; use super::dap_store::DapStore; -use anyhow::{Context as _, Result, anyhow, bail}; +use anyhow::{Context as _, Result, anyhow}; use base64::Engine; use collections::{HashMap, HashSet, IndexMap}; use dap::adapters::{DebugAdapterBinary, DebugAdapterName}; @@ -33,24 +33,25 @@ use dap::{ }; use futures::channel::mpsc::UnboundedSender; use futures::channel::{mpsc, oneshot}; -use futures::compat::CompatSink; +use futures::io::BufReader; +use futures::{AsyncBufReadExt as _, SinkExt, StreamExt, TryStreamExt}; use futures::{FutureExt, future::Shared}; -use futures::{SinkExt, StreamExt}; use gpui::{ App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, EventEmitter, SharedString, Task, WeakEntity, }; +use http_client::HttpClient; -use gpui_tokio::Tokio; +use node_runtime::NodeRuntime; use remote::RemoteClient; use rpc::ErrorExt; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; use smol::net::TcpListener; use std::any::TypeId; use std::collections::BTreeMap; use std::ops::RangeInclusive; -use std::pin::Pin; +use std::path::PathBuf; use std::process::Stdio; use std::u64; use std::{ @@ -704,6 +705,9 @@ pub struct Session { memory: memory::Memory, quirks: SessionQuirks, remote_client: Option>, + node_runtime: Option, + http_client: Option>, + companion_port: Option, } trait CacheableCommand: Any + Send + Sync { @@ -821,6 +825,8 @@ impl Session { task_context: TaskContext, quirks: SessionQuirks, remote_client: Option>, + node_runtime: Option, + http_client: Option>, cx: &mut App, ) -> Entity { cx.new::(|cx| { @@ -877,6 +883,9 @@ impl Session { memory: memory::Memory::new(), quirks, remote_client, + node_runtime, + http_client, + companion_port: None, } }) } @@ -1574,13 +1583,13 @@ impl Session { log::error!("failed to deserialize launchBrowserInCompanion event"); return; }; - let Some(remote_client) = self.remote_client.clone() else { - log::error!( - "no remote client so not handling launchBrowserInCompanion event" - ); + self.launch_browser_for_remote_server(request, cx); + } else if event.event == "killCompanionBrowser" { + let Some(request) = serde_json::from_value(event.body).ok() else { + log::error!("failed to deserialize killCompanionBrowser event"); return; }; - self.launch_browser_for_remote_server(remote_client, request, cx); + self.kill_browser(request, cx); } } } @@ -2744,12 +2753,23 @@ impl Session { fn launch_browser_for_remote_server( &mut self, - remote_client: Entity, - request: LaunchBrowserInCompanionParams, + mut request: LaunchBrowserInCompanionParams, cx: &mut Context, ) { - let task = cx.spawn(async move |_, cx| { - let (port_for_dap, _child) = + let Some(remote_client) = self.remote_client.clone() else { + log::error!("can't launch browser in companion for non-remote project"); + return; + }; + let Some(http_client) = self.http_client.clone() else { + return; + }; + let Some(node_runtime) = self.node_runtime.clone() else { + return; + }; + + let mut console_output = self.console_output(cx); + let task = cx.spawn(async move |this, cx| { + let (dap_port, _child) = if remote_client.read_with(cx, |client, _| client.shares_network_interface())? { (request.server_port, None) } else { @@ -2775,111 +2795,289 @@ impl Session { (port, Some(child)) }; - let port_for_browser = { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .context("getting port for browser")?; - listener.local_addr()?.port() - }; - - let path = request.path.clone(); - let _child = spawn_browser(request, port_for_browser)?; - - Tokio::spawn(cx, async move { - let url = format!("ws://localhost:{port_for_dap}{path}"); - log::info!("will connect to DAP running on remote at {url}"); - let (dap_stream, _response) = tokio_tungstenite::connect_async(&url) - .await - .context("connecting to DAP")?; - let (mut dap_in, mut dap_out) = dap_stream.split(); - log::info!("established websocket connection to DAP running on remote"); - - let url = format!("ws://localhost:{port_for_browser}"); - log::info!("will connect to browser running running locally at {url}"); - tokio::time::sleep(std::time::Duration::from_millis(1000)).await; - let (browser_stream, _response) = tokio_tungstenite::connect_async(&url) - .await - .context("connecting to browser")?; - let (mut browser_in, mut browser_out) = browser_stream.split(); - log::info!("established websocket connection to browser running locally"); - - let down_task = tokio::spawn(async move { - while let Some(message) = dap_out.next().await { - let message = message.context("reading message from DAP")?; - browser_in - .send(message) - .await - .context("sending message to browser")?; + let mut companion_process = None; + let companion_port = + if let Some(companion_port) = this.read_with(cx, |this, _| this.companion_port)? { + companion_port + } else { + let task = cx.spawn(async move |cx| spawn_companion(node_runtime, cx).await); + match task.await { + Ok((port, child)) => { + companion_process = Some(child); + port + } + Err(e) => { + console_output + .send(format!("Failed to launch browser companion process: {e}")) + .await + .ok(); + return Err(e); + } } - anyhow::Ok(()) - }); - let up_task = tokio::spawn(async move { - while let Some(message) = browser_out.next().await { - let message = message.context("reading message from browser")?; - dap_in - .send(message) + }; + this.update(cx, |this, cx| { + this.companion_port = Some(companion_port); + let Some(mut child) = companion_process else { + return; + }; + if let Some(stderr) = child.stderr.take() { + let mut console_output = console_output.clone(); + this.background_tasks.push(cx.spawn(async move |_, _| { + let mut stderr = BufReader::new(stderr); + let mut line = String::new(); + while let Ok(n) = stderr.read_line(&mut line).await + && n > 0 + { + console_output + .send(format!("companion stderr: {line}")) + .await + .ok(); + line.clear(); + } + })); + } + this.background_tasks.push(cx.spawn({ + let mut console_output = console_output.clone(); + async move |_, _| match child.status().await { + Ok(status) => { + if status.success() { + console_output + .send(format!("Companion process exited normally")) + .await + .ok(); + } else { + console_output + .send(format!( + "Companion process exited abnormally with {status:?}" + )) + .await + .ok(); + } + } + Err(e) => { + console_output + .send(format!("Failed to join companion process: {e}")) + .await + .ok(); + } + } + })) + })?; + + request + .other + .insert("proxyUri".into(), format!("127.0.0.1:{dap_port}").into()); + // FIXME wslInfo? + + let response = http_client + .get( + &format!("http://127.0.0.1:{companion_port}/launch-and-attach"), + serde_json::to_string(&request) + .context("serializing request")? + .into(), + false, + ) + .await; + match response { + Ok(response) => { + if !response.status().is_success() { + console_output + .send(format!("Launch request to companion failed")) .await - .context("sending message to DAP")?; + .ok(); + return Err(anyhow!("launch request failed")); } - anyhow::Ok(()) - }); - down_task.await.ok(); - up_task.await.ok(); - anyhow::Ok(()) - })? - .await??; + } + Err(e) => { + console_output + .send(format!("Failed to read response from companion")) + .await + .ok(); + return Err(e); + } + } + anyhow::Ok(()) }); self.background_tasks.push(cx.spawn(async move |_, _| { task.await.log_err(); })); } -} -fn spawn_browser( - mut request: LaunchBrowserInCompanionParams, - port_for_browser: u16, -) -> Result { - if let Some(ix) = request - .browser_args - .iter() - .position(|arg| arg == "--remote-debugging-pipe") - { - request.browser_args[ix] = format!("--remote-debugging-port={port_for_browser}"); - request - .browser_args - .retain(|arg| !arg.starts_with("--remote-debugging-io-pipes")); - } else { - // FIXME - bail!("expected --remote-debugging-pipe") - } - - dbg!(&request.browser_args); - - // FIXME - let path = match request.r#type.as_str() { - "edge" => "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe", - "chrome" => "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", - other => bail!("unrecognized browser debugging type: {other:?}"), - }; + fn kill_browser(&self, request: KillCompanionBrowserParams, cx: &mut App) { + let Some(companion_port) = self.companion_port else { + log::error!("received killCompanionBrowser but js-debug-companion is not running"); + return; + }; + let Some(http_client) = self.http_client.clone() else { + return; + }; - let child = new_smol_command(path) - .args(request.browser_args) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .context("spawning browser")?; - Ok(child) + cx.spawn(async move |_| { + http_client + .get( + &format!("http://127.0.0.1:{companion_port}/launch-and-attach"), + serde_json::to_string(&request) + .context("serializing request")? + .into(), + false, + ) + .await?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx) + } } -#[derive(Deserialize)] +// export interface ILaunchParams { +// type: 'chrome' | 'edge'; +// path: string; +// proxyUri: string; +// launchId: number; +// browserArgs: string[]; +// wslInfo?: IWslInfo; +// attach?: { +// host: string; +// port: number; +// }; +// // See IChromiumLaunchConfiguration in js-debug for the full type, a subset of props are here: +// params: { +// env: Readonly<{ [key: string]: string | null }>; +// runtimeExecutable: string; +// userDataDir: boolean | string; +// cwd: string | null; +// webRoot: string | null; +// }; +// } +#[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct LaunchBrowserInCompanionParams { + // FIXME move some of these into other r#type: String, + path: String, browser_args: Vec, server_port: u16, - path: String, launch_id: u64, params: serde_json::Value, + #[serde(flatten)] + other: HashMap, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct KillCompanionBrowserParams { + launch_id: String, +} + +async fn spawn_companion( + node_runtime: NodeRuntime, + cx: &mut AsyncApp, +) -> Result<(u16, smol::process::Child)> { + let binary_path = node_runtime + .binary_path() + .await + .context("getting node path")?; + let path = cx + .spawn(async move |cx| get_or_install_companion(node_runtime, cx).await) + .await?; + log::info!("will launch js-debug-companion version {path:?}"); + + let port = { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .context("getting port for companion")?; + listener.local_addr()?.port() + }; + + // FIXME is this right? + let dir = paths::data_dir() + .join("js_debug_companion_state") + .to_string_lossy() + .to_string(); + + let child = new_smol_command(binary_path) + .arg(path) + .args([ + format!("--listen=127.0.0.1:{port}"), + format!("--state={dir}"), + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("spawning companion child process")?; + + Ok((port, child)) +} + +async fn get_or_install_companion(node: NodeRuntime, cx: &mut AsyncApp) -> Result { + // FIXME publish + const PACKAGE_NAME: &str = "C:\\Users\\Cole\\vscode-js-debug-companion"; + + async fn install_latest_version(dir: PathBuf, node: NodeRuntime) -> Result { + let temp_dir = tempfile::tempdir().context("creating temporary directory")?; + node.npm_install_packages(temp_dir.path(), &[(PACKAGE_NAME, "latest")]) + .await + .context("installing latest companion package")?; + let version = node + .npm_package_installed_version(temp_dir.path(), PACKAGE_NAME) + .await + .context("getting installed companion version")? + .context("companion was not installed")?; + smol::fs::rename(temp_dir.path(), dir.join(&version)) + .await + .context("moving companion package into place")?; + Ok(dir.join(version)) + } + + let dir = paths::debug_adapters_dir().join("js-debug-companion"); + let (latest_installed_version, latest_version) = cx + .background_spawn({ + let dir = dir.clone(); + let node = node.clone(); + async move { + smol::fs::create_dir_all(&dir) + .await + .context("creating companion installation directory")?; + + let mut children = smol::fs::read_dir(&dir) + .await + .context("reading companion installation directory")? + .try_collect::>() + .await + .context("reading companion installation directory entries")?; + children + .sort_by_key(|child| semver::Version::parse(child.file_name().to_str()?).ok()); + + let latest_installed_version = children.last().and_then(|child| { + let version = child.file_name().into_string().ok()?; + Some((child.path(), version)) + }); + let latest_version = node + .npm_package_latest_version(PACKAGE_NAME) + .await + .log_err(); + anyhow::Ok((latest_installed_version, latest_version)) + } + }) + .await?; + + let path = if let Some((installed_path, installed_version)) = latest_installed_version { + if let Some(latest_version) = latest_version + && latest_version != installed_version + { + cx.background_spawn(install_latest_version(dir.clone(), node.clone())) + .detach(); + } + Ok(installed_path) + } else { + cx.background_spawn(install_latest_version(dir.clone(), node.clone())) + .await + }; + + Ok(path? + .join("node_modules") + .join(PACKAGE_NAME) + .join("out") + .join("cli.js")) } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 24a364012e7eac22e34ef767d89b916338379198..8a27493138748adf29f08b25b2aa13336eda380d 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -1082,6 +1082,7 @@ impl Project { toolchain_store.read(cx).as_language_toolchain_store(), worktree_store.clone(), breakpoint_store.clone(), + false, cx, ) }); @@ -1304,6 +1305,9 @@ impl Project { remote.clone(), breakpoint_store.clone(), worktree_store.clone(), + node.clone(), + client.http_client(), + fs.clone(), cx, ) }); @@ -1501,6 +1505,7 @@ impl Project { client.clone().into(), breakpoint_store.clone(), worktree_store.clone(), + fs.clone(), cx, ) })?; diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index 0309ae36b1a985cbcc4e8b02332d209f27844d52..7083f353bb749d965eaa036011c3637788349da0 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -122,6 +122,7 @@ impl HeadlessProject { toolchain_store.read(cx).as_language_toolchain_store(), worktree_store.clone(), breakpoint_store.clone(), + true, cx, ); dap_store.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx);