debugger: Add support for remote browser debugging (#39248)

Cole Miller and Nia created

This PR adds support for browser debugging in SSH and WSL projects. We
use the vscode-js-debug-companion extension, repackaged as a standalone
CLI (https://github.com/zed-industries/js-debug-companion-cli).

Closes #38878

Release Notes:

- debugger: Browser debugging is now supported in SSH and WSL projects.

---------

Co-authored-by: Nia <nia@zed.dev>

Change summary

crates/dap/src/adapters.rs                      |   1 
crates/dap_adapters/src/javascript.rs           |   7 
crates/project/src/debugger/breakpoint_store.rs |   1 
crates/project/src/debugger/dap_store.rs        |  82 ++++
crates/project/src/debugger/session.rs          | 339 ++++++++++++++++++
crates/project/src/project.rs                   |   5 
crates/project_panel/src/project_panel.rs       |  15 
crates/remote/src/remote_client.rs              |  35 +
crates/remote/src/transport/ssh.rs              |  17 
crates/remote/src/transport/wsl.rs              |   9 
crates/remote_server/src/headless_project.rs    |   1 
crates/zeta2/src/zeta2.rs                       |   2 
12 files changed, 497 insertions(+), 17 deletions(-)

Detailed changes

crates/dap/src/adapters.rs 🔗

@@ -46,6 +46,7 @@ pub trait DapDelegate: Send + Sync + 'static {
     async fn which(&self, command: &OsStr) -> Option<PathBuf>;
     async fn read_text_file(&self, path: &RelPath) -> Result<String>;
     async fn shell_env(&self) -> collections::HashMap<String, String>;
+    fn is_headless(&self) -> bool;
 }
 
 #[derive(

crates/dap_adapters/src/javascript.rs 🔗

@@ -120,6 +120,13 @@ impl JsDebugAdapter {
             configuration
                 .entry("sourceMapRenames")
                 .or_insert(true.into());
+
+            // Set up remote browser debugging
+            if delegate.is_headless() {
+                configuration
+                    .entry("browserLaunchLocation")
+                    .or_insert("ui".into());
+            }
         }
 
         let adapter_path = if let Some(user_installed_path) = user_installed_path {

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);
     }

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<dyn HttpClient>,
     environment: Entity<ProjectEnvironment>,
     toolchain_store: Arc<dyn LanguageToolchainStore>,
+    is_headless: bool,
 }
 
 pub struct RemoteDapStore {
     remote_client: Entity<RemoteClient>,
     upstream_client: AnyProtoClient,
     upstream_project_id: u64,
+    node_runtime: NodeRuntime,
+    http_client: Arc<dyn HttpClient>,
 }
 
 pub struct DapStore {
@@ -134,17 +137,19 @@ impl DapStore {
         toolchain_store: Arc<dyn LanguageToolchainStore>,
         worktree_store: Entity<WorktreeStore>,
         breakpoint_store: Entity<BreakpointStore>,
+        is_headless: bool,
         cx: &mut Context<Self>,
     ) -> 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<RemoteClient>,
         breakpoint_store: Entity<BreakpointStore>,
         worktree_store: Entity<WorktreeStore>,
+        node_runtime: NodeRuntime,
+        http_client: Arc<dyn HttpClient>,
+        fs: Arc<dyn Fs>,
         cx: &mut Context<Self>,
     ) -> 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<BreakpointStore>,
         worktree_store: Entity<WorktreeStore>,
+        fs: Arc<dyn Fs>,
         cx: &mut Context<Self>,
     ) -> 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<BreakpointStore>,
         worktree_store: Entity<WorktreeStore>,
-        _cx: &mut Context<Self>,
+        fs: Arc<dyn Fs>,
+        cx: &mut Context<Self>,
     ) -> 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::<Vec<_>>().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,6 +449,15 @@ impl DapStore {
             });
         }
 
+        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,
@@ -409,6 +466,9 @@ impl DapStore {
             adapter,
             task_context,
             quirks,
+            remote_client,
+            node_runtime,
+            http_client,
             cx,
         );
 
@@ -538,6 +598,7 @@ impl DapStore {
             local_store.environment.update(cx, |env, cx| {
                 env.get_worktree_environment(worktree.clone(), cx)
             }),
+            local_store.is_headless,
         ))
     }
 
