Файли
openidea/crates/ide-dap/src/lib.rs
T

1811 рядки
77 KiB
Rust

//! Debug Adapter Protocol client.
//!
//! [`DebugManager`] owns every debug session plus the per-file breakpoint
//! store (independent of sessions; sent to each session during configuration
//! and updated live). Sessions emit `IdeEvent::Debug`; the UI then pulls
//! threads, frames, scopes and variables on demand.
//!
//! All lines and columns in this API are 0-based; the client tells adapters
//! `linesStartAt1`/`columnsStartAt1` and converts at this edge.
pub mod adapter;
pub mod attach;
mod cargo;
pub mod connection;
pub mod jdwp;
pub mod kotlin;
mod kotlin_jdwp;
mod session;
pub mod target;
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
use connection::{Connection, DapError};
use futures::future::join_all;
use ide_core::{DebugSessionId, IdeError, IdeResult, SharedSink};
use ide_language::LanguageRegistry;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use session::Session;
use specta::Type;
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::atomic::Ordering;
use std::sync::{Arc, Weak};
use std::time::Duration;
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct LaunchConfig {
pub name: String,
/// DAP adapter name from `languages/*.toml` (or a built-in: codelldb, debugpy, js-debug, delve, netcoredbg).
pub adapter: String,
/// `launch` or `attach`.
pub request: String,
pub program: Option<String>,
pub args: Vec<String>,
pub cwd: Option<ide_core::IpcPath>,
pub env: BTreeMap<String, String>,
/// Adapter specific extra arguments as JSON text, merged into the launch request.
/// A `"cargo": {"args": [...], "filter": {"name", "kind"}}` entry builds with cargo
/// and launches the produced executable; `"debugServer": <port>` connects to an
/// adapter already listening on that local port (e.g. java-debug inside jdtls)
/// instead of starting one, `"debugServer": "host:port"` to one on another machine
/// (`dlv --headless`, `debugpy --listen`).
pub extra_json: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct Breakpoint {
pub line: u32,
pub condition: Option<String>,
pub hit_condition: Option<String>,
/// Logpoint message; when set the breakpoint logs instead of suspending.
pub log_message: Option<String>,
pub enabled: bool,
}
/// A method breakpoint (DAP function breakpoint). `name` uses the adapter's syntax:
/// `pkg.Class#method` for java-debug, `pkg.Func` / `pkg.(*T).Method` for Delve, a plain
/// function name for debugpy and LLDB.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FunctionBreakpoint {
pub name: String,
pub condition: Option<String>,
pub hit_condition: Option<String>,
pub enabled: bool,
}
/// A field watchpoint (DAP data breakpoint) on a `dataId` from [`DebugManager::data_breakpoint_info`]
/// (java-debug ids, `Class#field`, stay valid across sessions).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DataBreakpoint {
pub data_id: String,
/// `read`, `write` or `readWrite` (adapter default when unset).
pub access_type: Option<String>,
pub condition: Option<String>,
pub hit_condition: Option<String>,
pub enabled: bool,
}
/// Answer of `dataBreakpointInfo`: `data_id` is `None` when the value cannot be watched.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DataBreakpointInfo {
pub data_id: Option<String>,
pub description: String,
pub access_types: Vec<String>,
}
/// A call on the current line that Smart Step Into can enter (`stepInTargets`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct StepInTarget {
pub id: i64,
pub label: String,
/// 0-based, when the adapter reports where the call is.
pub line: Option<u32>,
pub column: Option<u32>,
pub end_column: Option<u32>,
}
/// What a session's adapter supports, for enabling actions.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DebugCapabilities {
pub restart_frame: bool,
pub step_in_targets: bool,
pub function_breakpoints: bool,
pub data_breakpoints: bool,
/// `setVariable` or `setExpression`.
pub set_value: bool,
pub conditional_breakpoints: bool,
pub hit_conditional_breakpoints: bool,
pub log_points: bool,
/// HotSwap ("Reload Changed Classes"): java-debug's `redefineClasses` request.
pub hot_code_replace: bool,
}
/// Result of [`DebugManager::redefine_classes`].
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct HotSwapResult {
/// Fully qualified names of the classes reloaded (empty: nothing had changed).
pub changed_classes: Vec<String>,
pub error_message: Option<String>,
}
/// Adapters that implement java-debug's hot code replace protocol.
pub const HOT_CODE_REPLACE_ADAPTERS: &[&str] = &["java-debug"];
/// java-debug's `HotCodeReplaceEvent.changeType` (an enum name, or its numeric code).
pub(crate) fn hot_code_replace_type(v: Option<&Value>) -> String {
match v {
Some(Value::String(s)) => s.clone(),
Some(Value::Number(n)) => match n.as_i64() {
Some(-1) => "ERROR",
Some(-2) => "WARNING",
Some(1) => "STARTING",
Some(2) => "END",
Some(3) => "BUILD_COMPLETE",
_ => "UNKNOWN",
}
.to_owned(),
_ => "UNKNOWN".to_owned(),
}
}
pub(crate) fn hot_swap_result(body: &Value) -> HotSwapResult {
HotSwapResult {
changed_classes: body
.get("changedClasses")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|c| c.as_str().map(str::to_owned)).collect())
.unwrap_or_default(),
error_message: body.get("errorMessage").and_then(Value::as_str).filter(|m| !m.is_empty()).map(str::to_owned),
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct BreakpointState {
pub line: u32,
pub verified: bool,
pub message: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
pub struct ThreadInfo {
pub id: i64,
pub name: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct StackFrame {
pub id: i64,
pub name: String,
pub path: Option<ide_core::IpcPath>,
pub line: u32,
pub column: u32,
/// `subtle` frames (library/generated) are collapsed in the UI.
pub presentation_hint: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct Scope {
pub name: String,
pub variables_reference: i64,
pub expensive: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct Variable {
pub name: String,
pub value: String,
pub type_name: Option<String>,
/// > 0 when expandable.
pub variables_reference: i64,
pub evaluate_name: Option<String>,
}
/// A completion the adapter offers for an expression (DAP `completions`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DebugCompletion {
pub label: String,
/// What to insert, when it differs from `label`.
pub text: Option<String>,
/// DAP item type: `method`, `field`, `variable`, `class`, `keyword`, …
pub kind: Option<String>,
pub detail: Option<String>,
pub sort_text: Option<String>,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum StepKind {
Over,
Into,
Out,
}
/// An exception breakpoint filter offered by an adapter (`exceptionBreakpointFilters`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ExceptionFilter {
pub filter: String,
pub label: String,
pub description: Option<String>,
/// Enabled unless the user chose otherwise.
pub default: bool,
pub supports_condition: bool,
}
/// Exception filters of one session (see [`DebugManager::exception_filters`]).
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct SessionExceptionFilters {
pub session: DebugSessionId,
pub adapter: String,
pub filters: Vec<ExceptionFilter>,
}
/// A live session, as listed by [`DebugManager::sessions`].
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct SessionInfo {
pub id: DebugSessionId,
pub name: String,
pub adapter: String,
/// Set for child sessions started by the adapter (`startDebugging`).
pub parent: Option<DebugSessionId>,
}
/// Who started a session. Every event of the session, and of the child sessions
/// its adapter starts, goes to `sink` only; `key` (e.g. a window label) scopes
/// listings such as [`DebugManager::sessions`].
#[derive(Clone)]
pub struct SessionOwner {
pub key: String,
pub sink: SharedSink,
}
impl SessionOwner {
pub fn new(key: impl Into<String>, sink: SharedSink) -> Self {
Self { key: key.into(), sink }
}
}
const INIT_TIMEOUT: Duration = Duration::from_secs(20);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3);
/// Redefining many classes in a large JVM can take a while.
const HOT_SWAP_TIMEOUT: Duration = Duration::from_secs(120);
pub struct DebugManager {
languages: Arc<LanguageRegistry>,
launcher: Arc<dyn AdapterLauncher>,
me: Weak<DebugManager>,
sessions: Mutex<HashMap<DebugSessionId, Arc<Session>>>,
/// Session start order, for "the first session" semantics.
order: Mutex<Vec<DebugSessionId>>,
breakpoints: Mutex<BTreeMap<PathBuf, Vec<Breakpoint>>>,
states: Mutex<HashMap<PathBuf, Vec<BreakpointState>>>,
/// Explicit exception filter choices (filter id → enabled); unset filters use the adapter default.
exception_choices: Mutex<BTreeMap<String, bool>>,
function_breakpoints: Mutex<Vec<FunctionBreakpoint>>,
data_breakpoints: Mutex<Vec<DataBreakpoint>>,
/// In-flight stops, by session: a second Stop sets `stop_force`, the waiting one kills.
stopping: Mutex<HashMap<DebugSessionId, Arc<Session>>>,
}
/// rdbg (Ruby's debug gem) starts the debuggee itself and ignores `launch`'s program,
/// arguments and environment: they go on its command line after `--` and into its process.
/// In a Bundler project it runs from the bundle, which must have `debug`: the debuggee then
/// loads that version, and the gems rdbg needs (irb is not a default gem from Ruby 3.5).
fn rdbg_command_line(spec: &mut AdapterSpec, config: &LaunchConfig, cwd: &Path) -> IdeResult<()> {
if config.request != "launch" {
return Ok(());
}
if spec.command == "rdbg"
&& let Some(gemfile) = bundler_gemfile(cwd, &config.env)
{
if !bundles_debug(&gemfile) {
return Err(IdeError::unavailable(format!(
"the bundle of {} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`",
gemfile.display()
)));
}
spec.args.splice(0..0, ["exec".to_owned(), "rdbg".to_owned()]);
spec.command = "bundle".into();
}
spec.env.extend(config.env.iter().map(|(k, v)| (k.clone(), v.clone())));
spec.args.push("--".into());
// With only a command, Ruby's exec treats it as shell text. Ruby entry points
// without arguments go through -S so spaces and metacharacters stay literal.
if config.program.is_some() && config.args.is_empty() {
spec.args.extend(["ruby", "-S", "--"].map(str::to_owned));
}
spec.args.extend(config.program.iter().cloned());
spec.args.extend(config.args.iter().cloned());
Ok(())
}
/// The Gemfile Bundler uses in `dir`: `BUNDLE_GEMFILE`, else the closest `Gemfile` / `gems.rb` from `dir` up.
fn bundler_gemfile(dir: &Path, env: &BTreeMap<String, String>) -> Option<PathBuf> {
let gemfile = env
.get("BUNDLE_GEMFILE")
.cloned()
.or_else(|| ide_core::process::host_var_os("BUNDLE_GEMFILE").map(|v| v.to_string_lossy().into_owned()));
match gemfile.filter(|g| !g.is_empty()) {
Some(gemfile) => Some(dir.join(gemfile)),
None => dir.ancestors().flat_map(|d| [d.join("gems.rb"), d.join("Gemfile")]).find(|g| g.is_file()),
}
}
/// Whether the lockfile of `gemfile` lists the `debug` gem.
fn bundles_debug(gemfile: &Path) -> bool {
let lockfile = if gemfile.file_name().is_some_and(|n| n == "gems.rb") {
gemfile.with_file_name("gems.locked")
} else {
let mut name = gemfile.as_os_str().to_owned();
name.push(".lock");
PathBuf::from(name)
};
// The bundle's gems are the lines indented by four spaces: ` debug (1.9.2)`.
std::fs::read_to_string(lockfile).is_ok_and(|text| text.lines().any(|l| l.starts_with(" debug (")))
}
/// Whether the debugged project depends on the Flutter SDK, so it must go
/// through `flutter debug_adapter` instead of the plain Dart one.
///
/// `dart debug_adapter` cannot start a program that imports `dart:ui`, and only
/// the Flutter adapter answers `hotReload` / `hotRestart`. `dir` is the launch
/// `cwd`; the program's own directory is tried first because a module inside a
/// larger repository has its `pubspec.yaml` above the program, not above `cwd`.
fn is_flutter_project(dir: &Path, program: Option<&Path>) -> bool {
let mut start: Vec<&Path> = program.and_then(Path::parent).into_iter().collect();
start.push(dir);
start
.iter()
.filter_map(|d| d.ancestors().take(6).find(|a| a.join("pubspec.yaml").is_file()))
.filter_map(|root| std::fs::read_to_string(root.join("pubspec.yaml")).ok())
.any(|s| s.contains("sdk: flutter"))
}
fn no_session(s: DebugSessionId) -> IdeError {
IdeError::not_found(format!("no debug session {}", s.0))
}
/// Builds the `launch`/`attach` arguments: our fields first, then `extra_json` merged on top.
pub fn launch_arguments(config: &LaunchConfig) -> IdeResult<Value> {
// VS Code puts `type`, `request` and `name` in the arguments; js-debug picks launch
// or attach from `request` there, not from the DAP command.
let mut args = json!({ "name": config.name, "request": config.request });
if let Some(t) = adapter::default_type(&config.adapter) {
args["type"] = t.into();
}
if let Some(p) = &config.program {
args["program"] = p.as_str().into();
}
if !config.args.is_empty() {
args["args"] = json!(config.args);
}
if let Some(cwd) = &config.cwd {
args["cwd"] = json!(cwd);
}
if !config.env.is_empty() {
args["env"] = json!(config.env);
}
if let Some(extra) = config.extra_json.as_deref().filter(|e| !e.trim().is_empty()) {
let extra: Value =
serde_json::from_str(extra).map_err(|e| IdeError::invalid(format!("invalid launch JSON: {e}")))?;
let Value::Object(extra) = extra else {
return Err(IdeError::invalid("launch JSON must be an object"));
};
if let Some(obj) = args.as_object_mut() {
obj.extend(extra);
}
}
Ok(args)
}
/// Placeholder in launch arguments for a free local port, the same one everywhere in a
/// launch: Deno's `--inspect-wait=127.0.0.1:${freePort}` and js-debug's
/// `"attachSimplePort": "${freePort}"` (a `port` / `…Port` attribute that is only the
/// placeholder becomes a number; everything else stays a string, e.g. `env` values).
pub const FREE_PORT_PLACEHOLDER: &str = "${freePort}";
/// Replaces [`FREE_PORT_PLACEHOLDER`] in every string of `value` with `port()` (called once, if used).
pub(crate) fn substitute_free_port(value: &mut Value, port: &mut impl FnMut() -> IdeResult<u16>) -> IdeResult<()> {
fn walk(v: &mut Value, key: &str, port: &mut dyn FnMut() -> IdeResult<u16>) -> IdeResult<()> {
match v {
Value::String(s) if s.contains(FREE_PORT_PLACEHOLDER) => {
let p = port()?;
*v = if s == FREE_PORT_PLACEHOLDER && (key == "port" || key.ends_with("Port")) {
p.into()
} else {
s.replace(FREE_PORT_PLACEHOLDER, &p.to_string()).into()
};
}
Value::Array(a) => a.iter_mut().try_for_each(|x| walk(x, key, port))?,
Value::Object(o) => o.iter_mut().try_for_each(|(k, x)| walk(x, k, port))?,
_ => {}
}
Ok(())
}
let mut chosen = None;
walk(value, "", &mut || match chosen {
Some(p) => Ok(p),
None => {
let p = port()?;
chosen = Some(p);
Ok(p)
}
})
}
/// Where a running adapter listens: `debugServer` of the launch arguments.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct DebugServer {
/// `None` for the loopback interface (an adapter inside a local process such as jdtls).
pub host: Option<String>,
pub port: u16,
}
/// Removes `debugServer` (VS Code's "connect to a running adapter" attribute) from the
/// launch arguments: a port (`4711`, `"4711"`) on this machine, or `"host:port"` /
/// `"[v6]:port"` of a remote one (`dlv --headless`, `debugpy --listen`).
pub(crate) fn take_debug_server(arguments: &mut Value) -> IdeResult<Option<DebugServer>> {
let Some(v) = arguments.as_object_mut().and_then(|o| o.remove("debugServer")) else { return Ok(None) };
let invalid = || IdeError::invalid(format!("invalid debugServer address: {v}"));
let port_of = |p: u64| u16::try_from(p).ok().filter(|p| *p != 0);
if let Some(p) = v.as_u64() {
return port_of(p).map(|port| Some(DebugServer { host: None, port })).ok_or_else(invalid);
}
let s = v.as_str().map(str::trim).ok_or_else(invalid)?;
let (host, port) = match s.rsplit_once(':') {
Some((host, port)) => {
let host = host.trim().trim_start_matches('[').trim_end_matches(']');
(Some(host).filter(|h| !h.is_empty()).map(str::to_owned), port)
}
None => (None, s),
};
let port = port.trim().parse::<u64>().ok().and_then(port_of).ok_or_else(invalid)?;
Ok(Some(DebugServer { host, port }))
}
/// DAP stack frame → [`StackFrame`] (1-based → 0-based).
pub(crate) fn frame_from(f: &Value) -> StackFrame {
let source = f.get("source");
let source_hint = source.and_then(|s| s.get("presentationHint")).and_then(Value::as_str);
let hint = f
.get("presentationHint")
.and_then(Value::as_str)
.map(str::to_owned)
.or_else(|| (source_hint == Some("deemphasize")).then(|| "subtle".to_owned()));
StackFrame {
id: f.get("id").and_then(Value::as_i64).unwrap_or_default(),
name: f.get("name").and_then(Value::as_str).unwrap_or_default().to_owned(),
path: source.and_then(|s| s.get("path")).and_then(Value::as_str).map(Into::into),
line: (f.get("line").and_then(Value::as_u64).unwrap_or(0) as u32).saturating_sub(1),
column: (f.get("column").and_then(Value::as_u64).unwrap_or(0) as u32).saturating_sub(1),
presentation_hint: hint,
}
}
pub(crate) fn exception_filters_from(caps: &Value) -> Vec<ExceptionFilter> {
caps.get("exceptionBreakpointFilters")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|f| {
let filter = f.get("filter").and_then(Value::as_str)?.to_owned();
Some(ExceptionFilter {
label: f
.get("label")
.and_then(Value::as_str)
.map(str::to_owned)
.unwrap_or_else(|| filter.clone()),
description: f.get("description").and_then(Value::as_str).map(str::to_owned),
default: f.get("default").and_then(Value::as_bool).unwrap_or(false),
supports_condition: f.get("supportsCondition").and_then(Value::as_bool).unwrap_or(false),
filter,
})
})
.collect()
})
.unwrap_or_default()
}
/// Filter ids to enable: explicit choices win (`adapter:filter`, then the bare filter id,
/// since adapters reuse ids such as `uncaught`), otherwise the adapter's default.
pub(crate) fn enabled_filters(
filters: &[ExceptionFilter],
choices: &BTreeMap<String, bool>,
adapter: &str,
) -> Vec<String> {
filters
.iter()
.filter(|f| {
choices
.get(&format!("{adapter}:{}", f.filter))
.or_else(|| choices.get(&f.filter))
.copied()
.unwrap_or(f.default)
})
.map(|f| f.filter.clone())
.collect()
}
pub(crate) fn step_in_target_from(t: &Value) -> StepInTarget {
let num = |k: &str| t.get(k).and_then(Value::as_u64).map(|n| (n as u32).saturating_sub(1));
StepInTarget {
id: t.get("id").and_then(Value::as_i64).unwrap_or_default(),
label: t.get("label").and_then(Value::as_str).unwrap_or_default().to_owned(),
line: num("line"),
column: num("column"),
end_column: num("endColumn"),
}
}
pub(crate) fn capabilities_from(caps: &Value) -> DebugCapabilities {
let has = |k: &str| caps.get(k).and_then(Value::as_bool).unwrap_or(false);
DebugCapabilities {
restart_frame: has("supportsRestartFrame"),
step_in_targets: has("supportsStepInTargetsRequest"),
function_breakpoints: has("supportsFunctionBreakpoints"),
data_breakpoints: has("supportsDataBreakpoints"),
set_value: has("supportsSetVariable") || has("supportsSetExpression"),
conditional_breakpoints: has("supportsConditionalBreakpoints"),
hit_conditional_breakpoints: has("supportsHitConditionalBreakpoints"),
log_points: has("supportsLogPoints"),
hot_code_replace: false,
}
}
/// States of a file's line breakpoints that no session has been sent.
fn unsent(breakpoints: &[Breakpoint]) -> Vec<BreakpointState> {
breakpoints
.iter()
.map(|b| BreakpointState {
line: b.line,
verified: false,
message: (!b.enabled).then(|| "Disabled".to_owned()),
})
.collect()
}
/// States for breakpoints no session has verified yet.
fn unverified(enabled: impl Iterator<Item = bool>) -> Vec<BreakpointState> {
enabled
.map(|e| BreakpointState { line: 0, verified: false, message: (!e).then(|| "Disabled".to_owned()) })
.collect()
}
/// Delve compiles the debuggee to `__debug_bin` in the session's cwd (on Windows
/// `__debug_bin.exe` plus `__debug_bin.exe<digits>` in DAP mode) and removes it on a
/// graceful exit — a shutdown it doesn't always reach, so the files pile up. Sweep
/// the leftovers once the session is down. Runs after `session_closed`, so Delve
/// has already exited or been killed and holds no file locks; a leftover of a
/// still-building run is removed on that run's own shutdown.
fn remove_delve_binaries(cwd: &Path) {
let Ok(entries) = std::fs::read_dir(cwd) else { return };
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
// Windows writes `__debug_bin.exe` (and `.exe<digits>` in DAP mode); other
// platforms keep the bare name. Suffixes like `.go` are user files.
let suffix = name.strip_prefix("__debug_bin");
let leftover = suffix.is_some_and(|s| {
s.is_empty()
|| s == ".exe"
|| (s.starts_with(".exe") && s[".exe".len()..].chars().all(|c| c.is_ascii_digit()))
});
if !leftover || entry.file_type().is_ok_and(|t| !t.is_file()) {
continue;
}
// Windows: a just-killed dlv / debuggee can still hold this session's
// binary while the previous one's lock is already gone, so one try
// leaves exactly the current leftover behind. Retry on a short back-off
// to outlast the handle release; a truly busy file is retried into the
// next run's sweep anyway.
for attempt in 0..5 {
match std::fs::remove_file(entry.path()) {
Ok(()) => break,
Err(e) if attempt == 4 => {
tracing::debug!("could not remove Delve leftover {}: {e}", entry.path().display());
}
Err(_) => std::thread::sleep(std::time::Duration::from_millis(50 * (attempt as u64 + 1))),
}
}
}
}
pub(crate) fn variable_from(v: &Value) -> Variable {
let s = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_owned);
Variable {
name: s("name").unwrap_or_default(),
value: s("value").unwrap_or_default(),
type_name: s("type").filter(|t| !t.is_empty()),
variables_reference: v.get("variablesReference").and_then(Value::as_i64).unwrap_or(0),
evaluate_name: s("evaluateName"),
}
}
impl DebugManager {
pub fn new(languages: Arc<LanguageRegistry>) -> Arc<Self> {
Self::with_launcher(languages, Arc::new(ProcessLauncher))
}
/// Like [`new`](Self::new) with a custom way to start adapters (tests use in-memory adapters).
pub fn with_launcher(languages: Arc<LanguageRegistry>, launcher: Arc<dyn AdapterLauncher>) -> Arc<Self> {
Arc::new_cyclic(|me| Self {
languages,
launcher,
me: me.clone(),
sessions: Mutex::default(),
order: Mutex::default(),
breakpoints: Mutex::default(),
states: Mutex::default(),
exception_choices: Mutex::default(),
function_breakpoints: Mutex::default(),
data_breakpoints: Mutex::default(),
stopping: Mutex::default(),
})
}
fn session(&self, s: DebugSessionId) -> IdeResult<Arc<Session>> {
self.sessions.lock().get(&s).cloned().ok_or_else(|| no_session(s))
}
fn live_sessions(&self) -> Vec<Arc<Session>> {
let sessions = self.sessions.lock();
self.order.lock().iter().filter_map(|id| sessions.get(id).cloned()).collect()
}
pub(crate) fn stored_breakpoints(&self, path: &Path) -> Vec<Breakpoint> {
self.breakpoints.lock().get(path).cloned().unwrap_or_default()
}
/// Live sessions of `owner` (all owners when `None`).
pub fn sessions(&self, owner: Option<&str>) -> Vec<SessionInfo> {
self.live_sessions()
.iter()
.filter(|s| owner.is_none_or(|o| s.owner.key == o))
.map(|s| SessionInfo { id: s.id, name: s.name.clone(), adapter: s.adapter.clone(), parent: s.parent })
.collect()
}
/// Starts a session: spawns the adapter, `initialize`, `launch`/`attach`,
/// waits for `initialized`, sends breakpoints, then `configurationDone`.
/// Events of the session (including cargo build output) go to `owner` only.
pub async fn start(&self, config: LaunchConfig, owner: SessionOwner) -> IdeResult<DebugSessionId> {
self.start_with(config, owner, |_| None).await
}
/// [`start`](Self::start) with the adapter executable chosen by `custom_command`
/// (the configured command → a user-chosen executable, from settings).
pub async fn start_with(
&self,
config: LaunchConfig,
owner: SessionOwner,
custom_command: impl FnOnce(&str) -> Option<String>,
) -> IdeResult<DebugSessionId> {
self.start_prepared(config, owner, custom_command, |_, _| Ok(())).await
}
/// Configure a target adapter before any host tool resolution or launch.
pub async fn start_prepared(
&self,
config: LaunchConfig,
owner: SessionOwner,
custom_command: impl FnOnce(&str) -> Option<String>,
prepare: impl FnOnce(&mut AdapterSpec, &mut Value) -> IdeResult<()>,
) -> IdeResult<DebugSessionId> {
let mut arguments = launch_arguments(&config)?;
// An IDE-side marker (never sent to the adapter): the session is a Flutter
// one regardless of what the working directory holds.
let force_flutter =
arguments.as_object_mut().and_then(|o| o.remove("__flutter")).and_then(|v| v.as_bool()).unwrap_or(false);
let cwd = config
.cwd
.clone()
.map(Into::into)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
let dbg = adapter::find_config(&self.languages, &config.adapter)
.ok_or_else(|| IdeError::unavailable(format!("unknown debug adapter `{}`", config.adapter)))?;
let mut spec = adapter::resolve(&dbg, cwd.clone());
prepare(&mut spec, &mut arguments)?;
if spec.target.is_some() {
return self
.start_session(
DebugSessionId::next(),
owner,
config.name,
config.adapter,
None,
config.request,
arguments,
spec,
None,
)
.await;
}
if config.adapter == kotlin::ADAPTER
&& let Some(home) = arguments
.as_object_mut()
.and_then(|o| o.remove("__adapterJavaHome"))
.and_then(|v| v.as_str().map(str::to_owned))
{
spec.env.push(("JAVA_HOME".into(), home));
}
if config.adapter == "rdbg" {
rdbg_command_line(&mut spec, &config, &cwd)?;
}
if let Some(command) = custom_command(&spec.command) {
spec.command = command;
}
// debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy),
// or with the launcher's when the debuggee runs through a manager (`uv run python`).
if config.adapter == "debugpy" {
let python = match (arguments.get("debugLauncherPython"), arguments.get("python")) {
(Some(Value::String(p)), _) | (_, Some(Value::String(p))) => Some(p.clone()),
(_, Some(Value::Array(a))) => a.first().and_then(Value::as_str).map(str::to_owned),
_ => None,
};
if let Some(python) = python {
spec.command = python;
}
}
// Flutter: a project depending on the Flutter SDK needs `flutter debug_adapter`
// (the Dart adapter cannot start a program importing `dart:ui`, and hot reload
// is Flutter-only). Plain Dart projects keep the configured `dart`.
if config.adapter == "dart" && spec.command == "dart" {
let program = arguments.get("program").and_then(Value::as_str).map(Path::new);
if force_flutter || is_flutter_project(&cwd, program) {
spec.command = "flutter".into();
}
}
let id = DebugSessionId::next();
let debug_server = take_debug_server(&mut arguments)?;
if let Some(server) = &debug_server {
spec.server_host = server.host.clone();
}
if let Some(cargo) = arguments.as_object_mut().and_then(|o| o.remove("cargo")) {
let cargo_args: Vec<String> = cargo
.get("args")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect())
.unwrap_or_else(|| vec!["build".into()]);
let filter = cargo::CargoFilter::from_json(cargo.get("filter"));
let sink = owner.sink.clone();
let program = cargo::build(&cwd, &cargo_args, &filter, move |line| {
sink.emit(ide_core::IdeEvent::Debug {
session: id,
event: ide_core::event::DebugEvent::Output { category: "console".into(), output: line },
});
})
.await?;
arguments["program"] = json!(program);
}
// Attach to a Node process started without `--inspect`: open its inspector first.
if config.adapter == "js-debug" && config.request == "attach" && arguments.get("port").is_none() {
let pid = arguments.get("processId").and_then(|p| match p {
Value::String(s) => s.split(':').next().and_then(|p| p.trim().parse::<u32>().ok()),
other => other.as_u64().and_then(|p| u32::try_from(p).ok()),
});
if let Some(pid) = pid {
let port = attach::enable_node_inspector(pid).await?;
if let Some(obj) = arguments.as_object_mut() {
obj.remove("processId");
obj.insert("port".into(), port.into());
obj.entry("address").or_insert_with(|| "127.0.0.1".into());
}
}
}
substitute_free_port(&mut arguments, &mut || adapter::free_port().map_err(IdeError::from))?;
// VS Code passes the workspace folder: js-debug's default `outFiles` (where it looks
// for compiled code of a TypeScript breakpoint before it loads) is relative to it.
if config.adapter == "js-debug" && arguments.get("__workspaceFolder").is_none() {
arguments["__workspaceFolder"] = json!(cwd);
}
// Godot compares `project` with its own (resolved) project path: `/tmp/game` is
// rejected by an editor that opened `/private/tmp/game`.
if config.adapter == "godot"
&& let Some(project) = arguments.get("project").and_then(Value::as_str)
&& let Ok(real) = std::fs::canonicalize(project)
{
arguments["project"] = json!(real);
}
if config.adapter == "codelldb" {
if arguments.get("sourceLanguages").is_none() {
arguments["sourceLanguages"] = json!(["rust"]);
}
// The Debug Console and Evaluate Expression take expressions, as with every other
// debugger; LLDB commands still run with a leading backtick.
match arguments.get_mut("_adapterSettings") {
None => arguments["_adapterSettings"] = json!({"consoleMode": "evaluate"}),
Some(Value::Object(settings)) => {
settings.entry("consoleMode").or_insert_with(|| json!("evaluate"));
}
Some(_) => {}
}
}
let port = debug_server.map(|s| s.port);
self.start_session(id, owner, config.name, config.adapter, None, config.request, arguments, spec, port).await
}
/// `startDebugging` reverse request: a child session on the same adapter.
pub(crate) async fn start_child(
&self,
parent: &Arc<Session>,
request: String,
configuration: Value,
) -> IdeResult<()> {
let name =
configuration.get("name").and_then(Value::as_str).map(str::to_owned).unwrap_or_else(|| parent.name.clone());
let mut configuration = configuration;
if let Some(obj) = configuration.as_object_mut() {
obj.entry("request").or_insert_with(|| request.clone().into());
}
self.start_session(
DebugSessionId::next(),
parent.owner.clone(),
name,
parent.adapter.clone(),
Some(parent.id),
request,
configuration,
parent.spec.clone(),
parent.port,
)
.await
.map(|_| ())
}
#[allow(clippy::too_many_arguments)]
async fn start_session(
&self,
id: DebugSessionId,
owner: SessionOwner,
name: String,
adapter_name: String,
parent: Option<DebugSessionId>,
request: String,
arguments: Value,
spec: AdapterSpec,
reuse_port: Option<u16>,
) -> IdeResult<DebugSessionId> {
if request != "launch" && request != "attach" {
return Err(IdeError::invalid(format!("request must be `launch` or `attach`, got `{request}`")));
}
let io = self.launcher.launch(spec.clone(), reuse_port).await.map_err(|e| {
if spec.target.is_some() {
IdeError::new(e.kind, format!("{}\nargv: {:?} {:?}", e.message, spec.command, spec.args))
} else {
e
}
})?;
let stderr = io.stderr.clone();
let (conn, incoming) = {
let stderr = stderr.clone();
Connection::start_with_target(
io.reader,
io.writer,
Box::new(move |line| adapter::push_line(&stderr, line)),
spec.target.clone(),
)
};
let rdbg_pid = (adapter_name == "rdbg" && request == "launch")
.then(|| io.child.as_ref().and_then(tokio::process::Child::id))
.flatten();
let session =
Arc::new(Session::new(id, owner, name, adapter_name, parent, request, conn, spec, io.port, io.child));
// rdbg execs the debuggee in its own process and sends no DAP process event.
if let Some(pid) = rdbg_pid {
session.debuggee_pid.store(i64::from(pid), Ordering::Release);
}
if let Some(mut output) = io.program_output {
session.output_drained.send_replace(false);
let session = session.clone();
tokio::spawn(async move {
while let Some((category, output)) = output.recv().await {
session.emit(ide_core::event::DebugEvent::Output { category: category.into(), output });
}
session.output_drained.send_replace(true);
});
}
self.sessions.lock().insert(id, session.clone());
self.order.lock().push(id);
// Announced before the event loop runs, so it precedes every adapter event.
session.emit(ide_core::event::DebugEvent::Started {
name: session.name.clone(),
adapter: session.adapter.clone(),
parent,
});
tokio::spawn(session::event_loop(self.me.clone(), session.clone(), incoming));
let configured = tokio::select! {
biased;
_ = session.startup_cancel.cancelled() => return Err(IdeError::cancelled()),
result = self.configure(&session, arguments) => result,
};
match configured {
Ok(()) => Ok(id),
Err(e) => {
let e = if session.conn.is_closed() {
let mut msg = format!("debug adapter `{}` exited during startup", session.adapter);
if session.spec.target.is_some() {
msg.push_str(&format!("\nargv: {:?} {:?}", session.spec.command, session.spec.args));
}
let tail = stderr.lock().join("\n");
if !tail.is_empty() {
msg.push_str(":\n");
msg.push_str(&tail);
}
if let Some(hint) = &session.spec.install_hint {
msg.push('\n');
msg.push_str(hint);
}
IdeError::unavailable(msg)
} else if session.spec.target.is_some() {
IdeError::new(
e.kind,
format!(
"{}\nargv: {:?} {:?}\n{}",
e.message,
session.spec.command,
session.spec.args,
stderr.lock().join("\n")
),
)
} else {
e
};
session.terminated.store(true, Ordering::Release);
self.shutdown(&session, true).await;
Err(e)
}
}
}
async fn configure(&self, s: &Arc<Session>, mut arguments: Value) -> IdeResult<()> {
if s.adapter == kotlin::ADAPTER {
let project = arguments
.get("projectRoot")
.and_then(Value::as_str)
.map(PathBuf::from)
.unwrap_or_else(|| s.spec.cwd.clone());
let mirror = tokio::task::spawn_blocking(move || kotlin::SourceMirror::create(&project))
.await
.map_err(|e| IdeError::internal(e.to_string()))??;
arguments["projectRoot"] = json!(mirror.root());
*s.kotlin_mirror.lock() = Some(mirror);
}
let caps = s
.conn
.request_timeout(
"initialize",
json!({
"clientID": "openidea",
"clientName": "OpenIDEA",
"adapterID": s.adapter,
"locale": "en",
"linesStartAt1": true,
"columnsStartAt1": true,
"pathFormat": "path",
"supportsVariableType": true,
"supportsVariablePaging": false,
"supportsRunInTerminalRequest": s.spec.target.is_none(),
"supportsStartDebuggingRequest": s.spec.target.is_none(),
"supportsProgressReporting": true,
"supportsInvalidatedEvent": false,
"supportsMemoryReferences": false,
"supportsArgsCanBeInterpretedByShell": false,
}),
INIT_TIMEOUT,
)
.await?;
*s.caps.lock() = if caps.is_object() { caps } else { json!({}) };
// The Kotlin adapter ignores the resolved classpath and project runtime on launch.
let request = if s.adapter == kotlin::ADAPTER && s.request == "launch" {
session::start_kotlin_debuggee(s, &mut arguments).await?;
"attach"
} else {
&s.request
};
if s.adapter == kotlin::ADAPTER {
let host = arguments
.get("hostName")
.and_then(Value::as_str)
.ok_or_else(|| IdeError::invalid("Missing Kotlin JDWP host"))?
.to_owned();
let port = arguments
.get("port")
.and_then(Value::as_u64)
.and_then(|p| u16::try_from(p).ok())
.filter(|p| *p != 0)
.ok_or_else(|| IdeError::invalid("Invalid Kotlin JDWP port"))?;
let (relay, port) = kotlin_jdwp::StartupRelay::start(host, port).await?;
*s.kotlin_relay.lock() = Some(relay);
arguments["hostName"] = json!("127.0.0.1");
arguments["port"] = json!(port);
}
// Launch/attach may wait for a slow runtime or a remote target. Let the adapter report
// failure; Stop cancels configuration and a closed connection fails the pending reply.
// Many adapters answer only after `configurationDone`, so don't wait for the reply yet.
let launch = s.conn.send_request(request, arguments)?;
let mut launch = Box::pin(async move { launch.await.unwrap_or(Err(DapError::Closed)) });
let mut initialized = s.initialized.subscribe();
let mut launch_result = None;
let got_initialized = tokio::select! {
r = initialized.wait_for(|v| *v) => { r.map_err(|_| DapError::Closed)?; true }
r = &mut launch => {
r?;
launch_result = Some(());
false
}
};
if !got_initialized {
// Launched before `initialized`; give the adapter a moment to send it.
let _ = tokio::time::timeout(INIT_TIMEOUT, initialized.wait_for(|v| *v)).await;
}
// A fresh adapter has no breakpoints, so a file with none enabled needs no request; the
// others go out together, as VS Code sends them, instead of one round trip per file.
let (files, idle): (Vec<_>, Vec<_>) = self
.breakpoints
.lock()
.iter()
.map(|(p, b)| (p.clone(), b.clone()))
.partition(|(_, b)| b.iter().any(|b| b.enabled));
let sent = join_all(files.iter().map(|(path, bps)| s.send_breakpoints(path, bps, None))).await;
// Breakpoint states follow the owner's first session.
let first = self.live_sessions().into_iter().find(|f| f.owner.key == s.owner.key).map(|f| f.id) == Some(s.id);
let idle = idle.into_iter().map(|(path, bps)| (path, Ok(unsent(&bps))));
for (path, result) in files.into_iter().map(|(path, _)| path).zip(sent).chain(idle) {
match result {
Ok(states) => {
if first {
self.states.lock().insert(path.clone(), states);
s.emit(ide_core::event::DebugEvent::BreakpointsChanged { path: path.into() });
}
}
Err(e) if s.adapter == kotlin::ADAPTER => return Err(e.into()),
Err(e) => tracing::debug!("setBreakpoints for {} failed: {e}", path.display()),
}
}
let functions = self.function_breakpoints.lock().clone();
if !functions.is_empty()
&& s.supports("supportsFunctionBreakpoints")
&& let Err(e) = s.send_function_breakpoints(&functions).await
{
tracing::debug!("setFunctionBreakpoints failed: {e}");
}
let data = self.data_breakpoints.lock().clone();
if !data.is_empty()
&& s.supports("supportsDataBreakpoints")
&& let Err(e) = s.send_data_breakpoints(&data).await
{
tracing::debug!("setDataBreakpoints failed: {e}");
}
let has_filters = s.caps.lock().get("exceptionBreakpointFilters").and_then(Value::as_array).is_some();
if has_filters {
let _ = self.send_exception_breakpoints(s).await;
}
if s.supports("supportsConfigurationDoneRequest") {
if s.adapter == kotlin::ADAPTER {
drop(s.conn.send_request("configurationDone", json!({}))?);
} else {
s.conn.request_timeout("configurationDone", Value::Null, REQUEST_TIMEOUT).await?;
}
}
s.configured.store(true, Ordering::Release);
if launch_result.is_none() {
launch.await?;
}
if let Some(relay) = s.kotlin_relay.lock().as_mut() {
relay.release();
}
Ok(())
}
/// The connection dropped (adapter exited or `shutdown` finished).
pub(crate) fn session_closed(&self, s: &Arc<Session>) {
s.kill_processes();
let removed = self.sessions.lock().remove(&s.id).is_some();
self.order.lock().retain(|id| *id != s.id);
if removed && !s.terminated.swap(true, Ordering::AcqRel) {
s.emit(ide_core::event::DebugEvent::Terminated);
}
}
/// Ends a session: `terminate` (when asked and supported) → `disconnect` → kill.
pub(crate) async fn shutdown(&self, s: &Arc<Session>, terminate: bool) {
// java-debug runs the program itself and pipes its stdout over DAP. A `disconnect`
// ends that DAP session, so every line the program writes while shutting down is lost.
// Ask the program to exit ourselves and keep the adapter attached until it has drained
// the console (`terminated`), as IDEA does with the process it owns.
let pid = s.debuggee_pid.load(Ordering::Acquire);
let own_terminate = terminate && s.request == "launch" && pid > 0;
// rdbg's terminate exits the Ruby process without terminating its children.
let graceful =
terminate && s.request == "launch" && s.adapter != "rdbg" && s.supports("supportsTerminateRequest");
if !s.conn.is_closed() {
if graceful {
let _ = s.conn.request_timeout("terminate", json!({ "restart": false }), SHUTDOWN_TIMEOUT).await;
}
if !graceful && !own_terminate {
let _ = s
.conn
.request_timeout(
"disconnect",
json!({ "restart": false, "terminateDebuggee": s.request == "launch" }),
SHUTDOWN_TIMEOUT,
)
.await;
}
}
if own_terminate && !graceful {
// Signal before anything else: waiting on a request the adapter may not answer
// would delay the shutdown the console is supposed to show.
crate::session::signal_debuggee(s, false);
}
if graceful {
// java-debug sends `terminated` only after it has drained the program's console
// (it waits up to 5 s for the output pump). Close on the process id alone and the
// last shutdown lines are dropped, so wait for that event here.
let deadline = std::time::Instant::now() + crate::session::DEBUGGEE_EXIT_WAIT;
while !s.terminated.load(Ordering::Acquire) && std::time::Instant::now() < deadline {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
}
// An adapter answers `disconnect` before the program it launched has exited, and that
// program keeps its port until it is gone. Reporting the session as terminated then
// lets Run start again into an occupied port, so wait for the program first — with the
// connection still open, so a second Stop can find the session and kill it.
crate::session::wait_for_debuggee(s, terminate).await;
if terminate && s.request == "launch" {
// The adapter reports `terminated` only after it has drained the program's console
// (java-debug waits up to 5 s for the pump): closing before that drops the last
// shutdown lines from the console.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(6);
while !s.terminated.load(Ordering::Acquire) && !s.conn.is_closed() && std::time::Instant::now() < deadline {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
}
if graceful && !s.conn.is_closed() {
let _ = s
.conn
.request_timeout(
"disconnect",
json!({ "restart": false, "terminateDebuggee": false }),
SHUTDOWN_TIMEOUT,
)
.await;
}
s.conn.close();
self.session_closed(s);
if s.adapter == "delve" && s.spec.server_host.is_none() {
let cwd = s.spec.cwd.clone();
tokio::task::spawn_blocking(move || remove_delve_binaries(&cwd));
}
}
/// Breakpoints are kept per file even without a session and sent on start.
/// Returns states from the first live session, or unverified states when none.
pub async fn set_breakpoints(
&self,
path: PathBuf,
breakpoints: Vec<Breakpoint>,
) -> IdeResult<Vec<BreakpointState>> {
{
let mut store = self.breakpoints.lock();
if breakpoints.is_empty() {
store.remove(&path);
} else {
store.insert(path.clone(), breakpoints.clone());
}
}
let mut result: Option<Vec<BreakpointState>> = None;
for s in self.live_sessions() {
if !s.configured.load(Ordering::Acquire) || s.conn.is_closed() {
continue;
}
match s.send_breakpoints(&path, &breakpoints, None).await {
Ok(states) => {
result.get_or_insert(states);
}
Err(e) if s.adapter == kotlin::ADAPTER => return Err(e.into()),
Err(e) => tracing::debug!("setBreakpoints on session {} failed: {e}", s.id.0),
}
}
let states = result.unwrap_or_else(|| unsent(&breakpoints));
self.states.lock().insert(path, states.clone());
Ok(states)
}
/// Refresh fallback copies after an editor save.
pub async fn refresh_source_mirrors(&self, owner: &str) -> IdeResult<()> {
let sessions: Vec<_> = self.sessions.lock().values().filter(|s| s.owner.key == owner).cloned().collect();
tokio::task::spawn_blocking(move || {
for s in sessions {
if let Some(mirror) = s.kotlin_mirror.lock().as_mut()
&& let Err(e) = mirror.refresh()
{
tracing::warn!("Could not refresh Kotlin debug sources: {e}");
}
}
Ok(())
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Last known verification states of a file's breakpoints (after `BreakpointsChanged`).
pub fn breakpoint_states(&self, path: &Path) -> Vec<BreakpointState> {
self.states.lock().get(path).cloned().unwrap_or_default()
}
pub(crate) fn update_breakpoint_state(&self, path: &Path, index: usize, bp: &Value) -> bool {
let mut states = self.states.lock();
let Some(slot) = states.get_mut(path).and_then(|v| v.get_mut(index)) else { return false };
let next = session::state_from(bp, slot.line);
let next = BreakpointState { message: next.message.or_else(|| slot.message.clone()), ..next };
let changed = *slot != next;
*slot = next;
changed
}
async fn send_exception_breakpoints(&self, s: &Arc<Session>) -> IdeResult<()> {
let filters = exception_filters_from(&s.caps.lock());
let enabled = enabled_filters(&filters, &self.exception_choices.lock(), &s.adapter);
s.conn.request_timeout("setExceptionBreakpoints", json!({ "filters": enabled }), REQUEST_TIMEOUT).await?;
Ok(())
}
/// Exception filters offered by the adapter of each live session of `owner` (all when `None`).
pub fn exception_filters(&self, owner: Option<&str>) -> Vec<SessionExceptionFilters> {
self.live_sessions()
.iter()
.filter(|s| owner.is_none_or(|o| s.owner.key == o))
.map(|s| SessionExceptionFilters {
session: s.id,
adapter: s.adapter.clone(),
filters: exception_filters_from(&s.caps.lock()),
})
.collect()
}
/// Stores exception filter choices (filter id → enabled; kept without a session, applied on
/// start) and sends them to every configured session that offers exception filters.
pub async fn set_exception_breakpoints(&self, choices: BTreeMap<String, bool>) -> IdeResult<()> {
*self.exception_choices.lock() = choices;
for s in self.live_sessions() {
if !s.configured.load(Ordering::Acquire) || s.conn.is_closed() {
continue;
}
if exception_filters_from(&s.caps.lock()).is_empty() {
continue;
}
if let Err(e) = self.send_exception_breakpoints(&s).await {
tracing::debug!("setExceptionBreakpoints on session {} failed: {e}", s.id.0);
}
}
Ok(())
}
/// Changes a variable's value: `setVariable` in its container, or `setExpression`
/// on its evaluate name when the adapter only supports that. Returns the new value.
pub async fn set_variable(
&self,
s: DebugSessionId,
container: i64,
name: String,
evaluate_name: Option<String>,
frame_id: Option<i64>,
value: String,
) -> IdeResult<Variable> {
let session = self.session(s)?;
let body = if container > 0 && session.supports("supportsSetVariable") {
session
.conn
.request_timeout(
"setVariable",
json!({ "variablesReference": container, "name": name, "value": value }),
REQUEST_TIMEOUT,
)
.await?
} else if let (Some(expr), true) = (evaluate_name.as_ref(), session.supports("supportsSetExpression")) {
let mut args = json!({ "expression": expr, "value": value });
if let Some(f) = frame_id {
args["frameId"] = f.into();
}
session.conn.request_timeout("setExpression", args, REQUEST_TIMEOUT).await?
} else {
return Err(IdeError::unavailable(format!("debug adapter `{}` cannot change values", session.adapter)));
};
Ok(Variable {
name: name.clone(),
value: body.get("value").and_then(Value::as_str).unwrap_or(&value).to_owned(),
type_name: body.get("type").and_then(Value::as_str).filter(|t| !t.is_empty()).map(str::to_owned),
variables_reference: body.get("variablesReference").and_then(Value::as_i64).unwrap_or(0),
evaluate_name: evaluate_name.or(Some(name)),
})
}
pub async fn threads(&self, s: DebugSessionId) -> IdeResult<Vec<ThreadInfo>> {
let body = self.session(s)?.conn.request_timeout("threads", Value::Null, REQUEST_TIMEOUT).await?;
Ok(body
.get("threads")
.and_then(Value::as_array)
.map(|t| {
t.iter()
.map(|t| ThreadInfo {
id: t.get("id").and_then(Value::as_i64).unwrap_or_default(),
name: t.get("name").and_then(Value::as_str).unwrap_or_default().to_owned(),
})
.collect()
})
.unwrap_or_default())
}
pub async fn stack_trace(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<Vec<StackFrame>> {
let session = self.session(s)?;
let body = session
.conn
.request_timeout(
"stackTrace",
json!({ "threadId": thread_id, "startFrame": 0, "levels": 400 }),
REQUEST_TIMEOUT,
)
.await?;
let frame = |f: &Value| {
let mut frame = frame_from(f);
if let Some(client) = frame.path.as_deref().and_then(|p| session.client_path(&p.to_string_lossy())) {
frame.path = Some(client.into());
}
frame
};
Ok(body.get("stackFrames").and_then(Value::as_array).map(|f| f.iter().map(frame).collect()).unwrap_or_default())
}
pub async fn scopes(&self, s: DebugSessionId, frame_id: i64) -> IdeResult<Vec<Scope>> {
let body =
self.session(s)?.conn.request_timeout("scopes", json!({ "frameId": frame_id }), REQUEST_TIMEOUT).await?;
Ok(body
.get("scopes")
.and_then(Value::as_array)
.map(|sc| {
sc.iter()
.map(|sc| Scope {
name: sc.get("name").and_then(Value::as_str).unwrap_or_default().to_owned(),
variables_reference: sc.get("variablesReference").and_then(Value::as_i64).unwrap_or(0),
expensive: sc.get("expensive").and_then(Value::as_bool).unwrap_or(false),
})
.collect()
})
.unwrap_or_default())
}
pub async fn variables(&self, s: DebugSessionId, reference: i64) -> IdeResult<Vec<Variable>> {
let body = self
.session(s)?
.conn
.request_timeout("variables", json!({ "variablesReference": reference }), REQUEST_TIMEOUT)
.await?;
Ok(body
.get("variables")
.and_then(Value::as_array)
.map(|v| v.iter().map(variable_from).collect())
.unwrap_or_default())
}
/// Evaluates in the `repl` context.
pub async fn evaluate(&self, s: DebugSessionId, frame_id: Option<i64>, expression: String) -> IdeResult<Variable> {
self.evaluate_in(s, frame_id, expression, None).await
}
/// Evaluates with an explicit DAP context: `repl` (default), `watch`, `hover`, `clipboard`.
pub async fn evaluate_in(
&self,
s: DebugSessionId,
frame_id: Option<i64>,
expression: String,
context: Option<String>,
) -> IdeResult<Variable> {
let session = self.session(s)?;
let mut context = context.unwrap_or_else(|| "repl".into());
if context == "hover" && !session.supports("supportsEvaluateForHovers") {
context = "watch".into();
}
let mut args = json!({ "expression": expression, "context": context });
if let Some(f) = frame_id {
args["frameId"] = f.into();
}
let body = session.conn.request_timeout("evaluate", args, REQUEST_TIMEOUT).await?;
Ok(Variable {
name: expression.clone(),
value: body.get("result").and_then(Value::as_str).unwrap_or_default().to_owned(),
type_name: body.get("type").and_then(Value::as_str).filter(|t| !t.is_empty()).map(str::to_owned),
variables_reference: body.get("variablesReference").and_then(Value::as_i64).unwrap_or(0),
evaluate_name: Some(expression),
})
}
/// Completions for `text` at `line` / `column` (0-based, UTF-16) in the frame's scope; none
/// when the adapter doesn't offer them.
pub async fn completions(
&self,
s: DebugSessionId,
frame_id: Option<i64>,
text: String,
line: u32,
column: u32,
) -> IdeResult<Vec<DebugCompletion>> {
let session = self.session(s)?;
if !session.supports("supportsCompletionsRequest") {
return Ok(Vec::new());
}
let mut args = json!({ "text": text, "line": line + 1, "column": column + 1 });
if let Some(f) = frame_id {
args["frameId"] = f.into();
}
let body = session.conn.request_timeout("completions", args, REQUEST_TIMEOUT).await?;
let text =
|v: &Value, key: &str| v.get(key).and_then(Value::as_str).filter(|t| !t.is_empty()).map(str::to_owned);
Ok(body
.get("targets")
.and_then(Value::as_array)
.map(|targets| {
targets
.iter()
.filter_map(|t| {
Some(DebugCompletion {
label: text(t, "label")?,
text: text(t, "text"),
kind: text(t, "type"),
detail: text(t, "detail"),
sort_text: text(t, "sortText"),
})
})
.collect()
})
.unwrap_or_default())
}
pub async fn resume(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<()> {
let session = self.session(s)?;
let body = session.conn.request_timeout("continue", json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?;
let all = body.get("allThreadsContinued").and_then(Value::as_bool).unwrap_or(true);
session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: all });
Ok(())
}
pub async fn pause(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<()> {
self.session(s)?.conn.request_timeout("pause", json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?;
Ok(())
}
/// Types `text` into the terminal the session's program runs in (see [`ide_core::event::DebugEvent::ProgramInput`]).
pub fn send_input(&self, s: DebugSessionId, text: &str) -> IdeResult<()> {
let session = self.session(s)?;
let mut program = session.program.lock();
let program = program
.as_mut()
.ok_or_else(|| IdeError::invalid("The program doesn't read input from the debug console"))?;
program.write(text).map_err(|e| IdeError::internal(format!("cannot write to the program: {e}")))
}
/// Flutter's DAP adapter: injects the changed sources into the running app.
/// Not part of the DAP standard; adapters that do not know the request reject it.
pub async fn hot_reload(&self, s: DebugSessionId) -> IdeResult<()> {
self.session(s)?.conn.request_timeout("hotReload", json!({ "reason": "manual" }), REQUEST_TIMEOUT).await?;
Ok(())
}
/// Flutter's DAP adapter: restarts the app without preserving state.
pub async fn hot_restart(&self, s: DebugSessionId) -> IdeResult<()> {
self.session(s)?.conn.request_timeout("hotRestart", json!({ "reason": "manual" }), REQUEST_TIMEOUT).await?;
Ok(())
}
pub async fn step(&self, s: DebugSessionId, thread_id: i64, kind: StepKind) -> IdeResult<()> {
let command = match kind {
StepKind::Over => "next",
StepKind::Into => "stepIn",
StepKind::Out => "stepOut",
};
let session = self.session(s)?;
session.conn.request_timeout(command, json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?;
session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: true });
Ok(())
}
/// Run to cursor: a temporary breakpoint at `line` (0-based) plus `continue`;
/// the file's real breakpoints are restored on the next stop. `force` (Force Run
/// to Cursor) also suspends every other breakpoint until then.
pub async fn run_to(
&self,
s: DebugSessionId,
thread_id: i64,
path: PathBuf,
line: u32,
force: bool,
) -> IdeResult<()> {
let session = self.session(s)?;
if force {
self.suspend_breakpoints(&session, Some(&path)).await?;
}
let stored = self.stored_breakpoints(&path);
let sent: Vec<Breakpoint> =
if force { stored.iter().map(|b| Breakpoint { enabled: false, ..b.clone() }).collect() } else { stored };
session.send_breakpoints(&path, &sent, Some(line)).await?;
session.pending_restore.lock().files.insert(path);
self.resume(s, thread_id).await
}
/// Sends empty breakpoint lists (line, function, data) to `session` until its next
/// stop, when the stored ones are restored. `except` is left to the caller.
async fn suspend_breakpoints(&self, session: &Arc<Session>, except: Option<&Path>) -> IdeResult<()> {
// While paused the adapter holds the stored lists: only files with something enabled change.
let files: Vec<PathBuf> = self
.breakpoints
.lock()
.iter()
.filter(|(path, bps)| Some(path.as_path()) != except && bps.iter().any(|b| b.enabled))
.map(|(path, _)| path.clone())
.collect();
let sent = join_all(files.iter().map(|path| session.send_breakpoints(path, &[], None))).await;
let mut failed = None;
for (path, result) in files.into_iter().zip(sent) {
match result {
Ok(_) => {
session.pending_restore.lock().files.insert(path);
}
Err(e) => failed = failed.or(Some(e)),
}
}
if let Some(e) = failed {
return Err(e.into());
}
if !self.function_breakpoints.lock().is_empty() && session.supports("supportsFunctionBreakpoints") {
session.send_function_breakpoints(&[]).await?;
session.pending_restore.lock().functions = true;
}
if !self.data_breakpoints.lock().is_empty() && session.supports("supportsDataBreakpoints") {
session.send_data_breakpoints(&[]).await?;
session.pending_restore.lock().data = true;
}
Ok(())
}
/// Re-sends what [`suspend_breakpoints`](Self::suspend_breakpoints) / run to cursor replaced.
pub(crate) async fn restore_breakpoints(&self, session: &Arc<Session>) {
let pending = std::mem::take(&mut *session.pending_restore.lock());
let files: Vec<(PathBuf, Vec<Breakpoint>)> =
pending.files.into_iter().map(|path| (path.clone(), self.stored_breakpoints(&path))).collect();
let sent = join_all(files.iter().map(|(path, bps)| session.send_breakpoints(path, bps, None))).await;
for ((path, _), result) in files.iter().zip(sent) {
if let Err(e) = result {
tracing::debug!("restoring breakpoints of {} failed: {e}", path.display());
}
}
if pending.functions {
let functions = self.function_breakpoints.lock().clone();
if let Err(e) = session.send_function_breakpoints(&functions).await {
tracing::debug!("restoring function breakpoints failed: {e}");
}
}
if pending.data {
let data = self.data_breakpoints.lock().clone();
if let Err(e) = session.send_data_breakpoints(&data).await {
tracing::debug!("restoring data breakpoints failed: {e}");
}
}
}
/// Force Step Over: `next` with every breakpoint suspended until the step ends.
pub async fn force_step_over(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<()> {
let session = self.session(s)?;
self.suspend_breakpoints(&session, None).await?;
self.step(s, thread_id, StepKind::Over).await
}
/// Step into a specific call of the current line (Smart Step Into).
pub async fn step_in_target(&self, s: DebugSessionId, thread_id: i64, target_id: i64) -> IdeResult<()> {
let session = self.session(s)?;
session
.conn
.request_timeout("stepIn", json!({ "threadId": thread_id, "targetId": target_id }), REQUEST_TIMEOUT)
.await?;
session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: true });
Ok(())
}
/// Calls on the current line of `frame_id` that can be stepped into.
pub async fn step_in_targets(&self, s: DebugSessionId, frame_id: i64) -> IdeResult<Vec<StepInTarget>> {
let session = self.session(s)?;
if !session.supports("supportsStepInTargetsRequest") {
return Err(IdeError::unavailable(format!(
"debug adapter `{}` does not support Smart Step Into",
session.adapter
)));
}
let body =
session.conn.request_timeout("stepInTargets", json!({ "frameId": frame_id }), REQUEST_TIMEOUT).await?;
Ok(body
.get("targets")
.and_then(Value::as_array)
.map(|t| t.iter().map(step_in_target_from).collect())
.unwrap_or_default())
}
/// Reset Frame (IDEA's Drop Frame): `restartFrame`, so execution re-enters the frame.
pub async fn restart_frame(&self, s: DebugSessionId, frame_id: i64) -> IdeResult<()> {
let session = self.session(s)?;
if !session.supports("supportsRestartFrame") {
return Err(IdeError::unavailable(format!("debug adapter `{}` cannot reset frames", session.adapter)));
}
session.conn.request_timeout("restartFrame", json!({ "frameId": frame_id }), REQUEST_TIMEOUT).await?;
Ok(())
}
pub fn capabilities(&self, s: DebugSessionId) -> IdeResult<DebugCapabilities> {
let session = self.session(s)?;
let mut caps = capabilities_from(&session.caps.lock());
caps.hot_code_replace = HOT_CODE_REPLACE_ADAPTERS.contains(&session.adapter.as_str());
Ok(caps)
}
/// HotSwap: reloads the classes compiled since the session started (or since the
/// last reload) into the debuggee (java-debug `redefineClasses`).
pub async fn redefine_classes(&self, s: DebugSessionId) -> IdeResult<HotSwapResult> {
let session = self.session(s)?;
if !HOT_CODE_REPLACE_ADAPTERS.contains(&session.adapter.as_str()) {
return Err(IdeError::unavailable(format!(
"debug adapter `{}` cannot reload changed classes",
session.adapter
)));
}
let body = session.conn.request_timeout("redefineClasses", json!({}), HOT_SWAP_TIMEOUT).await?;
Ok(hot_swap_result(&body))
}
/// Stores method breakpoints (kept without a session, sent on start) and sends them to
/// every configured session whose adapter supports them. States from the first one.
pub async fn set_function_breakpoints(&self, list: Vec<FunctionBreakpoint>) -> IdeResult<Vec<BreakpointState>> {
*self.function_breakpoints.lock() = list.clone();
let mut result = None;
for s in self.live_sessions() {
if !s.configured.load(Ordering::Acquire) || s.conn.is_closed() || !s.supports("supportsFunctionBreakpoints")
{
continue;
}
match s.send_function_breakpoints(&list).await {
Ok(states) => {
result.get_or_insert(states);
}
Err(e) => tracing::debug!("setFunctionBreakpoints on session {} failed: {e}", s.id.0),
}
}
Ok(result.unwrap_or_else(|| unverified(list.iter().map(|b| b.enabled))))
}
/// Stores field watchpoints and sends them to every configured session that supports them.
pub async fn set_data_breakpoints(&self, list: Vec<DataBreakpoint>) -> IdeResult<Vec<BreakpointState>> {
*self.data_breakpoints.lock() = list.clone();
let mut result = None;
for s in self.live_sessions() {
if !s.configured.load(Ordering::Acquire) || s.conn.is_closed() || !s.supports("supportsDataBreakpoints") {
continue;
}
match s.send_data_breakpoints(&list).await {
Ok(states) => {
result.get_or_insert(states);
}
Err(e) => tracing::debug!("setDataBreakpoints on session {} failed: {e}", s.id.0),
}
}
Ok(result.unwrap_or_else(|| unverified(list.iter().map(|b| b.enabled))))
}
/// Whether (and how) `name` in the variables container `container` can be watched.
pub async fn data_breakpoint_info(
&self,
s: DebugSessionId,
container: i64,
name: String,
frame_id: Option<i64>,
) -> IdeResult<DataBreakpointInfo> {
let session = self.session(s)?;
if !session.supports("supportsDataBreakpoints") {
return Err(IdeError::unavailable(format!(
"debug adapter `{}` does not support field watchpoints",
session.adapter
)));
}
let mut args = json!({ "name": name });
if container > 0 {
args["variablesReference"] = container.into();
}
if let Some(f) = frame_id {
args["frameId"] = f.into();
}
let body = session.conn.request_timeout("dataBreakpointInfo", args, REQUEST_TIMEOUT).await?;
Ok(DataBreakpointInfo {
data_id: body.get("dataId").and_then(Value::as_str).map(str::to_owned),
description: body.get("description").and_then(Value::as_str).unwrap_or_default().to_owned(),
access_types: body
.get("accessTypes")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect())
.unwrap_or_default(),
})
}
/// Stops every session `key` started: the window that opened the project is closing.
pub async fn stop_key(&self, key: &str) {
let ids: Vec<DebugSessionId> = self.sessions(Some(key)).into_iter().map(|s| s.id).collect();
for id in ids {
if let Err(e) = self.stop(id).await {
tracing::debug!("debug session {id:?} did not stop: {e}");
}
}
}
/// Stops every debug session started from `root` or below — closing a project stops the
/// programs it launched even when the session's owner key does not name the window doing
/// the closing (window groups, auxiliary windows).
pub async fn stop_under(&self, root: &Path) {
let ids: Vec<DebugSessionId> =
self.live_sessions().into_iter().filter(|s| s.spec.cwd.starts_with(root)).map(|s| s.id).collect();
tracing::info!(root = %root.display(), sessions = ids.len(), "closing project: stopping debug sessions");
for id in ids {
if let Err(e) = self.stop(id).await {
tracing::debug!("debug session {id:?} did not stop: {e}");
}
}
}
/// Stops a session and its child sessions. A second Stop while the first one waits kills
/// the program instead of waiting for it — even after the adapter dropped the session.
pub async fn stop(&self, s: DebugSessionId) -> IdeResult<()> {
{
let in_flight = self.stopping.lock();
if let Some(session) = in_flight.get(&s) {
session.stop_force.store(true, Ordering::Release);
return Ok(());
}
}
let session = self.session(s)?;
self.stopping.lock().insert(s, session.clone());
let result = self.stop_tree(session).await;
self.stopping.lock().remove(&s);
result
}
async fn stop_tree(&self, session: Arc<Session>) -> IdeResult<()> {
session.startup_cancel.cancel();
let children: Vec<Arc<Session>> =
self.live_sessions().into_iter().filter(|c| c.parent == Some(session.id)).collect();
for child in children {
child.startup_cancel.cancel();
self.shutdown(&child, true).await;
}
self.shutdown(&session, true).await;
Ok(())
}
}
#[cfg(test)]
mod tests;