@@ -870,6 +931,7 @@ pub struct DapAdapterDelegate {
     http_client: Arc<dyn HttpClient>,
     toolchain_store: Arc<dyn LanguageToolchainStore>,
     load_shell_env_task: Shared<Task<Option<HashMap<String, String>>>>,
+    is_headless: bool,
 }
 
 impl DapAdapterDelegate {
@@ -881,6 +943,7 @@ impl DapAdapterDelegate {
         http_client: Arc<dyn HttpClient>,
         toolchain_store: Arc<dyn LanguageToolchainStore>,
         load_shell_env_task: Shared<Task<Option<HashMap<String, String>>>>,
+        is_headless: bool,
     ) -> Self {
         Self {
             fs,
@@ -890,6 +953,7 @@ impl DapAdapterDelegate {
             node_runtime,
             toolchain_store,
             load_shell_env_task,
+            is_headless,
         }
     }
 }
@@ -953,4 +1017,8 @@ impl dap::adapters::DapDelegate for DapAdapterDelegate {
 
         self.fs.load(&abs_path).await
     }
+
+    fn is_headless(&self) -> bool {
+        self.is_headless
+    }
 }

crates/project/src/debugger/session.rs 🔗

@@ -31,21 +31,28 @@ use dap::{
     RunInTerminalRequestArguments, StackFramePresentationHint, StartDebuggingRequestArguments,
     StartDebuggingRequestArgumentsRequest, VariablePresentationHint, WriteMemoryArguments,
 };
-use futures::SinkExt;
 use futures::channel::mpsc::UnboundedSender;
 use futures::channel::{mpsc, oneshot};
+use futures::io::BufReader;
+use futures::{AsyncBufReadExt as _, SinkExt, StreamExt, TryStreamExt};
 use futures::{FutureExt, future::Shared};
 use gpui::{
     App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, EventEmitter, SharedString,
     Task, WeakEntity,
 };
+use http_client::HttpClient;
 
+use node_runtime::NodeRuntime;
+use remote::RemoteClient;
 use rpc::ErrorExt;
+use serde::{Deserialize, Serialize};
 use serde_json::Value;
-use smol::stream::StreamExt;
+use smol::net::TcpListener;
 use std::any::TypeId;
 use std::collections::BTreeMap;
 use std::ops::RangeInclusive;
+use std::path::PathBuf;
+use std::process::Stdio;
 use std::u64;
 use std::{
     any::Any,
@@ -56,6 +63,7 @@ use std::{
 };
 use task::TaskContext;
 use text::{PointUtf16, ToPointUtf16};
+use util::command::new_smol_command;
 use util::{ResultExt, debug_panic, maybe};
 use worktree::Worktree;
 
@@ -696,6 +704,10 @@ pub struct Session {
     task_context: TaskContext,
     memory: memory::Memory,
     quirks: SessionQuirks,
+    remote_client: Option<Entity<RemoteClient>>,
+    node_runtime: Option<NodeRuntime>,
+    http_client: Option<Arc<dyn HttpClient>>,
+    companion_port: Option<u16>,
 }
 
 trait CacheableCommand: Any + Send + Sync {
@@ -812,6 +824,9 @@ impl Session {
         adapter: DebugAdapterName,
         task_context: TaskContext,
         quirks: SessionQuirks,
+        remote_client: Option<Entity<RemoteClient>>,
+        node_runtime: Option<NodeRuntime>,
+        http_client: Option<Arc<dyn HttpClient>>,
         cx: &mut App,
     ) -> Entity<Self> {
         cx.new::<Self>(|cx| {
@@ -867,6 +882,10 @@ impl Session {
                 task_context,
                 memory: memory::Memory::new(),
                 quirks,
+                remote_client,
+                node_runtime,
+                http_client,
+                companion_port: None,
             }
         })
     }
@@ -1557,7 +1576,21 @@ impl Session {
             Events::ProgressStart(_) => {}
             Events::ProgressUpdate(_) => {}
             Events::Invalidated(_) => {}
-            Events::Other(_) => {}
+            Events::Other(event) => {
+                if event.event == "launchBrowserInCompanion" {
+                    let Some(request) = serde_json::from_value(event.body).ok() else {
+                        log::error!("failed to deserialize launchBrowserInCompanion event");
+                        return;
+                    };
+                    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.kill_browser(request, cx);
+                }
+            }
         }
     }
 
@@ -2716,4 +2749,304 @@ impl Session {
     pub fn quirks(&self) -> SessionQuirks {
         self.quirks
     }
+
+    fn launch_browser_for_remote_server(
+        &mut self,
+        mut request: LaunchBrowserInCompanionParams,
+        cx: &mut Context<Self>,
+    ) {
+        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 {
+                    let port = {
+                        let listener = TcpListener::bind("127.0.0.1:0")
+                            .await
+                            .context("getting port for DAP")?;
+                        listener.local_addr()?.port()
+                    };
+                    let child = remote_client.update(cx, |client, _| {
+                        let command = client.build_forward_port_command(
+                            port,
+                            "localhost".into(),
+                            request.server_port,
+                        )?;
+                        let child = new_smol_command(command.program)
+                            .args(command.args)
+                            .envs(command.env)
+                            .spawn()
+                            .context("spawning port forwarding process")?;
+                        anyhow::Ok(child)
+                    })??;
+                    (port, Some(child))
+                };
+
+            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);
+                        }
+                    }
+                };
+            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("Companion process exited normally".into())
+                                    .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());
+            // TODO pass wslInfo as needed
+
+            let response = http_client
+                .post_json(
+                    &format!("http://127.0.0.1:{companion_port}/launch-and-attach"),
+                    serde_json::to_string(&request)
+                        .context("serializing request")?
+                        .into(),
+                )
+                .await;
+            match response {
+                Ok(response) => {
+                    if !response.status().is_success() {
+                        console_output
+                            .send("Launch request to companion failed".into())
+                            .await
+                            .ok();
+                        return Err(anyhow!("launch request failed"));
+                    }
+                }
+                Err(e) => {
+                    console_output
+                        .send("Failed to read response from companion".into())
+                        .await
+                        .ok();
+                    return Err(e);
+                }
+            }
+
+            anyhow::Ok(())
+        });
+        self.background_tasks.push(cx.spawn(async move |_, _| {
+            task.await.log_err();
+        }));
+    }
+
+    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;
+        };
+
+        cx.spawn(async move |_| {
+            http_client
+                .post_json(
+                    &format!("http://127.0.0.1:{companion_port}/kill"),
+                    serde_json::to_string(&request)
+                        .context("serializing request")?
+                        .into(),
+                )
+                .await?;
+            anyhow::Ok(())
+        })
+        .detach_and_log_err(cx)
+    }
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct LaunchBrowserInCompanionParams {
+    server_port: u16,
+    #[serde(flatten)]
+    other: HashMap<String, serde_json::Value>,
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct KillCompanionBrowserParams {
+    launch_id: u64,
+}
+
+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()
+    };
+
+    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<PathBuf> {
+    const PACKAGE_NAME: &str = "@zed-industries/js-debug-companion-cli";
+
+    async fn install_latest_version(dir: PathBuf, node: NodeRuntime) -> Result<PathBuf> {
+        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::<Vec<_>>()
+                    .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"))
 }

crates/project/src/project.rs 🔗

@@ -1084,6 +1084,7 @@ impl Project {
                     toolchain_store.read(cx).as_language_toolchain_store(),
                     worktree_store.clone(),
                     breakpoint_store.clone(),
+                    false,
                     cx,
                 )
             });
@@ -1306,6 +1307,9 @@ impl Project {
                     remote.clone(),
                     breakpoint_store.clone(),
                     worktree_store.clone(),
+                    node.clone(),
+                    client.http_client(),
+                    fs.clone(),
                     cx,
                 )
             });
@@ -1503,6 +1507,7 @@ impl Project {
                 client.clone().into(),
                 breakpoint_store.clone(),
                 worktree_store.clone(),
+                fs.clone(),
                 cx,
             )
         })?;

crates/project_panel/src/project_panel.rs 🔗

@@ -3426,17 +3426,20 @@ impl ProjectPanel {
                             new_state.max_width_item_index = Some(visited_worktrees_length + index);
                         }
                     }
-                    if let Some((worktree_id, entry_id)) = new_selected_entry {
-                        new_state.selection = Some(SelectedEntry {
-                            worktree_id,
-                            entry_id,
-                        });
-                    }
                     new_state
                 })
                 .await;
             this.update_in(cx, |this, window, cx| {
+                let current_selection = this.state.selection;
                 this.state = new_state;
+                if let Some((worktree_id, entry_id)) = new_selected_entry {
+                    this.state.selection = Some(SelectedEntry {
+                        worktree_id,
+                        entry_id,
+                    });
+                } else {
+                    this.state.selection = current_selection;
+                }
                 let elapsed = now.elapsed();
                 if this.last_reported_update.elapsed() > Duration::from_secs(3600) {
                     telemetry::event!(

crates/remote/src/remote_client.rs 🔗

@@ -836,6 +836,18 @@ impl RemoteClient {
         connection.build_command(program, args, env, working_dir, port_forward)
     }
 
+    pub fn build_forward_port_command(
+        &self,
+        local_port: u16,
+        host: String,
+        remote_port: u16,
+    ) -> Result<CommandTemplate> {
+        let Some(connection) = self.remote_connection() else {
+            return Err(anyhow!("no ssh connection"));
+        };
+        connection.build_forward_port_command(local_port, host, remote_port)
+    }
+
     pub fn upload_directory(
         &self,
         src_path: PathBuf,
@@ -1104,6 +1116,12 @@ pub(crate) trait RemoteConnection: Send + Sync {
         working_dir: Option<String>,
         port_forward: Option<(u16, String, u16)>,
     ) -> Result<CommandTemplate>;
+    fn build_forward_port_command(
+        &self,
+        local_port: u16,
+        remote: String,
+        remote_port: u16,
+    ) -> Result<CommandTemplate>;
     fn connection_options(&self) -> RemoteConnectionOptions;
     fn path_style(&self) -> PathStyle;
     fn shell(&self) -> String;
@@ -1533,6 +1551,23 @@ mod fake {
             })
         }
 
+        fn build_forward_port_command(
+            &self,
+            local_port: u16,
+            host: String,
+            remote_port: u16,
+        ) -> anyhow::Result<CommandTemplate> {
+            Ok(CommandTemplate {
+                program: "ssh".into(),
+                args: vec![
+                    "-N".into(),
+                    "-L".into(),
+                    format!("{local_port}:{host}:{remote_port}"),
+                ],
+                env: Default::default(),
+            })
+        }
+
         fn upload_directory(
             &self,
             _src_path: PathBuf,

crates/remote/src/transport/ssh.rs 🔗

@@ -145,6 +145,23 @@ impl RemoteConnection for SshRemoteConnection {
         )
     }
 
+    fn build_forward_port_command(
+        &self,
+        local_port: u16,
+        host: String,
+        remote_port: u16,
+    ) -> Result<CommandTemplate> {
+        Ok(CommandTemplate {
+            program: "ssh".into(),
+            args: vec![
+                "-N".into(),
+                "-L".into(),
+                format!("{local_port}:{host}:{remote_port}"),
+            ],
+            env: Default::default(),
+        })
+    }
+
     fn upload_directory(
         &self,
         src_path: PathBuf,

crates/remote/src/transport/wsl.rs 🔗

@@ -433,6 +433,15 @@ impl RemoteConnection for WslRemoteConnection {
         })
     }
 
+    fn build_forward_port_command(
+        &self,
+        _: u16,
+        _: String,
+        _: u16,
+    ) -> anyhow::Result<CommandTemplate> {
+        Err(anyhow!("WSL shares a network interface with the host"))
+    }
+
     fn connection_options(&self) -> RemoteConnectionOptions {
         RemoteConnectionOptions::Wsl(self.connection_options.clone())
     }

crates/remote_server/src/headless_project.rs 🔗

@@ -123,6 +123,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);

crates/zeta2/src/zeta2.rs 🔗

@@ -1387,7 +1387,7 @@ mod tests {
 
                                 let (res_tx, res_rx) = oneshot::channel();
                                 req_tx.unbounded_send((req, res_tx)).unwrap();
-                                serde_json::to_string(&res_rx.await.unwrap()).unwrap()
+                                serde_json::to_string(&res_rx.await?).unwrap()
                             }
                             _ => {
                                 panic!("Unexpected path: {}", uri)