diff --git a/Cargo.lock b/Cargo.lock index a3d6654e0..7d990498c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4271,11 +4271,15 @@ version = "0.1.41" dependencies = [ "futures", "ide-core", + "ide-jdwp", "ide-jsonrpc", + "ide-jvm-attach", + "ide-jvm-inspect", "ide-language", "libc", "memchr", "parking_lot", + "percent-encoding", "portable-pty", "serde", "serde_json", @@ -4400,6 +4404,7 @@ dependencies = [ "ide-http", "ide-index", "ide-jsonrpc", + "ide-jvm-attach", "ide-language", "ide-lsp", "ide-mcp", @@ -4482,6 +4487,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "ide-jdwp" +version = "0.1.41" +dependencies = [ + "ide-jvm-inspect", + "proptest", + "tempfile", + "thiserror 2.0.21", + "tokio", + "tracing", +] + [[package]] name = "ide-jsonrpc" version = "0.1.41" @@ -4499,6 +4516,26 @@ dependencies = [ "tracing", ] +[[package]] +name = "ide-jvm-attach" +version = "0.1.41" +dependencies = [ + "libc", + "tempfile", + "thiserror 2.0.21", +] + +[[package]] +name = "ide-jvm-inspect" +version = "0.1.41" +dependencies = [ + "futures", + "serde", + "serde_json", + "specta", + "thiserror 2.0.21", +] + [[package]] name = "ide-language" version = "0.1.41" @@ -4564,6 +4601,7 @@ dependencies = [ "globset", "ide-core", "ide-jsonrpc", + "ide-jvm-inspect", "ide-language", "ide-text", "ide-vfs", diff --git a/Cargo.toml b/Cargo.toml index c5638cc32..ea8550941 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,9 @@ ide-notebook = { path = "crates/ide-notebook" } ide-extension = { path = "crates/ide-extension" } ide-plugin = { path = "crates/ide-plugin" } ide-cli = { path = "crates/ide-cli" } +ide-jvm-attach = { path = "crates/ide-jvm-attach" } +ide-jvm-inspect = { path = "crates/ide-jvm-inspect" } +ide-jdwp = { path = "crates/ide-jdwp" } # serialization & IPC typing serde = { version = "1", features = ["derive"] } diff --git a/crates/ide-core/src/event.rs b/crates/ide-core/src/event.rs index 224cb6531..7868abb96 100644 --- a/crates/ide-core/src/event.rs +++ b/crates/ide-core/src/event.rs @@ -273,6 +273,27 @@ pub enum DebugEvent { change_type: String, message: String, }, + /// The session's capabilities changed after it started: fetch them again. + Capabilities, + JvmBackend { + state: JvmBackendState, + }, +} + +/// The backend that reads a debugged JVM's objects (instances, fields, referrers). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum JvmBackendState { + Ready, + /// Turned off for the rest of the session: the debug adapter lacks what it needs. + Unsupported { + detail: String, + }, + /// The Kotlin JDWP link only forwards now; inspecting stops. + Degraded { + detail: String, + }, + Closed, } /// Where backend crates publish [`IdeEvent`]s. Cheap to clone via `Arc`. @@ -294,3 +315,19 @@ pub struct NullSink; impl EventSink for NullSink { fn emit(&self, _: IdeEvent) {} } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn jvm_backend_events_json_shape() { + let degraded = DebugEvent::JvmBackend { state: JvmBackendState::Degraded { detail: "x".into() } }; + assert_eq!( + serde_json::to_value(°raded).unwrap(), + json!({"kind": "jvmBackend", "state": {"kind": "degraded", "detail": "x"}}) + ); + assert_eq!(serde_json::to_value(DebugEvent::Capabilities).unwrap(), json!({"kind": "capabilities"})); + } +} diff --git a/crates/ide-core/src/process.rs b/crates/ide-core/src/process.rs index daed2ca49..5f26bdeea 100644 --- a/crates/ide-core/src/process.rs +++ b/crates/ide-core/src/process.rs @@ -72,6 +72,75 @@ pub fn kill_tree_of(pid: u32) { let _ = pid; } +/// Whether a process with this id exists. PID reuse makes this conservative: a reused id keeps +/// whatever was tied to the old process alive, a live process is never reported dead. +pub fn process_alive(pid: u32) -> bool { + // `kill(0, 0)` would signal the caller's own process group. + if pid == 0 { + return false; + } + #[cfg(unix)] + { + // Above `i32::MAX` the id would wrap to a negative value, which `kill` reads as a group. + let Ok(pid) = libc::pid_t::try_from(pid) else { return false }; + // SAFETY: signal 0 delivers nothing; it only checks that the process exists and is reachable. + unsafe { libc::kill(pid, 0) == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) } + } + #[cfg(windows)] + { + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; + use windows_sys::Win32::Foundation::{ERROR_ACCESS_DENIED, GetLastError, STILL_ACTIVE}; + use windows_sys::Win32::System::Threading::{ + GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + + // SAFETY: plain call; a non-null result is a process handle owned by this function. + let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + if handle.is_null() { + // A process we may not open still exists. + // SAFETY: reads the thread's last error, set by the call above. + return unsafe { GetLastError() } == ERROR_ACCESS_DENIED; + } + // SAFETY: `handle` is valid and nobody else owns it. + let handle = unsafe { OwnedHandle::from_raw_handle(handle) }; + let mut code = 0u32; + // SAFETY: a live process handle opened with query access; one out-parameter. + let ok = unsafe { GetExitCodeProcess(handle.as_raw_handle(), &mut code) }; + // A handle that cannot be queried is not evidence that the process is gone. + ok == 0 || code == STILL_ACTIVE as u32 + } + #[cfg(not(any(unix, windows)))] + { + true + } +} + +/// The program and everything it started: an adapter may report the launcher shell, whose +/// child is the debugged program, so stopping only the reported pid leaves the program alive. +#[cfg(unix)] +pub fn process_tree(pid: i32) -> Vec { + use std::collections::HashMap; + let Ok(out) = std::process::Command::new("ps").args(["-A", "-o", "pid=,ppid="]).output() else { + return vec![pid]; + }; + let mut children: HashMap> = HashMap::new(); + for line in String::from_utf8_lossy(&out.stdout).lines() { + let mut it = line.split_whitespace(); + let (Some(p), Some(parent)) = (it.next(), it.next()) else { continue }; + let (Ok(p), Ok(parent)) = (p.parse::(), parent.parse::()) else { continue }; + children.entry(parent).or_default().push(p); + } + let mut tree = vec![pid]; + let mut at = 0; + while at < tree.len() { + if let Some(kids) = children.get(&tree[at]) { + tree.extend(kids.iter().copied()); + } + at += 1; + } + tree +} + /// [`kill_tree`] that first asks the group to exit: `SIGTERM`, then `grace`, then `SIGKILL`. /// Git removes its `index.lock` on `SIGTERM`, where `SIGKILL` leaves it behind and the next /// git command fails. Windows has no such signal to send from outside the child's console, @@ -730,6 +799,30 @@ fn append_path_entries(path: &OsStr, extra: &OsStr) -> OsString { mod tests { use super::*; + #[test] + fn process_alive_tells_live_from_dead_pids() { + assert!(process_alive(std::process::id())); + assert!(!process_alive(0)); + let mut cmd = if cfg!(windows) { + let mut cmd = Command::new("cmd"); + cmd.args(["/C", "exit 0"]); + cmd + } else { + Command::new("true") + }; + let mut child = cmd.spawn().unwrap(); + let pid = child.id(); + child.wait().unwrap(); + drop(child); + assert!(!process_alive(pid)); + } + + #[cfg(unix)] + #[test] + fn process_alive_rejects_ids_that_are_not_a_single_process() { + assert!(!process_alive(u32::MAX)); + } + #[cfg(unix)] #[test] fn kill_tree_of_reaches_what_the_child_started() { @@ -1050,6 +1143,25 @@ mod tests { } assert_eq!(find_executable("nope", &search), None); } + + #[cfg(unix)] + #[test] + fn process_tree_lists_the_child_of_a_shell() { + let mut cmd = Command::new("sh"); + own_group(&mut cmd).args(["-c", "sleep 5 & wait"]); + let mut child = cmd.spawn().unwrap(); + let sh_pid = child.id() as i32; + // The shell forks `sleep` after it starts, so the tree grows a moment later. + let deadline = std::time::Instant::now() + Duration::from_secs(2); + let mut tree = process_tree(sh_pid); + while tree.len() < 2 && std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + tree = process_tree(sh_pid); + } + kill_tree(&mut child); + assert_eq!(tree[0], sh_pid, "the pid itself comes first"); + assert!(tree.len() >= 2 && tree[1..].iter().all(|p| *p != sh_pid), "the shell's child follows: {tree:?}"); + } } #[cfg(all(test, unix))] diff --git a/crates/ide-dap/Cargo.toml b/crates/ide-dap/Cargo.toml index 31fc65f4f..dadf7000c 100644 --- a/crates/ide-dap/Cargo.toml +++ b/crates/ide-dap/Cargo.toml @@ -11,12 +11,16 @@ tempfile.workspace = true ide-core.workspace = true ide-language.workspace = true ide-jsonrpc.workspace = true +ide-jvm-attach.workspace = true +ide-jvm-inspect.workspace = true +ide-jdwp.workspace = true serde.workspace = true serde_json.workspace = true specta.workspace = true tokio = { workspace = true, features = ["net"] } parking_lot.workspace = true memchr = "2" +percent-encoding = "2" portable-pty.workspace = true tracing.workspace = true diff --git a/crates/ide-dap/src/jdwp.rs b/crates/ide-dap/src/jdwp.rs index 41deab2c2..271c7f930 100644 --- a/crates/ide-dap/src/jdwp.rs +++ b/crates/ide-dap/src/jdwp.rs @@ -12,6 +12,35 @@ use tokio::net::{TcpListener, TcpStream}; /// How long the relay waits for java-debug to attach once the JVM is connected. const ATTACH_TIMEOUT: Duration = Duration::from_secs(60); +/// Loopback ports relaying a connected JVM, while their relay runs. +static RELAY_PORTS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +fn relay_ports() -> std::sync::MutexGuard<'static, Vec> { + RELAY_PORTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// Whether `port` is a relay of [`listen_for_jvm`]: a session attaching to it debugs a JVM that +/// connected to the IDE, so the JVM has no JDWP listener the memory view could find it by. +pub(crate) fn is_listen_relay(port: u16) -> bool { + relay_ports().contains(&port) +} + +/// Registers a relay port for as long as it lives. +struct RelayPort(u16); + +impl RelayPort { + fn new(port: u16) -> Self { + relay_ports().push(port); + Self(port) + } +} + +impl Drop for RelayPort { + fn drop(&mut self) { + relay_ports().retain(|&p| p != self.0); + } +} + /// The address to listen on for `host`: the interface it names, or every interface when /// it is not one of this machine's (a DNS name or a NAT address the JVM connects to). async fn bind(host: &str, port: u16) -> IdeResult { @@ -40,11 +69,11 @@ pub async fn listen_for_jvm(host: &str, port: u16, cancel: CancellationToken) -> let _ = jvm.set_nodelay(true); let relay = TcpListener::bind(("127.0.0.1", 0)).await?; let local = relay.local_addr()?.port(); - tokio::spawn(relay_one(relay, jvm)); + tokio::spawn(relay_one(relay, jvm, RelayPort::new(local))); Ok(local) } -async fn relay_one(relay: TcpListener, mut jvm: TcpStream) { +async fn relay_one(relay: TcpListener, mut jvm: TcpStream, _registered: RelayPort) { let debugger = match tokio::time::timeout(ATTACH_TIMEOUT, relay.accept()).await { Ok(Ok((s, _))) => s, _ => return, @@ -58,22 +87,64 @@ async fn relay_one(relay: TcpListener, mut jvm: TcpStream) { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use tokio::io::{AsyncReadExt, AsyncWriteExt}; - #[tokio::test] - async fn relays_the_jdwp_handshake_both_ways() { - let port = TcpListener::bind(("127.0.0.1", 0)).await.unwrap().local_addr().unwrap().port(); - let waiting = tokio::spawn(listen_for_jvm("127.0.0.1", port, CancellationToken::new())); - // The remote JVM (`server=n`) connects to the IDE. - let mut jvm = loop { - if let Ok(s) = TcpStream::connect(("127.0.0.1", port)).await { - break s; + /// A remote JVM (`server=n`) connected to [`listen_for_jvm`], and the relay port. A port + /// picked by binding to 0 can be taken by a parallel test before `listen_for_jvm` binds it, + /// so a failed listen is retried on a fresh one. + pub(crate) async fn connected_jvm() -> (TcpStream, u16) { + for _ in 0..20 { + let port = TcpListener::bind(("127.0.0.1", 0)).await.unwrap().local_addr().unwrap().port(); + if let Some(connected) = jvm_on(port).await { + return connected; + } + } + panic!("listen_for_jvm never accepted a JVM"); + } + + /// One attempt of [`connected_jvm`]; `None` when the port turned out to be taken. + async fn jvm_on(port: u16) -> Option<(TcpStream, u16)> { + let mut waiting = tokio::spawn(listen_for_jvm("127.0.0.1", port, CancellationToken::new())); + let mut jvm = None; + for _ in 0..100 { + // A listen that failed to bind leaves nothing to connect to. + if waiting.is_finished() { + break; + } + // macOS does not refuse a connect to a port a socket holds without listening: the + // SYN goes unanswered until the connect times out. + let connect = TcpStream::connect(("127.0.0.1", port)); + if let Ok(Ok(s)) = tokio::time::timeout(Duration::from_secs(1), connect).await { + jvm = Some(s); + break; } tokio::time::sleep(Duration::from_millis(20)).await; - }; - let local = waiting.await.unwrap().unwrap(); + } + // A connection to another test's listener never reaches `listen_for_jvm`. + let relay = tokio::time::timeout(Duration::from_secs(2), &mut waiting).await; + if let (Some(jvm), Ok(Ok(Ok(relay)))) = (jvm, relay) { + return Some((jvm, relay)); + } + waiting.abort(); + None + } + + #[tokio::test] + async fn a_port_another_socket_holds_is_given_up() { + // Bound but not listening, as a parallel test's outgoing connection holds its local port. + let held = tokio::net::TcpSocket::new_v4().unwrap(); + held.bind(([127, 0, 0, 1], 0).into()).unwrap(); + let port = held.local_addr().unwrap().port(); + let attempt = tokio::time::timeout(Duration::from_secs(5), jvm_on(port)).await; + assert!(matches!(attempt, Ok(None)), "an attempt on a taken port gives up instead of waiting forever"); + } + + #[tokio::test] + async fn relays_the_jdwp_handshake_both_ways() { + // The remote JVM (`server=n`) connects to the IDE. + let (mut jvm, local) = connected_jvm().await; // java-debug attaches to the relay and starts the handshake. let mut debugger = TcpStream::connect(("127.0.0.1", local)).await.unwrap(); debugger.write_all(b"JDWP-Handshake").await.unwrap(); @@ -85,6 +156,22 @@ mod tests { assert_eq!(&buf, b"JDWP-Handshake"); } + #[tokio::test] + async fn relay_port_is_known_while_the_relay_runs() { + let (jvm, local) = connected_jvm().await; + assert!(is_listen_relay(local)); + + let debugger = TcpStream::connect(("127.0.0.1", local)).await.unwrap(); + drop((jvm, debugger)); + for _ in 0..200 { + if !is_listen_relay(local) { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("relay port {local} still registered after the relay ended"); + } + #[tokio::test] async fn waiting_is_cancellable() { let cancel = CancellationToken::new(); diff --git a/crates/ide-dap/src/jvm_memory/fallback.rs b/crates/ide-dap/src/jvm_memory/fallback.rs new file mode 100644 index 000000000..990ea2a7a --- /dev/null +++ b/crates/ide-dap/src/jvm_memory/fallback.rs @@ -0,0 +1,92 @@ +//! The class table counted by the debugger (JDWP `InstanceCounts`) where no histogram can be +//! taken: JDK 8-16 while suspended, Windows, a remote JVM. Only while suspended, and slow on a +//! large heap (seconds on JDK 17+). + +use super::inspect::Call; +use super::snapshot::diff_rows; +use super::{ClassCounts, JvmMemoryResult, JvmMemorySnapshot, JvmMemorySource, JvmMemoryState, JvmMemoryUnavailable}; +use crate::session::Session; +use ide_core::IdeResult; +use ide_jvm_attach::HistogramRow; +use ide_jvm_inspect::ClassCount; + +/// Reasons the debugger may be able to work around; the others (the program exited, the JVM +/// did not answer) would fail for it too. +pub(super) fn falls_back(reason: &JvmMemoryUnavailable) -> bool { + use JvmMemoryUnavailable as U; + matches!( + reason, + U::UnsafeWhileSuspended { .. } + | U::UnsupportedPlatform + | U::AttachedJvmNotFound + | U::ListenerNotRunning + | U::ListenModeNotSupported + | U::NoJvmProcess + ) +} + +/// `histogram` unless it is unavailable for a reason `session`'s inspector can work around. +pub(super) async fn or_debugger_counts( + state: &mut JvmMemoryState, + histogram: Result, + session: Option<&Session>, +) -> IdeResult { + let reason = match histogram { + Ok(snapshot) => return Ok(JvmMemoryResult::Ready { snapshot }), + Err(reason) => reason, + }; + let unavailable = |can_count_when_paused| Ok(JvmMemoryResult::Unavailable { reason, can_count_when_paused }); + let Some(session) = session.filter(|_| falls_back(&reason)) else { + return unavailable(false); + }; + let call = match Call::begin(session, None).await { + Ok(call) => call, + Err(super::JvmInspectUnavailable::NotSuspended) => return unavailable(true), + Err(_) => return unavailable(false), + }; + let started = std::time::Instant::now(); + let result = call.run(call.inspector.class_counts(&call.target)).await; + match call.finish(result) { + Ok(counts) => { + let elapsed_ms = u32::try_from(started.elapsed().as_millis()).unwrap_or(u32::MAX); + let previous = state.baseline(JvmMemorySource::Debugger); + let (snapshot, counts) = debugger_snapshot(counts, previous.as_deref(), elapsed_ms); + state.remember(JvmMemorySource::Debugger, counts); + Ok(JvmMemoryResult::Ready { snapshot }) + } + Err(e) => { + tracing::debug!("debugger class counts: {e}"); + let running = !session.suspended.get(); + unavailable(running && session.jvm_inspector.lock().is_some()) + } + } +} + +/// Classes without instances are left out; most instances first. +pub(super) fn debugger_snapshot( + counts: Vec, + previous: Option<&ClassCounts>, + elapsed_ms: u32, +) -> (JvmMemorySnapshot, ClassCounts) { + let rows = counts + .into_iter() + .filter(|c| c.instances > 0) + .map(|c| HistogramRow { name: c.name, module: None, instances: c.instances, bytes: 0 }) + .collect(); + let (mut rows, counts) = diff_rows(rows, previous); + for row in &mut rows { + row.delta_bytes = None; + } + rows.sort_by(|a, b| b.instances.cmp(&a.instances).then_with(|| a.name.cmp(&b.name))); + let snapshot = JvmMemorySnapshot { + total_instances: rows.iter().map(|r| r.instances).sum(), + total_bytes: 0, + rows, + java_version: String::new(), + taken_while_suspended: true, + has_baseline: previous.is_some(), + elapsed_ms, + source: JvmMemorySource::Debugger, + }; + (snapshot, counts) +} diff --git a/crates/ide-dap/src/jvm_memory/inspect.rs b/crates/ide-dap/src/jvm_memory/inspect.rs new file mode 100644 index 000000000..ed9ab9af8 --- /dev/null +++ b/crates/ide-dap/src/jvm_memory/inspect.rs @@ -0,0 +1,251 @@ +//! A suspended JVM's instances, fields and referrers, read through the session's +//! [`JvmInspector`]. An object is only valid within the stop it was read in. + +use super::launch_pid; +use crate::session::Session; +use ide_core::event::{DebugEvent, JvmBackendState}; +use ide_core::{IdeError, IdeResult}; +use ide_jvm_inspect::{ + DEFAULT_REFERRERS, InspectError, InspectFuture, JvmField, JvmInspector, JvmReferrer, JvmValue, MAX_INSTANCES, + MAX_REFERRERS, ObjectId, VmTarget, +}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::sync::Arc; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct JvmObjectRef { + /// The `generation` of the [`JvmInstancesResult`] the object came from. + pub generation: u64, + pub id: ObjectId, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum JvmInspectUnavailable { + /// The session has no inspector: not a JVM session, not ready yet, or turned off. + NoBackend, + /// The debug adapter lacks what the inspector needs; turned off for the session. + Unsupported { + detail: String, + }, + NotSuspended, + /// The object is from an earlier stop, or the program stopped or resumed meanwhile. + Stale, + Collected, + ClassNotLoaded, + VmNotFound, + /// Transient: ask again. + TimedOut, + BackendClosed, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(tag = "status", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum JvmInstancesResult { + Ready { + /// Goes into the [`JvmObjectRef`] of each instance. + generation: u64, + instances: Vec, + /// The limit was reached; there may be more. + truncated: bool, + }, + Unavailable { + reason: JvmInspectUnavailable, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(tag = "status", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum JvmFieldsResult { + Ready { fields: Vec }, + Unavailable { reason: JvmInspectUnavailable }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(tag = "status", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum JvmReferrersResult { + Ready { referrers: Vec, truncated: bool }, + Unavailable { reason: JvmInspectUnavailable }, +} + +/// The session's JVM for the inspector: java-debug's VM is found by the launched program's pid +/// or by the port of an attach (the thread lists of two JVMs can be identical). +pub(crate) fn vm_target(s: &Session) -> VmTarget { + let (pid, attach) = if s.request == "launch" { + (u32::try_from(launch_pid(s)).ok().filter(|&p| p > 0), None) + } else { + (None, s.attach_target.get()) + }; + VmTarget { + pid, + attach_host: attach.map(|(host, _)| host.clone()).filter(|h| !h.is_empty()), + attach_port: attach.map(|&(_, port)| port), + generation: s.suspended.ticket(), + } +} + +pub(crate) fn set_inspector(s: &Session, inspector: Arc) { + *s.jvm_inspector.lock() = Some(inspector); + s.emit(DebugEvent::Capabilities); +} + +pub(crate) async fn instances(s: &Session, class_name: &str, limit: u32) -> IdeResult { + let limit = limit.clamp(1, MAX_INSTANCES); + let call = match Call::begin(s, None).await { + Ok(call) => call, + Err(reason) => return Ok(JvmInstancesResult::Unavailable { reason }), + }; + let generation = call.target.generation; + let result = call.run(call.inspector.instances(&call.target, class_name, limit)).await; + Ok(match settle(call.finish(result))? { + Ok(instances) => { + JvmInstancesResult::Ready { generation, truncated: instances.len() >= limit as usize, instances } + } + Err(reason) => JvmInstancesResult::Unavailable { reason }, + }) +} + +pub(crate) async fn fields(s: &Session, object: JvmObjectRef) -> IdeResult { + let call = match Call::begin(s, Some(object.generation)).await { + Ok(call) => call, + Err(reason) => return Ok(JvmFieldsResult::Unavailable { reason }), + }; + let result = call.run(call.inspector.fields(&call.target, object.id)).await; + Ok(match settle(call.finish(result))? { + Ok(fields) => JvmFieldsResult::Ready { fields }, + Err(reason) => JvmFieldsResult::Unavailable { reason }, + }) +} + +pub(crate) async fn referrers(s: &Session, object: JvmObjectRef, limit: u32) -> IdeResult { + let limit = if limit == 0 { DEFAULT_REFERRERS } else { limit.min(MAX_REFERRERS) }; + let call = match Call::begin(s, Some(object.generation)).await { + Ok(call) => call, + Err(reason) => return Ok(JvmReferrersResult::Unavailable { reason }), + }; + let result = call.run(call.inspector.referrers(&call.target, object.id, limit)).await; + Ok(match settle(call.finish(result))? { + Ok(referrers) => JvmReferrersResult::Ready { truncated: referrers.len() >= limit as usize, referrers }, + Err(reason) => JvmReferrersResult::Unavailable { reason }, + }) +} + +/// One inspector request. Holds the session's inspect lock and a clone of its inspector, which +/// [`Call::run`] lets go of when the session ends. +pub(super) struct Call<'s> { + session: &'s Session, + pub(super) inspector: Arc, + pub(super) target: VmTarget, + _turn: tokio::sync::MutexGuard<'s, ()>, +} + +impl<'s> Call<'s> { + /// `object_generation`: the stop the request's object was read in. + pub(super) async fn begin( + s: &'s Session, + object_generation: Option, + ) -> Result, JvmInspectUnavailable> { + // Checked under the lock: a request queued behind another must see the state it runs in. + let turn = s.inspect_lock.lock().await; + let Some(inspector) = s.jvm_inspector.lock().clone() else { + return Err(JvmInspectUnavailable::NoBackend); + }; + if !s.suspended.get() { + return Err(JvmInspectUnavailable::NotSuspended); + } + let target = vm_target(s); + if object_generation.is_some_and(|g| g != target.generation) { + return Err(JvmInspectUnavailable::Stale); + } + Ok(Call { session: s, inspector, target, _turn: turn }) + } + + pub(super) async fn run(&self, request: InspectFuture<'_, T>) -> Result { + tokio::select! { + result = request => result, + () = self.session.ended.cancelled() => Err(InspectError::Closed), + } + } + + /// The result unless the program stopped or resumed while it was read: a request of several + /// commands can straddle a stop. `Unsupported` turns the backend off. + pub(super) fn finish(self, result: Result) -> Result { + let s = self.session; + match result { + Err(InspectError::Unsupported(detail)) => { + turn_off(s, &self.inspector, &detail); + Err(InspectError::Unsupported(detail)) + } + _ if !s.suspended.get() || s.suspended.ticket() != self.target.generation => Err(InspectError::Stale), + result => result, + } + } +} + +/// An adapter that lacks what the inspector needs will not gain it during the session. +fn turn_off(s: &Session, inspector: &Arc, detail: &str) { + let removed = { + let mut slot = s.jvm_inspector.lock(); + // Not one installed since this request began. + let current = slot.as_ref().is_some_and(|i| std::ptr::addr_eq(Arc::as_ptr(i), Arc::as_ptr(inspector))); + current && slot.take().is_some() + }; + if removed { + s.emit(DebugEvent::JvmBackend { state: JvmBackendState::Unsupported { detail: detail.to_owned() } }); + s.emit(DebugEvent::Capabilities); + } +} + +/// A failure the inspector could not classify is an error, not a reason shown to the user. +fn settle(result: Result) -> IdeResult> { + use JvmInspectUnavailable as U; + let error = match result { + Ok(value) => return Ok(Ok(value)), + Err(error) => error, + }; + Ok(Err(match error { + InspectError::NotSuspended => U::NotSuspended, + InspectError::Stale => U::Stale, + InspectError::Collected => U::Collected, + InspectError::ClassNotLoaded => U::ClassNotLoaded, + InspectError::VmNotFound => U::VmNotFound, + InspectError::Unsupported(detail) => U::Unsupported { detail }, + // Also the Kotlin link's `Busy`: the backend stays. + InspectError::TimedOut => U::TimedOut, + InspectError::Closed => U::BackendClosed, + InspectError::Failed(message) => return Err(IdeError::internal(format!("JVM inspector: {message}"))), + })) +} + +/// Reports a Kotlin link that stops inspecting or closes. Holds neither the link nor the +/// session: it ends once the session drops the link, and never drops it itself (after +/// `VirtualMachine.Dispose` the link is closed but still forwarding the reply). +pub(crate) fn watch_link(s: &Arc, link: &ide_jdwp::JdwpLink) { + let mut states = link.subscribe(); + let session = Arc::downgrade(s); + tokio::spawn(async move { + let mut reported = None; + loop { + let state = match &*states.borrow_and_update() { + ide_jdwp::LinkState::Degraded(detail) => Some(JvmBackendState::Degraded { detail: detail.clone() }), + ide_jdwp::LinkState::Closed => Some(JvmBackendState::Closed), + _ => None, + }; + if let Some(state) = state + && reported.as_ref() != Some(&state) + { + let Some(s) = session.upgrade() else { return }; + if s.ended.is_cancelled() || s.terminated.load(std::sync::atomic::Ordering::Acquire) { + return; + } + s.emit(DebugEvent::JvmBackend { state: state.clone() }); + reported = Some(state); + } + if states.changed().await.is_err() { + return; + } + } + }); +} diff --git a/crates/ide-dap/src/jvm_memory/mod.rs b/crates/ide-dap/src/jvm_memory/mod.rs new file mode 100644 index 000000000..826fc2889 --- /dev/null +++ b/crates/ide-dap/src/jvm_memory/mod.rs @@ -0,0 +1,336 @@ +//! Class-table snapshots of a debugged JVM, read over the HotSpot attach listener or, where +//! that is unavailable, counted by the debugger; and the JVM's objects through the debugger. + +mod fallback; +mod inspect; +mod scan; +mod snapshot; +#[cfg(test)] +pub(crate) mod test_support; +#[cfg(test)] +mod tests; + +use crate::session::Session; +use crate::session::Suspension; +use ide_core::IdeResult; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::time::Duration; + +pub use inspect::{JvmFieldsResult, JvmInspectUnavailable, JvmInstancesResult, JvmObjectRef, JvmReferrersResult}; +pub(crate) use inspect::{fields, instances, referrers, set_inspector, watch_link}; +#[cfg(all(test, unix))] +pub(crate) use scan::watch_for_attached_jvm; +pub(crate) use scan::{JvmFinder, attach_target, find_attached_jvm, find_local_jvm, local_jdwp_port}; +use snapshot::{AttachProbe, JdkProbe, take_snapshot}; + +pub const JVM_MEMORY_ADAPTERS: &[&str] = &["java-debug", crate::kotlin::ADAPTER]; +pub const HISTOGRAM_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct JvmClassRow { + pub name: String, + pub module: Option, + pub instances: u64, + pub bytes: u64, + /// Change since the previous snapshot; `None` on the first one and for ambiguous rows. + pub delta_instances: Option, + pub delta_bytes: Option, + /// Several classes share this name (different class loaders), so it has no delta. + pub ambiguous: bool, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct JvmMemorySnapshot { + /// Largest `bytes` first. + pub rows: Vec, + pub total_instances: u64, + pub total_bytes: u64, + /// Empty when unknown. + pub java_version: String, + pub taken_while_suspended: bool, + /// A previous snapshot from the same source exists; deltas are against it. + pub has_baseline: bool, + /// Wall time of the histogram or count request. + pub elapsed_ms: u32, + pub source: JvmMemorySource, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum JvmMemorySource { + /// `GC.class_histogram -all`: sizes, and objects not yet collected. + Histogram, + /// JDWP `InstanceCounts`: reachable objects only, no sizes (`bytes` is 0). + Debugger, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum JvmMemoryUnavailable { + /// An attach session whose JVM is remote, or local but not found by its JDWP port. + AttachedJvmNotFound, + /// An attach through the IDE's listen-mode relay: the JVM connected to the IDE, so it has + /// no JDWP listener to be found by. + ListenModeNotSupported, + UnsupportedPlatform, + NoJvmProcess, + ListenerNotRunning, + /// `java_major` is 0 when the version is unknown. + UnsafeWhileSuspended { + java_major: u32, + }, + ProgramExited, + TimedOut, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(tag = "status", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum JvmMemoryResult { + Ready { + snapshot: JvmMemorySnapshot, + }, + Unavailable { + reason: JvmMemoryUnavailable, + /// The debugger could count the classes if the program were paused. + can_count_when_paused: bool, + }, +} + +/// Instances and bytes per class name, for the next snapshot's deltas; `None` for a name +/// several classes shared, so it gets no delta next time either. +type ClassCounts = HashMap>; + +/// Per-session baseline; its mutex also keeps one class-table request per session at a time. +#[derive(Default)] +pub struct JvmMemoryState { + /// Shared with the blocking task, so a task that panics leaves it in place. + previous: Option<(JvmMemorySource, Arc)>, +} + +impl JvmMemoryState { + /// The previous snapshot's counts if it came from `source`: counts from the other source + /// are not comparable (only reachable objects versus all of them). + pub(crate) fn baseline(&self, source: JvmMemorySource) -> Option> { + self.previous.as_ref().filter(|(s, _)| *s == source).map(|(_, counts)| counts.clone()) + } + + pub(crate) fn remember(&mut self, source: JvmMemorySource, counts: ClassCounts) { + self.previous = Some((source, Arc::new(counts))); + } +} + +pub(crate) async fn session_classes(session: Arc) -> IdeResult { + until_located(&session).await; + let launched = histogram_pid(&session); + // Detached, so a caller that gives up cannot release the lock while the JVM is still + // answering and let a second histogram request run next to it. + tokio::spawn(async move { + let target = session_target(&session, launched); + let probe = |suspended| -> Arc { Arc::new(JdkProbe { suspended }) }; + snapshot(&session.jvm_memory, &session.suspended, probe, target, Some(&session)).await + }) + .await + .map_err(|e| ide_core::IdeError::internal(format!("JVM memory snapshot failed: {e}")))? +} + +/// Bounds the wait of [`until_located`]; `configure` has its own timeouts, this only guards them. +const LOCATE_WAIT: Duration = Duration::from_secs(30); + +/// Until `configure` has recorded where `session`'s JVM is. The Memory view asks as soon as the +/// session appears; looked up earlier, an attached JVM is reported as missing. +async fn until_located(session: &Session) { + let mut located = session.jvm_located.subscribe(); + tokio::select! { + _ = located.wait_for(|v| *v) => {} + () = session.ended.cancelled() => {} + () = tokio::time::sleep(LOCATE_WAIT) => {} + } +} + +/// `launched`: [`histogram_pid`] of `session`. +fn session_target(session: &Session, launched: Result, JvmMemoryUnavailable>) -> JvmTarget<'_> { + match launched { + Ok(Some(pid)) => JvmTarget::Launched(pid), + Ok(None) => JvmTarget::Attached { pid: &session.jvm_pid, jdwp_port: session.jdwp_port.get().copied() }, + Err(reason) => JvmTarget::Unavailable(reason), + } +} + +/// The pid of a launch session's program (`None` for an attach session), or why no histogram +/// can be taken. +fn histogram_pid(session: &Session) -> Result, JvmMemoryUnavailable> { + if !JVM_MEMORY_ADAPTERS.contains(&session.adapter.as_str()) { + return Err(JvmMemoryUnavailable::NoJvmProcess); + } + if !cfg!(unix) { + return Err(JvmMemoryUnavailable::UnsupportedPlatform); + } + if session.request == "launch" { + return match i32::try_from(launch_pid(session)).ok().filter(|&p| p > 0) { + Some(pid) => Ok(Some(pid)), + None => Err(JvmMemoryUnavailable::NoJvmProcess), + }; + } + if session.jvm_pid.load(Ordering::Acquire) <= 0 { + if session.terminated.load(Ordering::Acquire) { + return Err(JvmMemoryUnavailable::ProgramExited); + } + if session.listen_relay.load(Ordering::Acquire) { + return Err(JvmMemoryUnavailable::ListenModeNotSupported); + } + } + Ok(None) +} + +/// The JVM a launch session started: the one the IDE started for an app-launched (Kotlin) +/// session, else the program the adapter reported; 0 when neither is known. +pub(crate) fn launch_pid(session: &Session) -> i64 { + match session.jvm_pid.load(Ordering::Acquire) { + p if p > 0 => p, + _ => session.debuggee_pid.load(Ordering::Acquire), + } +} + +#[derive(Clone, Copy)] +pub(crate) enum JvmTarget<'a> { + /// The program a launch session started: its pid and everything it started. + Launched(i32), + /// The JVM of an attach session. While `pid` is 0 it is looked up by `jdwp_port`, which is + /// `None` for a remote JVM. + Attached { pid: &'a AtomicI64, jdwp_port: Option }, + /// Known without asking any JVM: no histogram can be taken, for this reason. + Unavailable(JvmMemoryUnavailable), +} + +enum Lookup { + Tree(i32), + Known(i32), + Scan(u16), +} + +/// `probe` is built from the suspension read once the lock is held: a request queued behind +/// another must not decide on a state from before the program hit a breakpoint. Where no +/// histogram can be taken, `debugger` (the session) may count the classes instead. +pub(crate) async fn snapshot( + state: &tokio::sync::Mutex, + suspended: &Suspension, + probe: impl FnOnce(bool) -> Arc, + target: JvmTarget<'_>, + debugger: Option<&Session>, +) -> IdeResult { + let mut state = state.lock().await; + let outcome = histogram(&mut state, suspended, probe, target).await?; + fallback::or_debugger_counts(&mut state, outcome, debugger).await +} + +async fn histogram( + state: &mut JvmMemoryState, + suspended: &Suspension, + probe: impl FnOnce(bool) -> Arc, + target: JvmTarget<'_>, +) -> IdeResult> { + let suspended = suspended.get(); + let lookup = match lookup(target) { + Ok(lookup) => lookup, + Err(reason) => return Ok(Err(reason)), + }; + let probe = probe(suspended); + let previous = state.baseline(JvmMemorySource::Histogram); + let (found, outcome) = tokio::task::spawn_blocking(move || match resolve_jvm(lookup, &*probe, suspended) { + Ok((tree, found)) => (found, take_snapshot(&*probe, &tree, suspended, previous.as_deref())), + Err(reason) => (None, Ok(Err(reason))), + }) + .await + .map_err(|e| ide_core::IdeError::internal(format!("JVM memory snapshot failed: {e}")))?; + keep_found(target, found); + Ok(outcome?.map(|(snapshot, counts)| { + state.remember(JvmMemorySource::Histogram, counts); + snapshot + })) +} + +/// Where to look for `target`'s JVM, or why there is none to ask. +fn lookup(target: JvmTarget<'_>) -> Result { + match target { + JvmTarget::Launched(pid) => Ok(Lookup::Tree(pid)), + JvmTarget::Attached { pid, jdwp_port } => match i32::try_from(pid.load(Ordering::Acquire)) { + Ok(p) if p > 0 => Ok(Lookup::Known(p)), + _ => jdwp_port.map(Lookup::Scan).ok_or(JvmMemoryUnavailable::AttachedJvmNotFound), + }, + JvmTarget::Unavailable(reason) => Err(reason), + } +} + +/// Blocking. The pids to pick the JVM from, and the pid a scan found (for [`keep_found`]). +fn resolve_jvm( + lookup: Lookup, + probe: &dyn AttachProbe, + suspended: bool, +) -> Result<(Vec, Option), JvmMemoryUnavailable> { + match lookup { + Lookup::Tree(pid) => Ok((pid_tree(pid), None)), + Lookup::Known(pid) => Ok((vec![pid], None)), + Lookup::Scan(port) => scan_for_jvm(probe, port, suspended).map(|pid| (vec![pid], Some(pid))), + } +} + +fn keep_found(target: JvmTarget<'_>, found: Option) { + if let (Some(found), JvmTarget::Attached { pid, .. }) = (found, target) { + // The attach-time scan may have found it meanwhile; either answer is the same JVM. + let _ = pid.compare_exchange(0, i64::from(found), Ordering::AcqRel, Ordering::Acquire); + } +} + +/// A suspended JDK below 17 hangs on an attach request, so while suspended only a JVM known to +/// be 17 or later is asked anything. Pass a version read without attaching (the `release` file). +fn safe_while_suspended(version: Option<&ide_jvm_attach::JavaVersion>) -> bool { + version.is_some_and(|v| ide_jvm_attach::histogram_safe_while_suspended(v.major)) +} + +/// The JVM listening on JDWP `port`. While suspended the scan reaches the session's own JVM +/// too, so JVMs unsafe to ask are skipped, and when one of them may be the session's the +/// answer is a refusal rather than "not found". +fn scan_for_jvm(probe: &dyn AttachProbe, port: u16, suspended: bool) -> Result { + // Majors of the JVMs left out; 0 for an unknown version. + let skipped = std::cell::RefCell::new(std::collections::BTreeSet::new()); + let may_query = |pid: u32| { + if !suspended { + return true; + } + let version = probe.version(pid); + let safe = safe_while_suspended(version.as_ref()); + if !safe { + skipped.borrow_mut().insert(version.map_or(0, |v| v.major)); + } + safe + }; + match probe.find_by_jdwp_port(port, &may_query).and_then(|p| i32::try_from(p).ok()) { + Some(pid) if pid > 0 => Ok(pid), + _ => { + let skipped = skipped.into_inner(); + Err(match skipped.first() { + None => JvmMemoryUnavailable::AttachedJvmNotFound, + Some(&major) => JvmMemoryUnavailable::UnsafeWhileSuspended { + java_major: if skipped.len() == 1 { major } else { 0 }, + }, + }) + } + } +} + +fn pid_tree(pid: i32) -> Vec { + #[cfg(unix)] + { + ide_core::process::process_tree(pid) + } + #[cfg(not(unix))] + { + vec![pid] + } +} diff --git a/crates/ide-dap/src/jvm_memory/scan.rs b/crates/ide-dap/src/jvm_memory/scan.rs new file mode 100644 index 000000000..134d45bc7 --- /dev/null +++ b/crates/ide-dap/src/jvm_memory/scan.rs @@ -0,0 +1,146 @@ +//! Finding the JVM behind an attach session by its JDWP port. + +use super::safe_while_suspended; +use crate::session::Session; +use serde_json::Value; +use std::net::IpAddr; +use std::sync::Arc; +use std::sync::atomic::Ordering; +use std::time::Duration; + +/// One try of the scan at attach time, and the limit per socket within it: a silent socket's +/// query thread lives that long, so a longer per-socket limit piles threads up across tries. +const ATTACH_SCAN_TRY: Duration = Duration::from_millis(500); +const ATTACH_SCAN_INTERVAL: Duration = Duration::from_millis(150); +/// The JVM's attach socket can appear a little after the JDWP "Listening" banner, which is +/// when the IDE attaches. +const ATTACH_SCAN_WINDOW: Duration = Duration::from_secs(3); +/// The single scan a refresh makes while the attach-time one found nothing. +pub(super) const RESCAN_PER_SOCKET: Duration = Duration::from_secs(1); +pub(super) const RESCAN_BUDGET: Duration = Duration::from_secs(2); + +/// JDWP port of an attach to this machine (`hostName` loopback or absent): only such a JVM has +/// an attach socket the IDE can find. +pub(crate) fn local_jdwp_port(arguments: &Value) -> Option { + let local = match arguments.get("hostName") { + None | Some(Value::Null) => true, + Some(Value::String(host)) => { + let host = host.trim(); + let ip = host.strip_prefix('[').and_then(|h| h.strip_suffix(']')).unwrap_or(host); + host.eq_ignore_ascii_case("localhost") || ip.parse::().is_ok_and(|ip| ip.is_loopback()) + } + Some(_) => false, + }; + jdwp_port(arguments).filter(|_| local) +} + +/// `port` of an attach request: a number or a numeric string, never 0. +fn jdwp_port(arguments: &Value) -> Option { + let port = match arguments.get("port")? { + Value::Number(n) => n.as_u64().and_then(|p| u16::try_from(p).ok()), + Value::String(s) => s.trim().parse().ok(), + _ => None, + }; + port.filter(|&p| p != 0) +} + +/// `hostName` (empty when absent) and `port` of an attach request, local or not. +pub(crate) fn attach_target(arguments: &Value) -> Option<(String, u16)> { + let host = arguments.get("hostName").and_then(Value::as_str).unwrap_or_default(); + Some((host.to_owned(), jdwp_port(arguments)?)) +} + +/// Pid of the local JVM listening on a JDWP port (first argument), given whether the session is +/// suspended (second). Replaceable so tests never send attach requests to real JVMs. +pub(crate) type JvmFinder = Arc Option + Send + Sync>; + +/// The [`JvmFinder`] of the attach-time scan. +pub(crate) fn find_local_jvm(port: u16, suspended: bool) -> Option { + let may_query = |pid| !suspended || safe_while_suspended(ide_jvm_attach::java_version_of(pid).as_ref()); + ide_jvm_attach::find_jvm_by_jdwp_port(port, ATTACH_SCAN_TRY, ATTACH_SCAN_TRY, may_query) +} + +/// Looks for the JVM behind a local attach: one try now, the rest in the background, so a JVM +/// that never opens an attach socket holds the attach up by one try at most. Call it before the +/// debugger connects: the JVM clears its JDWP listener address then, and a scan can no longer +/// tell it by its port. +pub(crate) async fn find_attached_jvm(session: &Arc, port: u16, finder: JvmFinder) { + let _ = session.jdwp_port.set(port); + let find = move |suspended: bool| finder(port, suspended); + let first = find.clone(); + if let Ok(Some(pid)) = tokio::task::spawn_blocking(move || first(false)).await { + let _ = session.jvm_pid.compare_exchange(0, i64::from(pid), Ordering::AcqRel, Ordering::Acquire); + return; + } + drop(watch_for_attached_jvm(session, port, find, ATTACH_SCAN_INTERVAL, ATTACH_SCAN_WINDOW)); +} + +/// Retries `find` for the session's `jvm_pid`, holding the session weakly so an ended session +/// is not kept alive. `find` is told whether the session is suspended. +pub(crate) fn watch_for_attached_jvm( + session: &Arc, + port: u16, + find: F, + interval: Duration, + window: Duration, +) -> tokio::task::JoinHandle<()> +where + F: Fn(bool) -> Option + Clone + Send + 'static, +{ + let weak = Arc::downgrade(session); + tokio::spawn(async move { + let gate = || match weak.upgrade() { + Some(s) if !s.terminated.load(Ordering::Acquire) && !s.conn.is_closed() => { + if s.jvm_pid.load(Ordering::Acquire) > 0 { + ScanGate::Ended + } else { + ScanGate::Scan { suspended: s.suspended.get() } + } + } + _ => ScanGate::Ended, + }; + let Some(pid) = retry_find(find, gate, interval, window).await else { + tracing::debug!("no local JVM found for JDWP port {port} yet"); + return; + }; + if let Some(s) = weak.upgrade() { + let _ = s.jvm_pid.compare_exchange(0, i64::from(pid), Ordering::AcqRel, Ordering::Acquire); + } + }) +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum ScanGate { + Scan { suspended: bool }, + Ended, +} + +/// Calls `find` on a blocking thread, on each try `gate` allows, until it returns a pid, `gate` +/// reports the end or `window` has passed. +pub(crate) async fn retry_find( + find: F, + gate: impl Fn() -> ScanGate, + interval: Duration, + window: Duration, +) -> Option +where + F: Fn(bool) -> Option + Clone + Send + 'static, +{ + let deadline = tokio::time::Instant::now() + window; + loop { + match gate() { + ScanGate::Ended => break, + ScanGate::Scan { suspended } => { + let find = find.clone(); + if let Ok(Some(pid)) = tokio::task::spawn_blocking(move || find(suspended)).await { + return Some(pid); + } + } + } + if tokio::time::Instant::now() + interval >= deadline { + break; + } + tokio::time::sleep(interval).await; + } + None +} diff --git a/crates/ide-dap/src/jvm_memory/snapshot.rs b/crates/ide-dap/src/jvm_memory/snapshot.rs new file mode 100644 index 000000000..79e0f372e --- /dev/null +++ b/crates/ide-dap/src/jvm_memory/snapshot.rs @@ -0,0 +1,196 @@ +//! One class histogram of a JVM and its deltas against the previous one. + +use super::scan::{RESCAN_BUDGET, RESCAN_PER_SOCKET}; +use super::{ + ClassCounts, HISTOGRAM_TIMEOUT, JvmClassRow, JvmMemorySnapshot, JvmMemorySource, JvmMemoryUnavailable, + safe_while_suspended, +}; +use ide_core::IdeResult; +use ide_jvm_attach::{AttachError, HistogramRow, JavaVersion}; +use std::collections::HashMap; +use std::time::Duration; + +/// Version probe for a JVM whose `release` file could not be read. +const VERSION_TIMEOUT: Duration = Duration::from_secs(2); + +/// The attach calls a snapshot makes, so tests can stand in for a JVM. +pub(crate) trait AttachProbe: Send + Sync { + fn has_listener(&self, pid: u32) -> bool; + /// Asks only the JVMs whose pid `may_query` accepts. + fn find_by_jdwp_port(&self, port: u16, may_query: &dyn Fn(u32) -> bool) -> Option; + fn version(&self, pid: u32) -> Option; + fn histogram(&self, pid: u32) -> Result; + fn alive(&self, pid: u32) -> bool; +} + +pub(super) struct JdkProbe { + pub(super) suspended: bool, +} + +impl AttachProbe for JdkProbe { + fn has_listener(&self, pid: u32) -> bool { + ide_jvm_attach::has_listener(pid) + } + + fn find_by_jdwp_port(&self, port: u16, may_query: &dyn Fn(u32) -> bool) -> Option { + ide_jvm_attach::find_jvm_by_jdwp_port(port, RESCAN_PER_SOCKET, RESCAN_BUDGET, may_query) + } + + fn version(&self, pid: u32) -> Option { + ide_jvm_attach::java_version_of(pid).or_else(|| { + // The version decides whether attaching to a suspended VM hangs it, so never + // attach to learn it while suspended. + if self.suspended { + return None; + } + let out = ide_jvm_attach::jcmd(pid, "VM.version", VERSION_TIMEOUT).ok()?; + ide_jvm_attach::parse_vm_version(&out) + }) + } + + fn histogram(&self, pid: u32) -> Result { + ide_jvm_attach::jcmd(pid, "GC.class_histogram -all", HISTOGRAM_TIMEOUT) + } + + fn alive(&self, pid: u32) -> bool { + alive(pid) + } +} + +fn alive(pid: u32) -> bool { + #[cfg(unix)] + { + // 0 and negative pids address process groups. + let Ok(pid) = i32::try_from(pid) else { return false }; + // SAFETY: signal 0 only checks that the process exists and may be signalled. + pid > 0 + && (unsafe { libc::kill(pid, 0) } == 0 + || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)) + } + #[cfg(not(unix))] + { + let _ = pid; + false + } +} + +/// Why `pid`'s attach listener cannot answer: it stopped listening or the program ended. +pub(super) fn gone(probe: &dyn AttachProbe, pid: u32) -> JvmMemoryUnavailable { + if probe.alive(pid) { JvmMemoryUnavailable::ListenerNotRunning } else { JvmMemoryUnavailable::ProgramExited } +} + +/// The reason for an attach request (`what`) to `jvm` that failed, or an error where no reason +/// fits. +pub(super) fn attach_failure( + probe: &dyn AttachProbe, + jvm: u32, + error: AttachError, + what: &str, +) -> IdeResult { + use JvmMemoryUnavailable as U; + match error { + AttachError::Timeout => Ok(U::TimedOut), + AttachError::Refused | AttachError::NoListener => Ok(gone(probe, jvm)), + AttachError::ForeignListener => { + tracing::warn!("attach socket of JVM {jvm} is no longer private to this user; not using it"); + Ok(U::ListenerNotRunning) + } + AttachError::Unsupported => Ok(U::UnsupportedPlatform), + e => Err(ide_core::IdeError::internal(format!("{what} of JVM {jvm}: {e}"))), + } +} + +pub(crate) fn pick_jvm_pid(tree: &[i32], has_listener: impl Fn(u32) -> bool) -> Option { + tree.iter().filter_map(|&p| u32::try_from(p).ok()).filter(|&p| p > 0).find(|&p| has_listener(p)) +} + +pub(crate) fn diff_rows(rows: Vec, previous: Option<&ClassCounts>) -> (Vec, ClassCounts) { + let mut seen: HashMap<&str, u32> = HashMap::with_capacity(rows.len()); + for r in &rows { + *seen.entry(r.name.as_str()).or_default() += 1; + } + let ambiguous: Vec = rows.iter().map(|r| seen[r.name.as_str()] > 1).collect(); + drop(seen); + + let mut counts = ClassCounts::with_capacity(rows.len()); + let mut out: Vec = rows + .into_iter() + .zip(ambiguous) + .map(|(r, ambiguous)| { + counts.insert(r.name.clone(), (!ambiguous).then_some((r.instances, r.bytes))); + let delta = match previous { + _ if ambiguous => None, + None => None, + Some(p) => match p.get(&r.name) { + Some(None) => None, + // Absent: new since the previous snapshot, so it grew from nothing. + before => { + let (instances, bytes) = before.copied().flatten().unwrap_or_default(); + Some((delta(r.instances, instances), delta(r.bytes, bytes))) + } + }, + }; + JvmClassRow { + name: r.name, + module: r.module, + instances: r.instances, + bytes: r.bytes, + delta_instances: delta.map(|d| d.0), + delta_bytes: delta.map(|d| d.1), + ambiguous, + } + }) + .collect(); + out.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(&b.name))); + (out, counts) +} + +/// Exact while the difference fits in an i64 (two's complement). +fn delta(now: u64, before: u64) -> i64 { + now.wrapping_sub(before) as i64 +} + +/// `pid_tree` starts with the session's reported pid, followed by its descendants. +pub(crate) fn take_snapshot( + probe: &dyn AttachProbe, + pid_tree: &[i32], + suspended: bool, + previous: Option<&ClassCounts>, +) -> IdeResult> { + use JvmMemoryUnavailable as U; + let Some(root) = pid_tree.first().and_then(|&p| u32::try_from(p).ok()) else { + return Ok(Err(U::NoJvmProcess)); + }; + let Some(jvm) = pick_jvm_pid(pid_tree, |p| probe.has_listener(p)) else { + return Ok(Err(gone(probe, root))); + }; + + let version = probe.version(jvm); + if suspended && !safe_while_suspended(version.as_ref()) { + return Ok(Err(U::UnsafeWhileSuspended { java_major: version.map_or(0, |v| v.major) })); + } + + let started = std::time::Instant::now(); + let text = match probe.histogram(jvm) { + Ok(text) => text, + Err(e) => return attach_failure(probe, jvm, e, "class histogram").map(Err), + }; + let elapsed_ms = u32::try_from(started.elapsed().as_millis()).unwrap_or(u32::MAX); + + let histogram = ide_jvm_attach::parse_class_histogram(&text); + if histogram.skipped_lines > 0 { + tracing::debug!("class histogram of JVM {jvm}: {} unreadable lines", histogram.skipped_lines); + } + let (rows, counts) = diff_rows(histogram.rows, previous); + let snapshot = JvmMemorySnapshot { + total_instances: rows.iter().map(|r| r.instances).sum(), + total_bytes: rows.iter().map(|r| r.bytes).sum(), + rows, + java_version: version.map(|v| v.text).unwrap_or_default(), + taken_while_suspended: suspended, + has_baseline: previous.is_some(), + elapsed_ms, + source: JvmMemorySource::Histogram, + }; + Ok(Ok((snapshot, counts))) +} diff --git a/crates/ide-dap/src/jvm_memory/test_support.rs b/crates/ide-dap/src/jvm_memory/test_support.rs new file mode 100644 index 000000000..a0f9a5cec --- /dev/null +++ b/crates/ide-dap/src/jvm_memory/test_support.rs @@ -0,0 +1,119 @@ +//! A scripted [`JvmInspector`] and a session without an adapter behind it. + +use crate::SessionOwner; +use crate::adapter::{AdapterSpec, Transport}; +use crate::connection::Connection; +use crate::session::Session; +use ide_core::{DebugSessionId, IdeEvent, SharedSink}; +use ide_jvm_inspect::{ + ClassCount, InspectError, InspectFuture, JvmField, JvmInspector, JvmReferrer, JvmValue, ObjectId, VmTarget, +}; +use parking_lot::Mutex; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use tokio::sync::Notify; + +#[derive(Default)] +pub(crate) struct FakeInspector { + pub targets: Mutex>, + /// The `limit` of each `instances` / `referrers` call. + pub limits: Mutex>, + pub calls: AtomicUsize, + /// Returned by every call when set. + pub error: Mutex>, + pub counts: Mutex>, + pub instances: Mutex>, + /// Every call signals `entered`, then waits for `release`. + pub hold: bool, + pub entered: Notify, + pub release: Notify, +} + +impl FakeInspector { + pub fn failing(error: InspectError) -> Self { + Self { error: Mutex::new(Some(error)), ..Self::default() } + } + + pub fn calls(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } + + async fn answer(&self, target: &VmTarget, limit: Option, ok: T) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + self.targets.lock().push(target.clone()); + self.limits.lock().extend(limit); + if self.hold { + self.entered.notify_one(); + self.release.notified().await; + } + match self.error.lock().clone() { + Some(e) => Err(e), + None => Ok(ok), + } + } +} + +impl JvmInspector for FakeInspector { + fn class_counts<'a>(&'a self, target: &'a VmTarget) -> InspectFuture<'a, Vec> { + Box::pin(self.answer(target, None, self.counts.lock().clone())) + } + + fn instances<'a>(&'a self, target: &'a VmTarget, _: &'a str, limit: u32) -> InspectFuture<'a, Vec> { + Box::pin(self.answer(target, Some(limit), self.instances.lock().clone())) + } + + fn fields<'a>(&'a self, target: &'a VmTarget, _: ObjectId) -> InspectFuture<'a, Vec> { + Box::pin(self.answer(target, None, Vec::new())) + } + + fn referrers<'a>(&'a self, target: &'a VmTarget, _: ObjectId, limit: u32) -> InspectFuture<'a, Vec> { + Box::pin(self.answer(target, Some(limit), Vec::new())) + } +} + +pub(crate) struct BareSession { + pub session: Arc, + pub events: Arc>>, + /// The adapter's end of the connection, kept open. + _adapter: tokio::io::DuplexStream, +} + +/// A java-debug session that no adapter answers; must be called inside a Tokio runtime. +pub(crate) fn bare_session(request: &str) -> BareSession { + bare_session_of("java-debug", request) +} + +/// A session of adapter `name` that no adapter answers; must be called inside a Tokio runtime. +pub(crate) fn bare_session_of(name: &str, request: &str) -> BareSession { + let events: Arc>> = Arc::default(); + let sink_events = events.clone(); + let sink: SharedSink = Arc::new(move |e: IdeEvent| sink_events.lock().push(e)); + let (client, adapter) = tokio::io::duplex(1 << 10); + let (reader, writer) = tokio::io::split(client); + let (conn, _) = Connection::start(reader, writer); + let spec = AdapterSpec { + adapter: name.into(), + command: String::new(), + args: Vec::new(), + transport: Transport::Stdio, + install_hint: None, + cwd: std::env::temp_dir(), + env: Vec::new(), + server_host: None, + connect: None, + pinned: None, + }; + let session = Session::new( + DebugSessionId(1), + SessionOwner::new("test", sink), + "test".into(), + name.into(), + None, + request.into(), + conn, + spec, + None, + None, + ); + BareSession { session: Arc::new(session), events, _adapter: adapter } +} diff --git a/crates/ide-dap/src/jvm_memory/tests.rs b/crates/ide-dap/src/jvm_memory/tests.rs new file mode 100644 index 000000000..025a635c2 --- /dev/null +++ b/crates/ide-dap/src/jvm_memory/tests.rs @@ -0,0 +1,702 @@ +use super::fallback::{debugger_snapshot, falls_back}; +use super::scan::*; +use super::snapshot::*; +use super::test_support::{BareSession, FakeInspector, bare_session, bare_session_of}; +use super::*; +use ide_jvm_attach::{AttachError, HistogramRow, JavaVersion}; +use std::collections::VecDeque; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +fn row(name: &str, instances: u64, bytes: u64) -> HistogramRow { + HistogramRow { name: name.into(), module: None, instances, bytes } +} + +struct FakeProbe { + listener: u32, + version: Option, + histograms: parking_lot::Mutex>>, + alive: bool, + delay: Duration, + in_flight: AtomicUsize, + max_in_flight: AtomicUsize, + /// JDWP port → pid of the JVM listening on it. + jdwp: Option<(u16, u32)>, + scans: AtomicUsize, + asked: parking_lot::Mutex>, + /// Pids the scan sent a request to. + queried: parking_lot::Mutex>, + /// Attach commands sent, in order. + commands: parking_lot::Mutex>, +} + +impl FakeProbe { + fn new(listener: u32, histograms: Vec>) -> Self { + Self { + listener, + version: Some(JavaVersion { major: 21, text: "21.0.5".into() }), + histograms: parking_lot::Mutex::new(histograms.into()), + alive: true, + delay: Duration::ZERO, + in_flight: AtomicUsize::new(0), + max_in_flight: AtomicUsize::new(0), + jdwp: None, + scans: AtomicUsize::new(0), + asked: parking_lot::Mutex::new(Vec::new()), + queried: parking_lot::Mutex::new(Vec::new()), + commands: parking_lot::Mutex::new(Vec::new()), + } + } + + fn enter(&self, command: &'static str) { + self.commands.lock().push(command); + let now = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1; + self.max_in_flight.fetch_max(now, Ordering::SeqCst); + } + + fn leave(&self) { + self.in_flight.fetch_sub(1, Ordering::SeqCst); + } +} + +impl AttachProbe for FakeProbe { + fn has_listener(&self, pid: u32) -> bool { + self.asked.lock().push(pid); + pid == self.listener + } + fn find_by_jdwp_port(&self, port: u16, may_query: &dyn Fn(u32) -> bool) -> Option { + self.scans.fetch_add(1, Ordering::SeqCst); + let (listening, pid) = self.jdwp?; + if !may_query(pid) { + return None; + } + self.queried.lock().push(pid); + (listening == port).then_some(pid) + } + fn version(&self, _pid: u32) -> Option { + self.version.clone() + } + fn histogram(&self, _pid: u32) -> Result { + self.enter("histogram"); + std::thread::sleep(self.delay); + self.leave(); + self.histograms.lock().pop_front().expect("unexpected histogram request") + } + fn alive(&self, _pid: u32) -> bool { + self.alive + } +} + +const HISTOGRAM: &str = " num #instances #bytes class name (module)\n-------------------------------------------------------\n 1: 5 80 A\n 2: 1 16 B\nTotal 6 96\n"; + +fn unavailable(r: IdeResult>) -> JvmMemoryUnavailable { + r.unwrap().map(|_| ()).unwrap_err() +} + +#[test] +fn picks_first_descendant_with_listener() { + assert_eq!(pick_jvm_pid(&[10, 11, 12], |p| p == 12), Some(12)); + assert_eq!(pick_jvm_pid(&[10], |_| false), None); +} + +#[test] +fn first_snapshot_has_no_deltas() { + let (rows, map) = diff_rows(vec![row("A", 5, 80), row("B", 1, 16)], None); + assert!(rows.iter().all(|r| r.delta_instances.is_none() && r.delta_bytes.is_none() && !r.ambiguous)); + assert_eq!(map, HashMap::from([("A".into(), Some((5, 80))), ("B".into(), Some((1, 16)))])); +} + +#[test] +fn deltas_against_previous_snapshot() { + let previous = HashMap::from([("A".to_owned(), Some((2, 32)))]); + let (rows, _) = diff_rows(vec![row("A", 5, 80), row("B", 1, 16)], Some(&previous)); + assert_eq!((rows[0].name.as_str(), rows[0].delta_instances, rows[0].delta_bytes), ("A", Some(3), Some(48))); + assert_eq!((rows[1].name.as_str(), rows[1].delta_instances, rows[1].delta_bytes), ("B", Some(1), Some(16))); + + let previous = HashMap::from([("A".to_owned(), Some((9, 144)))]); + let (rows, _) = diff_rows(vec![row("A", 5, 80)], Some(&previous)); + assert_eq!((rows[0].delta_instances, rows[0].delta_bytes), (Some(-4), Some(-64))); +} + +#[test] +fn duplicate_names_are_ambiguous_without_delta() { + let previous = HashMap::from([("Foo".to_owned(), Some((1, 16)))]); + let (rows, map) = diff_rows(vec![row("Foo", 2, 32), row("Foo", 3, 48), row("Bar", 1, 8)], Some(&previous)); + let foos: Vec<_> = rows.iter().filter(|r| r.name == "Foo").collect(); + assert_eq!(foos.len(), 2); + assert!(foos.iter().all(|r| r.ambiguous && r.delta_instances.is_none() && r.delta_bytes.is_none())); + assert_eq!(map.get("Foo"), Some(&None)); + assert_eq!(map.get("Bar"), Some(&Some((1, 8)))); +} + +#[test] +fn previously_duplicate_now_unique_has_no_delta() { + let (_, previous) = diff_rows(vec![row("Foo", 2, 32), row("Foo", 3, 48)], None); + let (rows, map) = diff_rows(vec![row("Foo", 4, 64)], Some(&previous)); + assert!(!rows[0].ambiguous); + assert_eq!((rows[0].delta_instances, rows[0].delta_bytes), (None, None)); + assert_eq!(map.get("Foo"), Some(&Some((4, 64)))); +} + +#[test] +fn rows_sorted_by_bytes_descending() { + let (rows, _) = diff_rows(vec![row("S", 1, 8), row("L", 1, 800), row("M", 1, 80)], None); + let names: Vec<_> = rows.iter().map(|r| r.name.as_str()).collect(); + assert_eq!(names, ["L", "M", "S"]); +} + +#[test] +fn refused_socket_reports_program_exited() { + let mut probe = FakeProbe::new(12, vec![Err(AttachError::Refused)]); + probe.alive = false; + assert_eq!(unavailable(take_snapshot(&probe, &[10, 12], false, None)), JvmMemoryUnavailable::ProgramExited); + + let probe = FakeProbe::new(12, vec![Err(AttachError::Refused)]); + assert_eq!(unavailable(take_snapshot(&probe, &[10, 12], false, None)), JvmMemoryUnavailable::ListenerNotRunning); +} + +#[test] +fn no_listener_in_tree() { + let mut probe = FakeProbe::new(99, Vec::new()); + assert_eq!(unavailable(take_snapshot(&probe, &[10, 12], false, None)), JvmMemoryUnavailable::ListenerNotRunning); + probe.alive = false; + assert_eq!(unavailable(take_snapshot(&probe, &[10, 12], false, None)), JvmMemoryUnavailable::ProgramExited); +} + +#[test] +fn suspended_vm_needs_a_safe_jdk() { + let mut probe = FakeProbe::new(12, Vec::new()); + probe.version = Some(JavaVersion { major: 11, text: "11.0.25".into() }); + assert_eq!( + unavailable(take_snapshot(&probe, &[12], true, None)), + JvmMemoryUnavailable::UnsafeWhileSuspended { java_major: 11 } + ); + probe.version = None; + assert_eq!( + unavailable(take_snapshot(&probe, &[12], true, None)), + JvmMemoryUnavailable::UnsafeWhileSuspended { java_major: 0 } + ); + + let probe = FakeProbe::new(12, vec![Ok(HISTOGRAM.into())]); + let (snapshot, _) = take_snapshot(&probe, &[12], true, None).unwrap().unwrap(); + assert!(snapshot.taken_while_suspended); + assert_eq!(snapshot.java_version, "21.0.5"); +} + +#[test] +fn command_errors_are_internal() { + let probe = FakeProbe::new(12, vec![Err(AttachError::Command { code: 1, message: "no".into() })]); + let err = take_snapshot(&probe, &[12], false, None).unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::Internal); +} + +#[test] +fn result_serializes_with_camel_case_tags() { + let r = JvmMemoryResult::Unavailable { + reason: JvmMemoryUnavailable::UnsafeWhileSuspended { java_major: 11 }, + can_count_when_paused: true, + }; + assert_eq!( + serde_json::to_value(r).unwrap(), + serde_json::json!({ + "status": "unavailable", + "reason": {"kind": "unsafeWhileSuspended", "javaMajor": 11}, + "canCountWhenPaused": true + }) + ); + assert_eq!(serde_json::to_value(JvmMemorySource::Debugger).unwrap(), "debugger"); + let stale = JvmFieldsResult::Unavailable { reason: JvmInspectUnavailable::Stale }; + assert_eq!( + serde_json::to_value(stale).unwrap(), + serde_json::json!({"status": "unavailable", "reason": {"kind": "stale"}}) + ); +} + +#[test] +fn local_jdwp_port_accepts_loopback_only() { + let port = |args: serde_json::Value| local_jdwp_port(&args); + for host in ["127.0.0.1", "localhost", "LocalHost", "::1", "[::1]"] { + assert_eq!(port(serde_json::json!({"hostName": host, "port": 5005})), Some(5005), "{host}"); + } + assert_eq!(port(serde_json::json!({"port": 5005})), Some(5005)); + assert_eq!(port(serde_json::json!({"hostName": "localhost", "port": "5005"})), Some(5005)); + assert_eq!(port(serde_json::json!({"hostName": "10.0.0.5", "port": 5005})), None); + assert_eq!(port(serde_json::json!({"hostName": "build-box", "port": 5005})), None); + assert_eq!(port(serde_json::json!({"hostName": "localhost", "port": 0})), None); + assert_eq!(port(serde_json::json!({"hostName": "localhost", "port": 70000})), None); + assert_eq!(port(serde_json::json!({"hostName": "localhost", "port": "x"})), None); + assert_eq!(port(serde_json::json!({"hostName": "localhost"})), None); +} + +#[tokio::test] +async fn attach_with_pid_takes_snapshot() { + let state = tokio::sync::Mutex::new(JvmMemoryState::default()); + let running = Suspension::default(); + let jvm_pid = AtomicI64::new(4242); + let fake = Arc::new(FakeProbe::new(4242, vec![Ok(HISTOGRAM.into())])); + let probe: Arc = fake.clone(); + let target = JvmTarget::Attached { pid: &jvm_pid, jdwp_port: Some(5005) }; + let r = snapshot(&state, &running, |_| probe, target, None).await.unwrap(); + assert!(matches!(r, JvmMemoryResult::Ready { .. }), "{r:?}"); + // The attached JVM's own pid only: its children are not part of the session. + assert_eq!(*fake.asked.lock(), [4242]); + assert_eq!(fake.scans.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn refresh_rescans_local_attach_without_pid() { + let state = tokio::sync::Mutex::new(JvmMemoryState::default()); + let running = Suspension::default(); + let jvm_pid = AtomicI64::new(0); + + let missing = Arc::new(FakeProbe::new(4242, Vec::new())); + let probe: Arc = missing.clone(); + let target = JvmTarget::Attached { pid: &jvm_pid, jdwp_port: Some(5005) }; + assert_eq!( + snapshot(&state, &running, |_| probe, target, None).await.unwrap(), + JvmMemoryResult::Unavailable { + reason: JvmMemoryUnavailable::AttachedJvmNotFound, + can_count_when_paused: false + } + ); + assert_eq!(missing.scans.load(Ordering::SeqCst), 1); + assert_eq!(jvm_pid.load(Ordering::Acquire), 0); + + let mut found = FakeProbe::new(4242, vec![Ok(HISTOGRAM.into()), Ok(HISTOGRAM.into())]); + found.jdwp = Some((5005, 4242)); + let found = Arc::new(found); + let probe: Arc = found.clone(); + let target = || JvmTarget::Attached { pid: &jvm_pid, jdwp_port: Some(5005) }; + let r = snapshot(&state, &running, |_| probe.clone(), target(), None).await.unwrap(); + assert!(matches!(r, JvmMemoryResult::Ready { .. }), "{r:?}"); + assert_eq!(jvm_pid.load(Ordering::Acquire), 4242); + snapshot(&state, &running, |_| probe, target(), None).await.unwrap(); + assert_eq!(found.scans.load(Ordering::SeqCst), 1, "a found pid is kept"); +} + +#[tokio::test] +async fn remote_attach_without_pid_is_not_scanned() { + let state = tokio::sync::Mutex::new(JvmMemoryState::default()); + let running = Suspension::default(); + let jvm_pid = AtomicI64::new(0); + let mut fake = FakeProbe::new(4242, Vec::new()); + fake.jdwp = Some((5005, 4242)); + let fake = Arc::new(fake); + let probe: Arc = fake.clone(); + let target = JvmTarget::Attached { pid: &jvm_pid, jdwp_port: None }; + assert_eq!( + snapshot(&state, &running, |_| probe, target, None).await.unwrap(), + JvmMemoryResult::Unavailable { + reason: JvmMemoryUnavailable::AttachedJvmNotFound, + can_count_when_paused: false + } + ); + assert_eq!(fake.scans.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn retries_find_until_the_jvm_appears() { + let tries = Arc::new(AtomicUsize::new(0)); + let counted = tries.clone(); + let find = move |_| (counted.fetch_add(1, Ordering::SeqCst) >= 2).then_some(4242); + let found = retry_find(find, || RUNNING, Duration::from_millis(1), Duration::from_secs(5)).await; + assert_eq!(found, Some(4242)); + assert_eq!(tries.load(Ordering::SeqCst), 3); +} + +const RUNNING: ScanGate = ScanGate::Scan { suspended: false }; + +#[tokio::test] +async fn retry_find_tells_each_try_whether_the_jvm_is_suspended() { + let seen = Arc::new(parking_lot::Mutex::new(Vec::new())); + let recorded = seen.clone(); + // The JVM is an old JDK, so a suspended try skips it. + let find = move |suspended: bool| { + recorded.lock().push(suspended); + (!suspended).then_some(4242) + }; + let checks = AtomicUsize::new(0); + // Suspended for the first three checks, then resumed. + let gate = || ScanGate::Scan { suspended: checks.fetch_add(1, Ordering::SeqCst) < 3 }; + let found = retry_find(find, gate, Duration::from_millis(1), Duration::from_secs(5)).await; + assert_eq!(found, Some(4242)); + assert_eq!(*seen.lock(), [true, true, true, false]); +} + +#[test] +fn only_a_known_jdk_17_or_later_is_safe_while_suspended() { + let jdk = |major| Some(JavaVersion { major, text: major.to_string() }); + assert!(safe_while_suspended(jdk(21).as_ref())); + assert!(safe_while_suspended(jdk(17).as_ref())); + assert!(!safe_while_suspended(jdk(11).as_ref())); + assert!(!safe_while_suspended(None)); +} + +/// A suspended local attach session whose JVM (pid 4242, JDWP 5005) is not known yet. +async fn suspended_scan(fake: FakeProbe) -> (JvmMemoryResult, Arc, i64) { + let state = tokio::sync::Mutex::new(JvmMemoryState::default()); + let suspended = Suspension::default(); + suspended.stopped(None, true); + let jvm_pid = AtomicI64::new(0); + let fake = Arc::new(fake); + let probe: Arc = fake.clone(); + let target = JvmTarget::Attached { pid: &jvm_pid, jdwp_port: Some(5005) }; + let result = snapshot(&state, &suspended, |_| probe, target, None).await.unwrap(); + (result, fake, jvm_pid.load(Ordering::Acquire)) +} + +#[tokio::test] +async fn suspended_scan_finds_a_jdk_17_or_later() { + let mut fake = FakeProbe::new(4242, vec![Ok(HISTOGRAM.into())]); + fake.jdwp = Some((5005, 4242)); + let (result, fake, pid) = suspended_scan(fake).await; + let JvmMemoryResult::Ready { snapshot } = result else { panic!("{result:?}") }; + assert!(snapshot.taken_while_suspended); + assert_eq!(*fake.queried.lock(), [4242]); + assert_eq!(pid, 4242); +} + +#[tokio::test] +async fn suspended_scan_never_asks_an_older_jdk() { + for (version, java_major) in [(Some(JavaVersion { major: 11, text: "11.0.25".into() }), 11), (None, 0)] { + let mut fake = FakeProbe::new(4242, Vec::new()); + fake.jdwp = Some((5005, 4242)); + fake.version = version; + let (result, fake, pid) = suspended_scan(fake).await; + assert_eq!( + result, + JvmMemoryResult::Unavailable { + reason: JvmMemoryUnavailable::UnsafeWhileSuspended { java_major }, + can_count_when_paused: false, + } + ); + assert!(fake.queried.lock().is_empty()); + assert!(fake.asked.lock().is_empty()); + assert_eq!(pid, 0); + } +} + +#[tokio::test] +async fn suspended_scan_without_candidates_is_not_found() { + let (result, _, _) = suspended_scan(FakeProbe::new(4242, Vec::new())).await; + assert_eq!( + result, + JvmMemoryResult::Unavailable { + reason: JvmMemoryUnavailable::AttachedJvmNotFound, + can_count_when_paused: false + } + ); +} + +#[tokio::test] +async fn retry_find_gives_up_after_its_window() { + let tries = Arc::new(AtomicUsize::new(0)); + let counted = tries.clone(); + let find = move |_| { + counted.fetch_add(1, Ordering::SeqCst); + None + }; + let started = std::time::Instant::now(); + let found = retry_find(find, || RUNNING, Duration::from_millis(10), Duration::from_millis(100)).await; + assert_eq!(found, None); + assert!(started.elapsed() < Duration::from_secs(2)); + assert!(tries.load(Ordering::SeqCst) >= 2); +} + +#[tokio::test] +async fn retry_find_stops_when_the_session_ends() { + let tries = Arc::new(AtomicUsize::new(0)); + let counted = tries.clone(); + let find = move |_| { + counted.fetch_add(1, Ordering::SeqCst); + None + }; + let live = AtomicBool::new(true); + let gate = || if live.swap(false, Ordering::SeqCst) { RUNNING } else { ScanGate::Ended }; + let found = retry_find(find, gate, Duration::from_millis(1), Duration::from_secs(5)).await; + assert_eq!(found, None); + assert_eq!(tries.load(Ordering::SeqCst), 1); +} + +#[cfg(unix)] +#[tokio::test] +async fn histogram_lock_released_after_timeout() { + let pid = std::process::id(); + let state = tokio::sync::Mutex::new(JvmMemoryState::default()); + let running = Suspension::default(); + let probe: Arc = + Arc::new(FakeProbe::new(pid, vec![Err(AttachError::Timeout), Ok(HISTOGRAM.into())])); + let first = snapshot(&state, &running, |_| probe.clone(), JvmTarget::Launched(pid as i32), None).await.unwrap(); + assert_eq!( + first, + JvmMemoryResult::Unavailable { reason: JvmMemoryUnavailable::TimedOut, can_count_when_paused: false } + ); + let second = tokio::time::timeout( + Duration::from_secs(5), + snapshot(&state, &running, |_| probe, JvmTarget::Launched(pid as i32), None), + ) + .await + .expect("second request blocked by the first") + .unwrap(); + let JvmMemoryResult::Ready { snapshot } = second else { panic!("{second:?}") }; + assert!(!snapshot.has_baseline); + assert_eq!((snapshot.total_instances, snapshot.total_bytes), (6, 96)); +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn one_histogram_at_a_time_per_session() { + let pid = std::process::id(); + let state = tokio::sync::Mutex::new(JvmMemoryState::default()); + let mut fake = FakeProbe::new(pid, vec![Ok(HISTOGRAM.into()), Ok(HISTOGRAM.into())]); + fake.delay = Duration::from_millis(100); + let fake = Arc::new(fake); + let probe: Arc = fake.clone(); + let running = Suspension::default(); + let (a, b) = tokio::join!( + snapshot(&state, &running, |_| probe.clone(), JvmTarget::Launched(pid as i32), None), + snapshot(&state, &running, |_| probe.clone(), JvmTarget::Launched(pid as i32), None) + ); + assert_eq!(fake.max_in_flight.load(Ordering::SeqCst), 1); + let has_baseline = |r: JvmMemoryResult| match r { + JvmMemoryResult::Ready { snapshot } => snapshot.has_baseline, + other => panic!("{other:?}"), + }; + let mut baselines = [has_baseline(a.unwrap()), has_baseline(b.unwrap())]; + baselines.sort(); + assert_eq!(baselines, [false, true]); +} + +#[cfg(unix)] +#[tokio::test] +async fn queued_request_reads_suspension_under_the_lock() { + let pid = std::process::id(); + let state = tokio::sync::Mutex::new(JvmMemoryState::default()); + let suspended = Suspension::default(); + let mut fake = FakeProbe::new(pid, Vec::new()); + fake.version = Some(JavaVersion { major: 11, text: "11.0.25".into() }); + let probe: Arc = Arc::new(fake); + let seen = parking_lot::Mutex::new(None); + + let running = state.lock().await; + let mut queued = Box::pin(snapshot( + &state, + &suspended, + |s| { + *seen.lock() = Some(s); + probe + }, + JvmTarget::Launched(pid as i32), + None, + )); + assert!(tokio::time::timeout(Duration::from_millis(50), &mut queued).await.is_err()); + // The program hits a breakpoint while the request waits behind another one. + suspended.stopped(None, true); + drop(running); + assert_eq!( + queued.await.unwrap(), + JvmMemoryResult::Unavailable { + reason: JvmMemoryUnavailable::UnsafeWhileSuspended { java_major: 11 }, + can_count_when_paused: false, + } + ); + assert_eq!(*seen.lock(), Some(true)); +} + +#[cfg(unix)] +#[tokio::test] +async fn failed_blocking_task_keeps_the_baseline() { + let pid = std::process::id(); + let state = tokio::sync::Mutex::new(JvmMemoryState::default()); + let running = Suspension::default(); + let probe = |histograms: Vec<_>| -> Arc { Arc::new(FakeProbe::new(pid, histograms)) }; + snapshot(&state, &running, |_| probe(vec![Ok(HISTOGRAM.into())]), JvmTarget::Launched(pid as i32), None) + .await + .unwrap(); + // An empty fake panics on its histogram call, failing the blocking task. + assert!(snapshot(&state, &running, |_| probe(Vec::new()), JvmTarget::Launched(pid as i32), None).await.is_err()); + let third = + snapshot(&state, &running, |_| probe(vec![Ok(HISTOGRAM.into())]), JvmTarget::Launched(pid as i32), None) + .await + .unwrap(); + let JvmMemoryResult::Ready { snapshot } = third else { panic!("{third:?}") }; + assert!(snapshot.has_baseline); +} + +fn class(name: &str, instances: u64) -> ide_jvm_inspect::ClassCount { + ide_jvm_inspect::ClassCount { name: name.into(), instances } +} + +fn jdk11(listener: u32) -> FakeProbe { + let mut probe = FakeProbe::new(listener, Vec::new()); + probe.version = Some(JavaVersion { major: 11, text: "11.0.25".into() }); + probe +} + +/// The class table of `bare`'s session, histogram from `probe` or else `target`'s reason. +async fn table(bare: &BareSession, probe: FakeProbe, target: JvmTarget<'_>) -> JvmMemoryResult { + let s = &bare.session; + let probe: Arc = Arc::new(probe); + snapshot(&s.jvm_memory, &s.suspended, |_| probe, target, Some(s)).await.unwrap() +} + +fn inspected(bare: &BareSession, fake: FakeInspector) -> Arc { + let fake = Arc::new(fake); + *bare.session.jvm_inspector.lock() = Some(fake.clone()); + fake +} + +#[cfg(unix)] +#[tokio::test] +async fn suspended_jdk11_falls_back_to_debugger_counts() { + let pid = std::process::id(); + let bare = bare_session("launch"); + bare.session.suspended.stopped(None, true); + let fake = inspected(&bare, FakeInspector::default()); + fake.counts.lock().extend([class("A", 3), class("B", 0)]); + let target = JvmTarget::Launched(pid as i32); + let result = table(&bare, jdk11(pid), target).await; + let JvmMemoryResult::Ready { snapshot } = result else { panic!("{result:?}") }; + assert_eq!(snapshot.source, JvmMemorySource::Debugger); + assert_eq!( + snapshot.rows, + [JvmClassRow { + name: "A".into(), + module: None, + instances: 3, + bytes: 0, + delta_instances: None, + delta_bytes: None, + ambiguous: false + }] + ); + assert_eq!((snapshot.total_instances, snapshot.total_bytes), (3, 0)); + assert!(snapshot.taken_while_suspended && !snapshot.has_baseline); + assert_eq!(fake.calls(), 1); +} + +#[tokio::test] +async fn reasons_found_before_asking_the_jvm_fall_back_too() { + let bare = bare_session("launch"); + bare.session.suspended.stopped(None, true); + let fake = inspected(&bare, FakeInspector::default()); + fake.counts.lock().push(class("A", 3)); + let result = table(&bare, jdk11(1), JvmTarget::Unavailable(JvmMemoryUnavailable::UnsupportedPlatform)).await; + assert!(matches!(&result, JvmMemoryResult::Ready { snapshot } if snapshot.source == JvmMemorySource::Debugger)); +} + +#[tokio::test] +async fn running_without_listener_offers_counting_when_paused() { + let bare = bare_session("launch"); + let reason = JvmMemoryUnavailable::ListenerNotRunning; + let target = JvmTarget::Unavailable(reason); + let without = table(&bare, jdk11(1), target).await; + assert_eq!(without, JvmMemoryResult::Unavailable { reason, can_count_when_paused: false }); + + let fake = inspected(&bare, FakeInspector::default()); + let with = table(&bare, jdk11(1), target).await; + assert_eq!(with, JvmMemoryResult::Unavailable { reason, can_count_when_paused: true }); + assert_eq!(fake.calls(), 0); +} + +#[tokio::test] +async fn other_reasons_do_not_fall_back() { + let bare = bare_session("attach"); + bare.session.suspended.stopped(None, true); + let fake = inspected(&bare, FakeInspector::default()); + for reason in [JvmMemoryUnavailable::ProgramExited, JvmMemoryUnavailable::TimedOut] { + let result = table(&bare, jdk11(1), JvmTarget::Unavailable(reason)).await; + assert_eq!(result, JvmMemoryResult::Unavailable { reason, can_count_when_paused: false }); + } + assert_eq!(fake.calls(), 0); +} + +#[tokio::test] +async fn failed_debugger_count_reports_the_histogram_reason() { + let bare = bare_session("launch"); + bare.session.suspended.stopped(None, true); + inspected(&bare, FakeInspector::failing(ide_jvm_inspect::InspectError::TimedOut)); + let reason = JvmMemoryUnavailable::UnsafeWhileSuspended { java_major: 11 }; + let result = table(&bare, jdk11(1), JvmTarget::Unavailable(reason)).await; + assert_eq!(result, JvmMemoryResult::Unavailable { reason, can_count_when_paused: false }); +} + +#[tokio::test] +async fn unsupported_count_turns_the_backend_off() { + use ide_core::event::{DebugEvent, JvmBackendState}; + let bare = bare_session("launch"); + bare.session.suspended.stopped(None, true); + inspected(&bare, FakeInspector::failing(ide_jvm_inspect::InspectError::Unsupported("x".into()))); + let reason = JvmMemoryUnavailable::UnsupportedPlatform; + let result = table(&bare, jdk11(1), JvmTarget::Unavailable(reason)).await; + assert_eq!(result, JvmMemoryResult::Unavailable { reason, can_count_when_paused: false }); + assert!(bare.session.jvm_inspector.lock().is_none()); + let events: Vec<_> = bare + .events + .lock() + .iter() + .filter_map(|e| match e { + ide_core::IdeEvent::Debug { event, .. } => Some(event.clone()), + _ => None, + }) + .collect(); + assert!( + matches!( + events.as_slice(), + [DebugEvent::JvmBackend { state: JvmBackendState::Unsupported { .. } }, DebugEvent::Capabilities] + ), + "{events:?}" + ); +} + +#[test] +fn falls_back_only_where_the_debugger_can_count() { + use JvmMemoryUnavailable as U; + for reason in [ + U::UnsafeWhileSuspended { java_major: 11 }, + U::UnsupportedPlatform, + U::AttachedJvmNotFound, + U::ListenerNotRunning, + U::ListenModeNotSupported, + U::NoJvmProcess, + ] { + assert!(falls_back(&reason), "{reason:?}"); + } + assert!(!falls_back(&U::ProgramExited) && !falls_back(&U::TimedOut)); +} + +#[test] +fn deltas_only_against_the_same_source() { + use JvmMemorySource::{Debugger, Histogram}; + let mut state = JvmMemoryState::default(); + let probe = FakeProbe::new(12, vec![Ok(HISTOGRAM.into())]); + let (_, counts) = take_snapshot(&probe, &[12], false, None).unwrap().unwrap(); + state.remember(Histogram, counts); + + let (snapshot, counts) = + debugger_snapshot(vec![class("A", 3), class("C", 1)], state.baseline(Debugger).as_deref(), 0); + assert!(!snapshot.has_baseline); + assert!(snapshot.rows.iter().all(|r| r.delta_instances.is_none() && r.delta_bytes.is_none()), "{snapshot:?}"); + state.remember(Debugger, counts); + assert!(state.baseline(Histogram).is_none()); + + let (snapshot, _) = debugger_snapshot(vec![class("A", 4)], state.baseline(Debugger).as_deref(), 0); + assert!(snapshot.has_baseline); + assert_eq!((snapshot.rows[0].delta_instances, snapshot.rows[0].delta_bytes), (Some(1), None)); +} + +#[test] +fn debugger_rows_are_ordered_by_instances() { + let (snapshot, _) = debugger_snapshot(vec![class("B", 1), class("A", 9), class("C", 9)], None, 0); + let names: Vec<_> = snapshot.rows.iter().map(|r| r.name.as_str()).collect(); + assert_eq!(names, ["A", "C", "B"]); +} + +#[tokio::test] +async fn capability_matches_memory() { + for adapter in JVM_MEMORY_ADAPTERS.iter().copied().chain(["debugpy"]) { + let bare = bare_session_of(adapter, "launch"); + let caps = crate::session_capabilities(&bare.session); + let jvm = adapter != "debugpy"; + assert_eq!(caps.jvm_memory, jvm, "{adapter}"); + } +} diff --git a/crates/ide-dap/src/kotlin.rs b/crates/ide-dap/src/kotlin.rs index fd3aae30a..ab36abd21 100644 --- a/crates/ide-dap/src/kotlin.rs +++ b/crates/ide-dap/src/kotlin.rs @@ -9,6 +9,13 @@ use std::{ pub const ADAPTER: &str = "kotlin-debug-adapter"; +/// The adapter attaches to a JVM the IDE starts, through the JDWP link. It resumes the VM while +/// constructing its debuggee, before its breakpoint hooks exist, so the link holds resumes back +/// until the attach is done. +pub(crate) fn link_options() -> ide_jdwp::LinkOptions { + ide_jdwp::LinkOptions { hold_resumes: true } +} + pub struct SourceMirror { dir: tempfile::TempDir, paths: HashMap, diff --git a/crates/ide-dap/src/kotlin_jdwp.rs b/crates/ide-dap/src/kotlin_jdwp.rs deleted file mode 100644 index f0c49c7f0..000000000 --- a/crates/ide-dap/src/kotlin_jdwp.rs +++ /dev/null @@ -1,166 +0,0 @@ -//! Keep a suspended JVM stopped until Kotlin's adapter has installed its listeners. -//! The official 0.4.4 adapter resumes VMStart while constructing its debuggee, before -//! installing breakpoint hooks. DAP attach completes only after those hooks exist. -use ide_core::IdeResult; -use std::{io, time::Duration}; -use tokio::{ - io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, - net::{TcpListener, TcpStream}, - sync::oneshot, - task::JoinHandle, -}; - -pub(crate) struct StartupRelay { - release: Option>, - task: JoinHandle<()>, -} - -impl StartupRelay { - pub async fn start(host: String, port: u16) -> IdeResult<(Self, u16)> { - let listener = TcpListener::bind(("127.0.0.1", 0)).await?; - let local = listener.local_addr()?.port(); - let (release, ready) = oneshot::channel(); - let task = tokio::spawn(async move { - let connect = async { - let (adapter, _) = listener.accept().await?; - let jvm = TcpStream::connect((host.as_str(), port)).await?; - adapter.set_nodelay(true)?; - jvm.set_nodelay(true)?; - Ok::<_, io::Error>((adapter, jvm)) - }; - if let Ok(Ok((adapter, jvm))) = tokio::time::timeout(Duration::from_secs(60), connect).await { - let (mut ar, mut aw) = adapter.into_split(); - let (mut jr, mut jw) = jvm.into_split(); - let upstream = forward(&mut ar, &mut jw, ready); - let downstream = tokio::io::copy(&mut jr, &mut aw); - // Close both sockets as soon as either side disconnects. - tokio::select! { - r = upstream => if let Err(e) = r { tracing::debug!("Kotlin JDWP relay: {e}"); }, - _ = downstream => {}, - } - } - }); - Ok((Self { release: Some(release), task }, local)) - } - - pub fn release(&mut self) { - if let Some(ready) = self.release.take() { - let _ = ready.send(()); - } - } -} - -impl Drop for StartupRelay { - fn drop(&mut self) { - self.task.abort(); - } -} - -async fn packet(reader: &mut (impl AsyncRead + Unpin)) -> io::Result> { - let mut header = [0; 11]; - reader.read_exact(&mut header).await?; - let len = u32::from_be_bytes(header[..4].try_into().unwrap()) as usize; - if !(11..=16 * 1024 * 1024).contains(&len) { - return Err(io::Error::new(io::ErrorKind::InvalidData, "Invalid JDWP packet length")); - } - let mut packet = vec![0; len]; - packet[..11].copy_from_slice(&header); - reader.read_exact(&mut packet[11..]).await?; - Ok(packet) -} - -async fn forward( - reader: &mut (impl AsyncRead + Unpin), - writer: &mut (impl AsyncWrite + Unpin), - mut ready: oneshot::Receiver<()>, -) -> io::Result<()> { - let mut handshake = [0; 14]; - reader.read_exact(&mut handshake).await?; - if &handshake != b"JDWP-Handshake" { - return Err(io::Error::new(io::ErrorKind::InvalidData, "Invalid JDWP handshake")); - } - writer.write_all(&handshake).await?; - let mut released = false; - let mut held: Vec> = Vec::new(); - loop { - // Retain the in-flight read when attach completes, including partial packets. - let read = packet(reader); - tokio::pin!(read); - let packet = tokio::select! { - result = &mut read => result?, - result = &mut ready, if !released => { - result.map_err(|_| io::Error::new(io::ErrorKind::ConnectionAborted, "Kotlin attach cancelled"))?; - released = true; - for packet in held.drain(..) { - writer.write_all(&packet).await?; - } - read.await? - } - }; - // JDWP VirtualMachine.Resume (1/9), ThreadReference.Resume (11/3). - // Other commands must proceed: attach itself queries threads and installs requests. - if !released && packet[8] == 0 && matches!((packet[9], packet[10]), (1, 9) | (11, 3)) { - if held.len() >= 1024 { - return Err(io::Error::new(io::ErrorKind::InvalidData, "Too many pending JDWP resumes")); - } - held.push(packet); - } else { - writer.write_all(&packet).await?; - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn command(id: u8, set: u8, cmd: u8) -> Vec { - vec![0, 0, 0, 11, 0, 0, 0, id, 0, set, cmd] - } - - #[tokio::test] - async fn holds_resumes_but_allows_setup_and_preserves_partial_reads() { - let (mut adapter, mut reader) = tokio::io::duplex(4096); - let (mut writer, mut jvm) = tokio::io::duplex(4096); - let (release, ready) = oneshot::channel(); - let task = tokio::spawn(async move { forward(&mut reader, &mut writer, ready).await }); - adapter.write_all(b"JDWP-Handshake").await.unwrap(); - let mut handshake = [0; 14]; - jvm.read_exact(&mut handshake).await.unwrap(); - assert_eq!(&handshake, b"JDWP-Handshake"); - let resume = command(1, 1, 9); - let thread_resume = command(2, 11, 3); - let setup = command(3, 1, 4); - adapter.write_all(&resume).await.unwrap(); - adapter.write_all(&thread_resume).await.unwrap(); - adapter.write_all(&setup).await.unwrap(); - assert_eq!(packet(&mut jvm).await.unwrap(), setup); - let next = command(4, 1, 9); - adapter.write_all(&next[..6]).await.unwrap(); - release.send(()).unwrap(); - assert_eq!(packet(&mut jvm).await.unwrap(), resume); - assert_eq!(packet(&mut jvm).await.unwrap(), thread_resume); - adapter.write_all(&next[6..]).await.unwrap(); - assert_eq!(packet(&mut jvm).await.unwrap(), next); - drop(adapter); - assert!(task.await.unwrap().is_err()); - } - - #[tokio::test] - async fn dropping_session_closes_relay() { - let (relay, port) = StartupRelay::start("127.0.0.1".into(), 1).await.unwrap(); - let mut adapter = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); - drop(relay); - let result = tokio::time::timeout(Duration::from_secs(1), adapter.read_u8()).await.unwrap(); - assert!(result.is_err()); - } - - #[tokio::test] - async fn rejects_invalid_packet_lengths() { - for len in [0_u32, 10, 16 * 1024 * 1024 + 1] { - let mut bytes = command(1, 1, 9); - bytes[..4].copy_from_slice(&len.to_be_bytes()); - assert_eq!(packet(&mut bytes.as_slice()).await.unwrap_err().kind(), io::ErrorKind::InvalidData); - } - } -} diff --git a/crates/ide-dap/src/lib.rs b/crates/ide-dap/src/lib.rs index 0c9682f88..d94c7352b 100644 --- a/crates/ide-dap/src/lib.rs +++ b/crates/ide-dap/src/lib.rs @@ -13,8 +13,8 @@ pub mod attach; mod cargo; pub mod connection; pub mod jdwp; +mod jvm_memory; pub mod kotlin; -mod kotlin_jdwp; mod session; use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher}; @@ -22,6 +22,7 @@ use connection::{Connection, DapError}; use futures::future::join_all; use ide_core::{DebugSessionId, IdeError, IdeResult, SharedSink}; use ide_language::LanguageRegistry; +pub use jvm_memory::*; use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -126,6 +127,12 @@ pub struct DebugCapabilities { pub log_points: bool, /// HotSwap ("Reload Changed Classes"): java-debug's `redefineClasses` request. pub hot_code_replace: bool, + /// The session debugs a JVM, so it has a Memory view; [`DebugManager::jvm_memory_classes`] + /// says why when it has no data (platform, remote JVM, ...). + pub jvm_memory: bool, + /// The session has a JVM inspector: instances, fields and referrers while suspended. Can + /// change during the session ([`ide_core::event::DebugEvent::Capabilities`]). + pub jvm_instances: bool, } /// Result of [`DebugManager::redefine_classes`]. @@ -302,6 +309,7 @@ pub struct DebugManager { data_breakpoints: Mutex>, /// In-flight stops, by session: a second Stop sets `stop_force`, the waiting one kills. stopping: Mutex>>, + jvm_finder: Mutex, } /// rdbg (Ruby's debug gem) starts the debuggee itself and ignores `launch`'s program, @@ -566,9 +574,19 @@ pub(crate) fn capabilities_from(caps: &Value) -> DebugCapabilities { hit_conditional_breakpoints: has("supportsHitConditionalBreakpoints"), log_points: has("supportsLogPoints"), hot_code_replace: false, + jvm_memory: false, + jvm_instances: false, } } +pub(crate) fn session_capabilities(session: &Session) -> DebugCapabilities { + let mut caps = capabilities_from(&session.caps.lock()); + caps.hot_code_replace = HOT_CODE_REPLACE_ADAPTERS.contains(&session.adapter.as_str()); + caps.jvm_memory = JVM_MEMORY_ADAPTERS.contains(&session.adapter.as_str()); + caps.jvm_instances = session.jvm_inspector.lock().is_some(); + caps +} + /// States of a file's line breakpoints that no session has been sent. fn unsent(breakpoints: &[Breakpoint]) -> Vec { breakpoints @@ -657,9 +675,15 @@ impl DebugManager { function_breakpoints: Mutex::default(), data_breakpoints: Mutex::default(), stopping: Mutex::default(), + jvm_finder: Mutex::new(Arc::new(jvm_memory::find_local_jvm)), }) } + #[cfg(test)] + pub(crate) fn set_jvm_finder(&self, finder: jvm_memory::JvmFinder) { + *self.jvm_finder.lock() = finder; + } + fn session(&self, s: DebugSessionId) -> IdeResult> { self.sessions.lock().get(&s).cloned().ok_or_else(|| no_session(s)) } @@ -979,13 +1003,31 @@ impl DebugManager { .await?; *s.caps.lock() = if caps.is_object() { caps } else { json!({}) }; - // The Kotlin adapter ignores the resolved classpath and project runtime on launch. + // The Kotlin adapters ignore 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 }; + // Both blocks below run before the Kotlin link replaces the JVM's address with its own. + if request == "attach" + && let Some(target) = jvm_memory::attach_target(&arguments) + { + let _ = s.attach_target.set(target); + } + if cfg!(unix) + && s.request == "attach" + && JVM_MEMORY_ADAPTERS.contains(&s.adapter.as_str()) + && let Some(port) = jvm_memory::local_jdwp_port(&arguments) + { + if jdwp::is_listen_relay(port) { + s.listen_relay.store(true, Ordering::Release); + } else { + let finder = self.jvm_finder.lock().clone(); + jvm_memory::find_attached_jvm(s, port, finder).await; + } + } if s.adapter == kotlin::ADAPTER { let host = arguments .get("hostName") @@ -998,11 +1040,14 @@ impl DebugManager { .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); + let (link, port) = ide_jdwp::JdwpLink::start(host, port, kotlin::link_options()).await?; + *s.jvm_inspector.lock() = Some(Arc::new(ide_jdwp::LinkInspector::new(link.clone()))); + jvm_memory::watch_link(s, &link); + *s.jdwp_link.lock() = Some(link); arguments["hostName"] = json!("127.0.0.1"); arguments["port"] = json!(port); } + s.jvm_located.send_replace(true); // 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. @@ -1097,8 +1142,8 @@ impl DebugManager { reply.await.unwrap_or(Err(DapError::Closed))?; } } - if let Some(relay) = s.kotlin_relay.lock().as_mut() { - relay.release(); + if let Some(link) = s.jdwp_link.lock().as_ref() { + link.release(); } Ok(()) } @@ -1331,8 +1376,9 @@ impl DebugManager { } pub async fn threads(&self, s: DebugSessionId) -> IdeResult> { - let body = self.session(s)?.conn.request_timeout("threads", Value::Null, REQUEST_TIMEOUT).await?; - Ok(body + let session = self.session(s)?; + let body = session.conn.request_timeout("threads", Value::Null, REQUEST_TIMEOUT).await?; + let threads: Vec = body .get("threads") .and_then(Value::as_array) .map(|t| { @@ -1343,7 +1389,8 @@ impl DebugManager { }) .collect() }) - .unwrap_or_default()) + .unwrap_or_default(); + Ok(threads) } pub async fn stack_trace(&self, s: DebugSessionId, thread_id: i64) -> IdeResult> { @@ -1472,8 +1519,11 @@ impl DebugManager { pub async fn resume(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<()> { let session = self.session(s)?; + let ticket = session.suspended.ticket(); let body = session.conn.request_timeout("continue", json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?; + // DAP: a response without the flag continued every thread. let all = body.get("allThreadsContinued").and_then(Value::as_bool).unwrap_or(true); + session.suspended.resumed(ticket, thread_id, all); session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: all }); Ok(()) } @@ -1513,7 +1563,10 @@ impl DebugManager { StepKind::Out => "stepOut", }; let session = self.session(s)?; + let ticket = session.suspended.ticket(); session.conn.request_timeout(command, json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?; + // A step runs its thread; other threads stopped by their own events stay stopped. + session.suspended.resumed(ticket, thread_id, false); session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: true }); Ok(()) } @@ -1611,10 +1664,12 @@ impl DebugManager { /// 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)?; + let ticket = session.suspended.ticket(); session .conn .request_timeout("stepIn", json!({ "threadId": thread_id, "targetId": target_id }), REQUEST_TIMEOUT) .await?; + session.suspended.resumed(ticket, thread_id, false); session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: true }); Ok(()) } @@ -1648,10 +1703,54 @@ impl DebugManager { } pub fn capabilities(&self, s: DebugSessionId) -> IdeResult { + Ok(session_capabilities(&*self.session(s)?)) + } + + /// Class table of the session's JVM (`GC.class_histogram -all`, or counted by the debugger + /// while suspended where no histogram can be taken), with deltas against the session's + /// previous snapshot from the same source. + pub async fn jvm_memory_classes(&self, s: DebugSessionId) -> IdeResult { 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) + jvm_memory::session_classes(session).await + } + + /// Gives the session the backend for [`Self::jvm_memory_instances`] and the debugger's class + /// counts; the UI is told to reload the session's capabilities. + pub fn set_jvm_inspector( + &self, + s: DebugSessionId, + inspector: Arc, + ) -> IdeResult<()> { + let session = self.session(s)?; + if session.ended.is_cancelled() { + return Err(no_session(s)); + } + jvm_memory::set_inspector(&session, inspector); + Ok(()) + } + + /// Up to `limit` (clamped to `1..=MAX_INSTANCES`) instances of `class_name` while suspended. + pub async fn jvm_memory_instances( + &self, + s: DebugSessionId, + class_name: String, + limit: u32, + ) -> IdeResult { + jvm_memory::instances(&*self.session(s)?, &class_name, limit).await + } + + pub async fn jvm_memory_fields(&self, s: DebugSessionId, object: JvmObjectRef) -> IdeResult { + jvm_memory::fields(&*self.session(s)?, object).await + } + + /// Objects that point at `object`; `limit` 0 means `DEFAULT_REFERRERS`, at most `MAX_REFERRERS`. + pub async fn jvm_memory_referrers( + &self, + s: DebugSessionId, + object: JvmObjectRef, + limit: u32, + ) -> IdeResult { + jvm_memory::referrers(&*self.session(s)?, object, limit).await } /// HotSwap: reloads the classes compiled since the session started (or since the diff --git a/crates/ide-dap/src/session.rs b/crates/ide-dap/src/session.rs index e88d520ac..c4e0a752c 100644 --- a/crates/ide-dap/src/session.rs +++ b/crates/ide-dap/src/session.rs @@ -5,6 +5,8 @@ use crate::adapter::AdapterSpec; use crate::connection::{Connection, DapError, Incoming}; use crate::{Breakpoint, BreakpointState, DataBreakpoint, DebugManager, FunctionBreakpoint, SessionOwner}; use ide_core::event::DebugEvent; +#[cfg(unix)] +use ide_core::process::process_tree; use ide_core::{DebugSessionId, IdeEvent, TaskId}; use parking_lot::Mutex; use serde_json::{Value, json}; @@ -62,6 +64,15 @@ pub(crate) struct Session { pub stop_force: AtomicBool, /// `systemProcessId` from the adapter's `process` event: the program being debugged. pub debuggee_pid: AtomicI64, + /// The JVM an app-launched (Kotlin) session started, or the local JVM an attach session + /// found by its JDWP port. Separate from `debuggee_pid`, which decides how Stop signals the + /// program and is 0 for Kotlin. + pub jvm_pid: AtomicI64, + /// JDWP port of an attach to a local JVM, to look the JVM up again; unset for a remote one. + pub jdwp_port: std::sync::OnceLock, + /// The attach goes through the listen-mode relay of [`crate::jdwp::listen_for_jvm`]. + pub listen_relay: AtomicBool, + pub suspended: Suspension, /// Pids signalled for the current stop: the reported pid and everything it started (an /// adapter may report the launcher shell, whose child is the program). #[cfg_attr(not(unix), allow(dead_code))] @@ -75,8 +86,105 @@ pub(crate) struct Session { /// Resolved path → the path as the IDE knows it, for adapters given resolved paths. resolved_paths: Mutex>, progress: Mutex>, - pub kotlin_relay: Mutex>, + /// Relay between a Kotlin adapter and its JVM. Dropping it closes both sockets. + pub jdwp_link: parking_lot::Mutex>, + pub jvm_inspector: parking_lot::Mutex>>, + /// One inspector request per session at a time. + pub inspect_lock: tokio::sync::Mutex<()>, + /// Cancelled when the session ends, so a request still waiting on the inspector lets go of + /// it (and of the JDWP link it holds). + pub ended: ide_core::CancellationToken, + /// `hostName` (empty when absent) and `port` of the session's `attach` request, as the IDE + /// was given them: Kotlin then replaces them with the link's. Unlike `jdwp_port`, also set + /// for a remote JVM. + pub attach_target: std::sync::OnceLock<(String, u16)>, + /// Set once `configure` has recorded where the program's JVM is (the fields above, the + /// Kotlin link), just before the `launch`/`attach` request goes out. + pub jvm_located: watch::Sender, pub kotlin_mirror: Mutex>, + pub jvm_memory: tokio::sync::Mutex, +} + +/// Which threads of the program are suspended: each from its `stopped` event until a +/// `continued` event or a successful resume/step request for it. Never cleared elsewhere, since +/// a stale "suspended" only makes a later check refuse. fwcd and java-debug stop a breakpoint's +/// thread alone, so a thread can run while another stays stopped. +/// +/// A resume reply ends only the stops taken before the request was sent: a thread's next +/// `stopped` event can overtake the reply. The check and the removal happen under one lock, so +/// no stop can land between them and be lost. +#[derive(Default)] +pub(crate) struct Suspension(Mutex); + +#[derive(Default)] +struct Stops { + /// Counts stops, and moves back to an earlier stopped thread: an object read under one + /// ticket is stale under another. + ticket: u64, + /// Threads stopped alone by their event, oldest first, with the ticket of that stop. + threads: Vec<(i64, u64)>, + /// The ticket of a stop of every thread (`allThreadsStopped`, or no thread id): any resume + /// ends it. + all: Option, +} + +impl Stops { + fn latest(&self) -> Option { + self.threads.last().map(|&(_, ticket)| ticket).max(self.all) + } + + /// Ends the stops taken by ticket `upto` of `thread`, or of every thread. + fn release(&mut self, thread: Option, every: bool, upto: u64) { + let latest = self.latest(); + if every || thread.is_none() || self.all.is_some_and(|t| t <= upto) { + self.all = self.all.filter(|&t| t > upto); + self.threads.retain(|&(_, t)| t > upto); + } else { + self.threads.retain(|&(id, t)| Some(id) != thread || t > upto); + } + // Back on an earlier thread's stop, as the JDWP link moves to it with a new generation. + let now = self.latest(); + if now.is_some() && now != latest { + self.ticket += 1; + } + } +} + +impl Suspension { + /// Whether some thread is stopped. + pub fn get(&self) -> bool { + let stops = self.0.lock(); + stops.all.is_some() || !stops.threads.is_empty() + } + + /// A `stopped` event; `all`: its `allThreadsStopped`. + pub fn stopped(&self, thread: Option, all: bool) { + let mut stops = self.0.lock(); + stops.ticket += 1; + let ticket = stops.ticket; + match thread.filter(|_| !all) { + Some(thread) => { + stops.threads.retain(|&(id, _)| id != thread); + stops.threads.push((thread, ticket)); + } + None => stops.all = Some(ticket), + } + } + + /// A `continued` event; `all`: its `allThreadsContinued`. + pub fn continued(&self, thread: Option, all: bool) { + self.0.lock().release(thread, all, u64::MAX); + } + + /// Take before sending a resume-like request and pass to [`Self::resumed`]. + pub fn ticket(&self) -> u64 { + self.0.lock().ticket + } + + /// After a successful resume-like request for `thread`; `all`: every thread ran. + pub fn resumed(&self, ticket: u64, thread: i64, all: bool) { + self.0.lock().release(Some(thread), all, ticket); + } } /// Adapters that only match files by their resolved path: Godot maps a script to @@ -125,6 +233,10 @@ impl Session { terminated: AtomicBool::new(false), stop_force: AtomicBool::new(false), debuggee_pid: AtomicI64::new(0), + jvm_pid: AtomicI64::new(0), + jdwp_port: std::sync::OnceLock::new(), + listen_relay: AtomicBool::new(false), + suspended: Suspension::default(), stop_pids: parking_lot::Mutex::new(Vec::new()), #[cfg(windows)] stop_tree: Mutex::new(None), @@ -132,8 +244,14 @@ impl Session { breakpoint_ids: Mutex::new(HashMap::new()), resolved_paths: Mutex::new(HashMap::new()), progress: Mutex::new(HashMap::new()), - kotlin_relay: Mutex::new(None), + jdwp_link: parking_lot::Mutex::new(None), + jvm_inspector: parking_lot::Mutex::new(None), + inspect_lock: tokio::sync::Mutex::default(), + ended: ide_core::CancellationToken::new(), + attach_target: std::sync::OnceLock::new(), + jvm_located: watch::channel(false).0, kotlin_mirror: Mutex::new(None), + jvm_memory: tokio::sync::Mutex::default(), } } @@ -294,7 +412,10 @@ impl Session { /// Kills the adapter and every debuggee we started. pub fn kill_processes(&self) { - self.kotlin_relay.lock().take(); + self.ended.cancel(); + // The inspector holds a clone of the link, so both go. + self.jvm_inspector.lock().take(); + self.jdwp_link.lock().take(); #[cfg(unix)] if self.request == "launch" { for group in launch_groups(self) { @@ -351,32 +472,6 @@ pub(crate) async fn remember_debuggee(s: &Arc) { let _ = s; } -/// The program and everything it started: an adapter may report the launcher shell, whose -/// child is the debugged program, so stopping only the reported pid leaves the program alive. -#[cfg(unix)] -fn process_tree(pid: i32) -> Vec { - use std::collections::HashMap; - let Ok(out) = std::process::Command::new("ps").args(["-A", "-o", "pid=,ppid="]).output() else { - return vec![pid]; - }; - let mut children: HashMap> = HashMap::new(); - for line in String::from_utf8_lossy(&out.stdout).lines() { - let mut it = line.split_whitespace(); - let (Some(p), Some(parent)) = (it.next(), it.next()) else { continue }; - let (Ok(p), Ok(parent)) = (p.parse::(), parent.parse::()) else { continue }; - children.entry(parent).or_default().push(p); - } - let mut tree = vec![pid]; - let mut at = 0; - while at < tree.len() { - if let Some(kids) = children.get(&tree[at]) { - tree.extend(kids.iter().copied()); - } - at += 1; - } - tree -} - /// Asks the program itself to exit (`force` = SIGKILL) without ending the adapter session: /// its output keeps flowing over DAP while it shuts down. The first call remembers the pid /// tree, because the reported pid may die before its program does. @@ -600,18 +695,27 @@ async fn on_event(mgr: &Arc, s: &Arc, event: &str, mut bo s.emit(DebugEvent::Initialized); } "stopped" => { + let all = body.get("allThreadsStopped").and_then(Value::as_bool).unwrap_or(false); + s.suspended.stopped(int("threadId"), all); + let reason = str_("reason").unwrap_or_else(|| "pause".into()); mgr.restore_breakpoints(s).await; s.emit(DebugEvent::Stopped { thread_id: int("threadId").unwrap_or(0), - reason: str_("reason").unwrap_or_else(|| "pause".into()), + reason, description: str_("description").or_else(|| str_("text")), - all_threads_stopped: body.get("allThreadsStopped").and_then(Value::as_bool).unwrap_or(false), + all_threads_stopped: all, + }); + } + "continued" => { + let all = body.get("allThreadsContinued").and_then(Value::as_bool); + // DAP: an event without the flag continues its thread alone. The UI keeps treating + // it as all threads; the suspension, which holds only threads that stopped, need not. + s.suspended.continued(int("threadId"), all.unwrap_or(false)); + s.emit(DebugEvent::Continued { + thread_id: int("threadId").unwrap_or(0), + all_threads_continued: all.unwrap_or(true), }); } - "continued" => s.emit(DebugEvent::Continued { - thread_id: int("threadId").unwrap_or(0), - all_threads_continued: body.get("allThreadsContinued").and_then(Value::as_bool).unwrap_or(true), - }), "output" => { let category = str_("category").unwrap_or_else(|| "console".into()); if category == "telemetry" { @@ -917,6 +1021,19 @@ fn forward_output(s: &Session, mut stream }); } +/// The attach listener must be on the command line before the user's arguments, which may +/// override it; the memory view needs it because the JVM is started by the IDE, not attached to. +/// Not on Windows (`windows`), which the memory view does not support. +fn kotlin_jvm_args(user: Vec, windows: bool) -> Vec { + if windows || !ide_jvm_attach::wants_listener_flag(&user.join(" ")) { + return user; + } + let mut args = Vec::with_capacity(user.len() + 1); + args.push(ide_jvm_attach::START_LISTENER_FLAG.to_owned()); + args.extend(user); + args +} + /// Launch with the IDE's resolved runtime and classpath; the adapter only attaches. pub(crate) async fn start_kotlin_debuggee(s: &Arc, args: &mut Value) -> ide_core::IdeResult<()> { use tokio::io::{AsyncBufReadExt, BufReader}; @@ -931,7 +1048,7 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc, args: &mut Value) -> std::env::join_paths(strings("classPaths")).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?; let mut cmd = tokio::process::Command::new(java); ide_core::process::prepare(cmd.as_std_mut()); - cmd.args(strings("vmArguments")) + cmd.args(kotlin_jvm_args(strings("vmArguments"), cfg!(windows))) // Let the JVM pick its own port (`:0`) and report it below: pre-picking a free // port here races with other processes that may take it before the JVM binds. .arg("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:0") @@ -965,11 +1082,12 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc, args: &mut Value) -> // forced stop), the session's job on Windows, so a stop reaches what it forks. ide_core::process::own_group(cmd.as_std_mut()); let mut child = cmd.spawn()?; - #[cfg(windows)] - if let Some(pid) = child.id() - && let Some(job) = s.job.lock().as_ref() - { - job.assign_pid(pid); + if let Some(pid) = child.id() { + s.jvm_pid.store(i64::from(pid), Ordering::Release); + #[cfg(windows)] + if let Some(job) = s.job.lock().as_ref() { + job.assign_pid(pid); + } } if let Some(err) = child.stderr.take() { forward_output(s, err, "stderr"); @@ -1010,7 +1128,162 @@ fn parse_jdwp_port(line: &str) -> Option { #[cfg(test)] mod tests { - use super::parse_jdwp_port; + use super::{Suspension, kotlin_jvm_args, parse_jdwp_port}; + + #[test] + fn a_resume_reply_clears_the_stop_it_was_sent_for() { + let s = Suspension::default(); + assert!(!s.get()); + s.stopped(Some(1), true); + let ticket = s.ticket(); + s.resumed(ticket, 1, true); + assert!(!s.get()); + } + + #[test] + fn a_stop_after_the_resume_request_keeps_the_session_suspended() { + let s = Suspension::default(); + s.stopped(Some(1), true); + let ticket = s.ticket(); + // The next breakpoint's `stopped` event overtakes the reply to `continue`. + s.continued(Some(1), true); + s.stopped(Some(1), true); + s.resumed(ticket, 1, true); + assert!(s.get()); + // A reply for the new stop still clears it. + s.resumed(s.ticket(), 1, true); + assert!(!s.get()); + } + + #[test] + fn a_reply_after_a_continued_event_changes_nothing() { + let s = Suspension::default(); + s.stopped(Some(1), true); + let ticket = s.ticket(); + s.continued(Some(1), true); + s.resumed(ticket, 1, true); + assert!(!s.get()); + s.stopped(Some(1), true); + assert!(s.get()); + } + + #[test] + fn concurrent_stops_are_never_lost() { + let s = std::sync::Arc::new(Suspension::default()); + s.stopped(Some(1), true); + let ticket = s.ticket(); + let stopping = { + let s = s.clone(); + std::thread::spawn(move || (0..10_000).for_each(|_| s.stopped(Some(1), true))) + }; + for _ in 0..10_000 { + s.resumed(ticket, 1, true); + } + stopping.join().unwrap(); + // However they interleave, a resume for the old ticket never clears a later stop. + assert!(s.get()); + assert_eq!(s.ticket(), ticket + 10_000); + } + + /// fwcd and java-debug stop a breakpoint's thread alone (`SUSPEND_EVENT_THREAD`): 7, then 8. + fn two_threads_stopped() -> Suspension { + let s = Suspension::default(); + s.stopped(Some(7), false); + s.stopped(Some(8), false); + s + } + + #[test] + fn continuing_the_latest_stopped_thread_keeps_the_other_as_a_new_stop() { + let s = two_threads_stopped(); + let ticket = s.ticket(); + s.continued(Some(8), false); + assert!(s.get()); + // Objects read on thread 8's stop are stale. + assert_eq!(s.ticket(), ticket + 1); + s.continued(Some(7), false); + assert!(!s.get()); + } + + #[test] + fn continuing_an_earlier_stopped_thread_keeps_the_latest_stop() { + let s = two_threads_stopped(); + let ticket = s.ticket(); + s.continued(Some(7), false); + assert!(s.get()); + assert_eq!(s.ticket(), ticket); + } + + #[test] + fn continuing_all_threads_ends_every_stop() { + let s = two_threads_stopped(); + s.continued(Some(7), true); + assert!(!s.get()); + let s = two_threads_stopped(); + s.continued(None, false); + assert!(!s.get()); + } + + #[test] + fn continuing_a_thread_that_did_not_stop_changes_nothing() { + let s = two_threads_stopped(); + let ticket = s.ticket(); + s.continued(Some(5), false); + assert!(s.get()); + assert_eq!(s.ticket(), ticket); + } + + #[test] + fn a_step_reply_resumes_its_thread_alone() { + let s = two_threads_stopped(); + let ticket = s.ticket(); + s.resumed(ticket, 8, false); + assert!(s.get()); + assert_eq!(s.ticket(), ticket + 1); + s.resumed(s.ticket(), 7, true); + assert!(!s.get()); + } + + #[test] + fn a_thread_stopping_again_before_its_resume_reply_stays_stopped() { + let s = two_threads_stopped(); + let ticket = s.ticket(); + // Thread 7's step ends, and its `stopped` event overtakes the reply to `next`. + s.stopped(Some(7), false); + s.resumed(ticket, 7, false); + s.continued(Some(8), false); + assert!(s.get()); + } + + #[test] + fn a_stop_of_every_thread_ends_with_any_resume() { + let s = Suspension::default(); + s.stopped(Some(7), false); + s.stopped(Some(8), true); + s.continued(Some(8), false); + assert!(!s.get()); + // A stop without a thread. + s.stopped(None, false); + s.resumed(s.ticket(), 5, false); + assert!(!s.get()); + } + + #[test] + fn kotlin_launch_starts_attach_listener_first() { + assert_eq!( + kotlin_jvm_args(vec!["-Xmx1g".into()], false), + vec!["-XX:+StartAttachListener".to_string(), "-Xmx1g".into()] + ); + assert_eq!( + kotlin_jvm_args(vec!["-XX:+DisableAttachMechanism".into()], false), + vec!["-XX:+DisableAttachMechanism".to_string()] + ); + } + + #[test] + fn kotlin_launch_on_windows_gets_no_attach_listener() { + assert_eq!(kotlin_jvm_args(vec!["-Xmx1g".into()], true), vec!["-Xmx1g".to_string()]); + } #[test] fn reads_the_port_the_jvm_chose() { diff --git a/crates/ide-dap/src/tests.rs b/crates/ide-dap/src/tests.rs index d5884d4ab..c8779fbc7 100644 --- a/crates/ide-dap/src/tests.rs +++ b/crates/ide-dap/src/tests.rs @@ -1,5 +1,7 @@ //! Protocol flow against an in-memory fake adapter. +mod jvm_inspect; + use super::*; use crate::adapter::{AdapterIo, LaunchFuture}; use ide_core::IdeEvent; @@ -120,6 +122,14 @@ struct Script { supports_terminate: bool, /// Answer `setBreakpoints` only once this many are waiting (a client that waits for each never gets one). hold_set_breakpoints: usize, + /// Send a `continued` event right after the first `stopped` one. + continued_after_stop: bool, + /// Close the connection when a `pause` request arrives (the adapter dies). + hang_up_on_pause: bool, + /// Answer `continue` with this body and keep the program running, instead of letting it exit. + continue_reply: Option, + /// Answer `initialize` only after this long (a slow adapter keeps the session connecting). + delay_initialize: Option, initialized_delay: Duration, launch_delay: Duration, /// Leave startup pending before `initialized` (false) or after configuration (true). @@ -144,6 +154,10 @@ impl Default for Script { output_after_terminate: false, supports_terminate: true, hold_set_breakpoints: 0, + continued_after_stop: false, + hang_up_on_pause: false, + continue_reply: None, + delay_initialize: None, initialized_delay: Duration::ZERO, launch_delay: Duration::ZERO, pending_startup: None, @@ -210,6 +224,9 @@ async fn fake_adapter(stream } match msg["command"].as_str().unwrap_or_default() { "initialize" => { + if let Some(delay) = script.delay_initialize { + tokio::time::sleep(delay).await; + } ok!( msg, json!({ @@ -341,6 +358,9 @@ async fn fake_adapter(stream send!( json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 1, "allThreadsStopped": true}}) ); + if script.continued_after_stop { + send!(json!({"type": "event", "event": "continued", "body": {"threadId": 1}})); + } send!( json!({"type": "event", "event": "breakpoint", "body": {"reason": "changed", "breakpoint": {"id": 1, "verified": false, "message": "moved", "line": 9, "source": {"path": script.source_path}}}}) ); @@ -372,11 +392,13 @@ async fn fake_adapter(stream {"label": ""}, ]}) ), + "continue" if script.continue_reply.is_some() => ok!(msg, script.continue_reply.clone().unwrap()), "continue" => { ok!(msg, json!({"allThreadsContinued": true})); send!(json!({"type": "event", "event": "exited", "body": {"exitCode": 3}})); send!(json!({"type": "event", "event": "terminated"})); } + "pause" if script.hang_up_on_pause => break, "stepIn" | "stepOut" | "pause" => ok!(msg, Value::Null), "redefineClasses" => { send!( @@ -430,6 +452,8 @@ fn harness(script: Script) -> Harness { Arc::new(LanguageRegistry::from_configs(Vec::new())), Arc::new(FakeLauncher { log: log.clone(), script }), ); + // A local attach must not send requests to the developer's own JVMs. + manager.set_jvm_finder(Arc::new(|_, _| None)); Harness { manager, log, events, owner } } @@ -1352,6 +1376,36 @@ async fn hot_swap_only_for_java_debug() { assert_eq!(failed.error_message.as_deref(), Some("boom")); } +/// A loopback port nothing listens on until `listen` is called on the socket, and that no parallel +/// test can bind meanwhile. A port picked by binding to 0 and closing the listener can be taken +/// by another test before the code under test connects to it. +fn reserved_port() -> (tokio::net::TcpSocket, u16) { + // Without SO_REUSEADDR, which every `TcpListener::bind` sets: no other socket may bind it. + let socket = tokio::net::TcpSocket::new_v4().unwrap(); + socket.bind(([127, 0, 0, 1], 0).into()).unwrap(); + let port = socket.local_addr().unwrap().port(); + (socket, port) +} + +/// An editor that starts listening on the reserved port after `delay` and serves one session. +#[cfg(unix)] +fn fake_editor(socket: tokio::net::TcpSocket, delay: Duration, log: Log) { + tokio::spawn(async move { + tokio::time::sleep(delay).await; + let listener = socket.listen(1).unwrap(); + let (stream, _) = listener.accept().await.unwrap(); + // Closed once its session is accepted, so a later start finds no editor open. + drop(listener); + fake_adapter(stream, log, Script::default()).await; + }); +} + +#[tokio::test] +async fn a_reserved_port_cannot_be_taken_by_a_parallel_test() { + let (_socket, port) = reserved_port(); + assert!(tokio::net::TcpListener::bind(("127.0.0.1", port)).await.is_err(), "port {port} is still free to bind"); +} + fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc { let lang = ide_language::LanguageConfig { id: ide_core::LanguageId::new("gdscript"), @@ -1419,7 +1473,7 @@ async fn godot_connects_to_the_editor_adapter() { #[tokio::test] async fn godot_without_editor_or_executable_says_what_to_do() { - let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port(); + let (_no_editor, port) = reserved_port(); let (owner, _events) = recording_owner("main"); let manager = DebugManager::new(godot_registry(port, "openidea-no-such-godot", &[])); let err = manager.start(godot_launch(&std::env::temp_dir()), owner).await.unwrap_err(); @@ -1432,15 +1486,9 @@ async fn godot_without_editor_or_executable_says_what_to_do() { #[cfg(unix)] #[tokio::test] async fn godot_starts_a_headless_editor_when_none_is_open() { - let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port(); + let (editor, port) = reserved_port(); let log: Log = Arc::default(); - let server_log = log.clone(); - tokio::spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis(300)).await; - let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await.unwrap(); - let (stream, _) = listener.accept().await.unwrap(); - fake_adapter(stream, server_log, Script::default()).await; - }); + fake_editor(editor, Duration::from_millis(300), log.clone()); let (owner, _events) = recording_owner("main"); // `sleep` stands in for `godot --headless --editor --path ${root} …`. let manager = DebugManager::new(godot_registry(port, "sleep", &["30"])); @@ -1643,7 +1691,7 @@ async fn a_plugin_language_runs_its_debug_adapter_from_the_approved_program() { std::fs::write(&program, "#!/bin/sh\nexec sleep 30\n").unwrap(); std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap(); - let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port(); + let (editor, port) = reserved_port(); let mut gdscript = parse_definition(include_str!("../../../languages/gdscript.toml")).unwrap(); let debugger = gdscript.debugger.as_mut().unwrap(); debugger.args.clear(); @@ -1660,13 +1708,7 @@ async fn a_plugin_language_runs_its_debug_adapter_from_the_approved_program() { let _language = store.add("acme.godot".into(), gdscript); let log: Log = Arc::default(); - let server_log = log.clone(); - tokio::spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis(300)).await; - let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await.unwrap(); - let (stream, _) = listener.accept().await.unwrap(); - fake_adapter(stream, server_log, Script::default()).await; - }); + fake_editor(editor, Duration::from_millis(300), log.clone()); let (owner, _events) = recording_owner("main"); let manager = DebugManager::new(registry.clone()); let id = manager.start(godot_launch(&dir), owner).await.unwrap(); @@ -2018,6 +2060,416 @@ fn delve_leftovers_are_removed_without_touching_other_files() { std::fs::remove_dir_all(&dir).unwrap(); } +fn is_suspended(h: &Harness, id: DebugSessionId) -> bool { + h.manager.session(id).unwrap().suspended.get() +} + +#[tokio::test] +async fn stopped_event_marks_session_suspended() { + let h = harness(Script::default()); + let id = h.start(config()).await.unwrap(); + h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await; + assert!(is_suspended(&h, id)); +} + +#[tokio::test] +async fn resume_clears_suspended_without_continued_event() { + let h = harness(Script::default()); + let id = h.start(config()).await.unwrap(); + h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await; + // The fake adapter ends the session after `continue`, so keep the session alive here. + let session = h.manager.session(id).unwrap(); + assert!(session.suspended.get()); + h.manager.resume(id, 1).await.unwrap(); + assert!(!session.suspended.get()); +} + +/// After the fake adapter's stop of every thread ends, threads 7 then 8 stop alone, as a +/// breakpoint does on fwcd and java-debug (`SUSPEND_EVENT_THREAD`). +async fn threads_stopped(script: Script) -> (Harness, Arc, DebugSessionId) { + let h = harness(script); + let id = h.start(config()).await.unwrap(); + h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await; + let session = h.manager.session(id).unwrap(); + session.suspended.continued(Some(1), true); + session.suspended.stopped(Some(7), false); + session.suspended.stopped(Some(8), false); + (h, session, id) +} + +#[tokio::test] +async fn resume_of_one_thread_keeps_the_others_suspended() { + let only_it = Script { continue_reply: Some(json!({"allThreadsContinued": false})), ..Script::default() }; + let (h, session, id) = threads_stopped(only_it).await; + h.manager.resume(id, 7).await.unwrap(); + assert!(session.suspended.get()); + h.manager.resume(id, 8).await.unwrap(); + assert!(!session.suspended.get()); +} + +#[tokio::test] +async fn resume_reply_without_the_flag_continues_every_thread() { + let (h, session, id) = threads_stopped(Script { continue_reply: Some(json!({})), ..Script::default() }).await; + h.manager.resume(id, 7).await.unwrap(); + assert!(!session.suspended.get()); +} + +#[tokio::test] +async fn step_of_one_thread_keeps_the_others_suspended() { + let (h, session, id) = threads_stopped(Script::default()).await; + let ticket = session.suspended.ticket(); + h.manager.step(id, 8, StepKind::Into).await.unwrap(); + assert!(session.suspended.get()); + // Thread 8 was the latest stop: what was read on it is stale. + assert_eq!(session.suspended.ticket(), ticket + 1); + h.manager.step_in_target(id, 7, 1).await.unwrap(); + assert!(!session.suspended.get()); +} + +#[tokio::test] +async fn continued_event_clears_suspended() { + let h = harness(Script { continued_after_stop: true, ..Script::default() }); + let id = h.start(config()).await.unwrap(); + h.wait_event(|e| matches!(e, DebugEvent::Continued { .. })).await; + assert!(!is_suspended(&h, id)); +} + +fn java_config(request: &str) -> LaunchConfig { + LaunchConfig { adapter: "java-debug".into(), request: request.into(), extra_json: None, ..config() } +} + +fn remote_attach(adapter: &str, project: &std::path::Path) -> LaunchConfig { + LaunchConfig { + adapter: adapter.into(), + cwd: Some(project.to_owned().into()), + extra_json: Some(json!({"projectRoot": project, "hostName": "10.0.0.5", "port": 5005}).to_string()), + ..java_config("attach") + } +} + +#[tokio::test] +async fn attach_session_reports_capability() { + let h = harness(Script::default()); + let project = tempfile::tempdir().unwrap(); + for adapter in ["java-debug", kotlin::ADAPTER] { + let id = h.start(remote_attach(adapter, project.path())).await.unwrap(); + let caps = h.manager.capabilities(id).unwrap(); + assert!(caps.jvm_memory, "{adapter}"); + } + + let id = h.start(config()).await.unwrap(); + let caps = h.manager.capabilities(id).unwrap(); + assert!(!caps.jvm_memory); + assert_eq!( + h.manager.jvm_memory_classes(id).await.unwrap(), + JvmMemoryResult::Unavailable { reason: JvmMemoryUnavailable::NoJvmProcess, can_count_when_paused: false } + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn ended_attach_session_without_pid_reports_program_exited() { + let h = harness(Script::default()); + let project = tempfile::tempdir().unwrap(); + let id = h.start(remote_attach("java-debug", project.path())).await.unwrap(); + h.manager.session(id).unwrap().terminated.store(true, Ordering::Release); + assert_eq!( + h.manager.jvm_memory_classes(id).await.unwrap(), + JvmMemoryResult::Unavailable { reason: JvmMemoryUnavailable::ProgramExited, can_count_when_paused: false } + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn listen_mode_attach_is_not_scanned() { + let h = harness(Script::default()); + let scans = Arc::new(Mutex::new(Vec::new())); + let counted = scans.clone(); + h.manager.set_jvm_finder(Arc::new(move |port, _| { + counted.lock().push(port); + None + })); + let (_jvm, relay) = jdwp::tests::connected_jvm().await; + let mut cfg = java_config("attach"); + cfg.extra_json = Some(json!({"hostName": "127.0.0.1", "port": relay}).to_string()); + let id = h.start(cfg).await.unwrap(); + assert_eq!( + h.manager.jvm_memory_classes(id).await.unwrap(), + JvmMemoryResult::Unavailable { + reason: JvmMemoryUnavailable::ListenModeNotSupported, + can_count_when_paused: false + } + ); + tokio::time::sleep(Duration::from_millis(50)).await; + assert!(scans.lock().is_empty()); +} + +#[cfg(unix)] +#[tokio::test] +async fn remote_host_is_not_scanned() { + let h = harness(Script::default()); + let project = tempfile::tempdir().unwrap(); + let id = h.start(remote_attach("java-debug", project.path())).await.unwrap(); + let session = h.manager.session(id).unwrap(); + assert_eq!(session.jdwp_port.get(), None); + assert_eq!( + h.manager.jvm_memory_classes(id).await.unwrap(), + JvmMemoryResult::Unavailable { + reason: JvmMemoryUnavailable::AttachedJvmNotFound, + can_count_when_paused: false + } + ); + assert_eq!(session.jvm_pid.load(Ordering::Acquire), 0); +} + +/// The Memory view asks as soon as the session appears, while its JVM is still being looked up: +/// answering then would claim there is no JVM to ask. +#[cfg(unix)] +#[tokio::test] +async fn memory_asked_while_an_attach_connects_waits_for_its_jvm() { + let h = harness(Script { delay_initialize: Some(Duration::from_millis(300)), ..Script::default() }); + let project = tempfile::tempdir().unwrap(); + let (manager, owner) = (h.manager.clone(), h.owner.clone()); + let config = remote_attach("java-debug", project.path()); + let start = tokio::spawn(async move { manager.start(config, owner).await }); + h.wait_event(|e| matches!(e, DebugEvent::Started { .. })).await; + let id = DebugSessionId(*event_sessions(&h.events).first().unwrap()); + let session = h.manager.session(id).unwrap(); + assert!(session.attach_target.get().is_none(), "asked too late to cover the connecting session"); + + h.manager.jvm_memory_classes(id).await.unwrap(); + assert!(session.attach_target.get().is_some(), "answered before the attach was looked at"); + start.await.unwrap().unwrap(); +} + +/// A JVM clears its JDWP listener address once a debugger connects, so only a scan made before +/// the `attach` request can tell it by its port. +#[cfg(unix)] +#[tokio::test] +async fn local_attach_finds_its_jvm_before_the_debugger_connects() { + let h = harness(Script::default()); + let log = h.log.clone(); + h.manager.set_jvm_finder(Arc::new(move |port, _| { + std::thread::sleep(Duration::from_millis(50)); + let attached = log.lock().iter().any(|m| m["command"] == "attach"); + (port == 5005 && !attached).then_some(4242) + })); + let mut cfg = java_config("attach"); + cfg.extra_json = Some(json!({"hostName": "127.0.0.1", "port": 5005}).to_string()); + let id = h.start(cfg).await.unwrap(); + let session = h.manager.session(id).unwrap(); + assert_eq!(session.jvm_pid.load(Ordering::Acquire), 4242); +} + +#[cfg(unix)] +#[tokio::test] +async fn local_attach_remembers_its_jdwp_port() { + let h = harness(Script::default()); + let ports = Arc::new(Mutex::new(Vec::new())); + let asked = ports.clone(); + h.manager.set_jvm_finder(Arc::new(move |port, _| { + asked.lock().push(port); + Some(4242) + })); + let mut cfg = java_config("attach"); + cfg.extra_json = Some(json!({"hostName": "localhost", "port": "5005"}).to_string()); + let id = h.start(cfg).await.unwrap(); + let session = h.manager.session(id).unwrap(); + assert_eq!(session.jdwp_port.get(), Some(&5005)); + assert_eq!(h.request("attach")["arguments"]["port"], "5005"); + for _ in 0..200 { + if session.jvm_pid.load(Ordering::Acquire) != 0 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(session.jvm_pid.load(Ordering::Acquire), 4242); + assert_eq!(ports.lock().first(), Some(&5005)); +} + +/// A fake `find_jvm_by_jdwp_port` for the JVM 4242, recording whether each scan ran while +/// suspended. A JVM not `safe_while_suspended` (JDK below 17) is skipped by a suspended scan. +#[cfg(unix)] +fn counting_find( + safe_while_suspended: bool, +) -> (Arc>>, impl Fn(bool) -> Option + Clone + Send + 'static) { + let scans = Arc::new(Mutex::new(Vec::new())); + let counted = scans.clone(); + (scans, move |suspended| { + counted.lock().push(suspended); + (!suspended || safe_while_suspended).then_some(4242) + }) +} + +#[cfg(unix)] +async fn suspended_remote_attach(h: &Harness) -> Arc { + let project = tempfile::tempdir().unwrap(); + let id = h.start(remote_attach("java-debug", project.path())).await.unwrap(); + h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await; + let session = h.manager.session(id).unwrap(); + assert!(session.suspended.get()); + session +} + +#[cfg(unix)] +#[tokio::test] +async fn background_scan_skips_an_old_jdk_while_suspended() { + let h = harness(Script::default()); + let session = suspended_remote_attach(&h).await; + let (scans, find) = counting_find(false); + let task = + jvm_memory::watch_for_attached_jvm(&session, 5005, find, Duration::from_millis(5), Duration::from_secs(5)); + tokio::time::sleep(Duration::from_millis(100)).await; + assert!(!scans.lock().is_empty() && scans.lock().iter().all(|&suspended| suspended)); + assert_eq!(session.jvm_pid.load(Ordering::Acquire), 0); + + session.suspended.continued(None, true); + tokio::time::timeout(Duration::from_secs(2), task).await.unwrap().unwrap(); + assert_eq!(scans.lock().last(), Some(&false)); + assert_eq!(session.jvm_pid.load(Ordering::Acquire), 4242); +} + +#[cfg(unix)] +#[tokio::test] +async fn background_scan_finds_a_jdk_17_or_later_while_suspended() { + let h = harness(Script::default()); + let session = suspended_remote_attach(&h).await; + let (scans, find) = counting_find(true); + let task = + jvm_memory::watch_for_attached_jvm(&session, 5005, find, Duration::from_millis(5), Duration::from_secs(5)); + tokio::time::timeout(Duration::from_secs(2), task).await.unwrap().unwrap(); + assert_eq!(*scans.lock(), [true]); + assert_eq!(session.jvm_pid.load(Ordering::Acquire), 4242); +} + +#[cfg(unix)] +#[tokio::test] +async fn background_scan_keeps_a_known_pid_and_stops_with_the_session() { + let h = harness(Script::default()); + let project = tempfile::tempdir().unwrap(); + let id = h.start(remote_attach("java-debug", project.path())).await.unwrap(); + h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await; + let session = h.manager.session(id).unwrap(); + session.suspended.continued(None, true); + session.jvm_pid.store(7, Ordering::Release); + let watch = |find| { + jvm_memory::watch_for_attached_jvm(&session, 5005, find, Duration::from_millis(5), Duration::from_secs(5)) + }; + let (scans, find) = counting_find(true); + tokio::time::timeout(Duration::from_secs(2), watch(find)).await.unwrap().unwrap(); + assert!(scans.lock().is_empty(), "a known pid needs no scan"); + assert_eq!(session.jvm_pid.load(Ordering::Acquire), 7); + + // A refresh stores the pid while this scan is still running. + session.jvm_pid.store(0, Ordering::Release); + let racing = session.clone(); + let find = move |_| { + racing.jvm_pid.store(7, Ordering::Release); + Some(4242) + }; + let task = + jvm_memory::watch_for_attached_jvm(&session, 5005, find, Duration::from_millis(5), Duration::from_secs(5)); + tokio::time::timeout(Duration::from_secs(2), task).await.unwrap().unwrap(); + assert_eq!(session.jvm_pid.load(Ordering::Acquire), 7, "a pid stored first is kept"); + + h.manager.stop(id).await.unwrap(); + let (scans, find) = counting_find(true); + tokio::time::timeout(Duration::from_secs(2), watch(find)).await.unwrap().unwrap(); + assert!(scans.lock().is_empty()); +} + +#[cfg(unix)] +#[tokio::test] +async fn unknown_pid_reports_no_jvm_process() { + let h = harness(Script::default()); + let id = h.start(java_config("launch")).await.unwrap(); + assert!(h.manager.capabilities(id).unwrap().jvm_memory); + assert_eq!( + h.manager.jvm_memory_classes(id).await.unwrap(), + JvmMemoryResult::Unavailable { reason: JvmMemoryUnavailable::NoJvmProcess, can_count_when_paused: false } + ); +} + +fn kotlin_attach(project: &std::path::Path, host: &str, port: u16) -> LaunchConfig { + LaunchConfig { + adapter: kotlin::ADAPTER.into(), + cwd: Some(project.to_owned().into()), + extra_json: Some(json!({"projectRoot": project, "hostName": host, "port": port}).to_string()), + ..java_config("attach") + } +} + +#[tokio::test] +async fn kotlin_session_installs_link_inspector() { + let h = harness(Script::default()); + let project = tempfile::tempdir().unwrap(); + let id = h.start(kotlin_attach(project.path(), "127.0.0.1", 5005)).await.unwrap(); + let session = h.manager.session(id).unwrap(); + assert!(session.jdwp_link.lock().is_some()); + assert!(session.jvm_inspector.lock().is_some()); +} + +#[tokio::test] +async fn attach_target_recorded_before_kotlin_rewrite() { + let h = harness(Script::default()); + let project = tempfile::tempdir().unwrap(); + let id = h.start(kotlin_attach(project.path(), "10.0.0.5", 5005)).await.unwrap(); + let session = h.manager.session(id).unwrap(); + assert_eq!(session.attach_target.get(), Some(&("10.0.0.5".to_owned(), 5005))); + let sent = &h.request("attach")["arguments"]; + assert_eq!(sent["hostName"], "127.0.0.1"); + let link_port = sent["port"].as_u64().unwrap(); + assert!(link_port != 0 && link_port != 5005); +} + +#[tokio::test] +async fn java_attach_records_target() { + let h = harness(Script::default()); + let mut cfg = java_config("attach"); + cfg.extra_json = Some(json!({"hostName": "localhost", "port": 5005}).to_string()); + let id = h.start(cfg).await.unwrap(); + let session = h.manager.session(id).unwrap(); + assert_eq!(session.attach_target.get(), Some(&("localhost".to_owned(), 5005))); + assert!(session.jdwp_link.lock().is_none()); + assert!(session.jvm_inspector.lock().is_none()); +} + +#[tokio::test] +async fn attach_target_recorded_for_any_adapter() { + let h = harness(Script::default()); + let mut cfg = java_config("attach"); + // "x" is not a registered adapter; debugpy has no JVM link or memory support. + cfg.adapter = "debugpy".into(); + cfg.extra_json = Some(json!({"hostName": "h", "port": 7}).to_string()); + let id = h.start(cfg).await.unwrap(); + assert_eq!(h.manager.session(id).unwrap().attach_target.get(), Some(&("h".to_owned(), 7))); +} + +#[tokio::test] +async fn launch_sessions_have_no_attach_target() { + let h = harness(Script::default()); + let id = h.start(java_config("launch")).await.unwrap(); + assert_eq!(h.manager.session(id).unwrap().attach_target.get(), None); +} + +#[tokio::test] +async fn closing_the_session_drops_the_link() { + let h = harness(Script { hang_up_on_pause: true, ..Script::default() }); + let project = tempfile::tempdir().unwrap(); + let id = h.start(kotlin_attach(project.path(), "127.0.0.1", 5005)).await.unwrap(); + let session = h.manager.session(id).unwrap(); + assert!(session.jdwp_link.lock().is_some() && session.jvm_inspector.lock().is_some()); + let _ = session.conn.request_timeout("pause", json!({"threadId": 1}), Duration::from_secs(2)).await; + for _ in 0..200 { + if h.manager.session(id).is_err() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(session.jdwp_link.lock().is_none()); + assert!(session.jvm_inspector.lock().is_none()); +} + #[cfg(unix)] #[tokio::test] async fn rdbg_drains_test_output_after_dap_closes_before_killing_the_process() { diff --git a/crates/ide-dap/src/tests/jvm_inspect.rs b/crates/ide-dap/src/tests/jvm_inspect.rs new file mode 100644 index 000000000..3802feeee --- /dev/null +++ b/crates/ide-dap/src/tests/jvm_inspect.rs @@ -0,0 +1,401 @@ +//! Instances, fields and referrers through a session's JVM inspector. + +use super::*; +use crate::jvm_memory::test_support::FakeInspector; +use ide_core::event::JvmBackendState; +use ide_jvm_inspect::{InspectError, JvmValue, VmTarget}; +use tokio::io::AsyncReadExt; + +fn debug_events(h: &Harness) -> Vec { + h.events + .lock() + .iter() + .filter_map(|e| match e { + IdeEvent::Debug { event, .. } => Some(event.clone()), + _ => None, + }) + .collect() +} + +fn backend_events(h: &Harness) -> Vec { + debug_events(h) + .into_iter() + .filter_map(|e| match e { + DebugEvent::JvmBackend { state } => Some(state), + _ => None, + }) + .collect() +} + +/// A java-debug session stopped at its breakpoint, inspected by `fake`. +async fn stopped_with(h: &Harness, config: LaunchConfig, fake: &Arc) -> DebugSessionId { + let id = h.start(config).await.unwrap(); + h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await; + h.manager.set_jvm_inspector(id, fake.clone()).unwrap(); + id +} + +fn object(id: u64) -> JvmValue { + JvmValue::Object { id, type_name: "A".into() } +} + +const NOT_SUSPENDED: JvmInstancesResult = + JvmInstancesResult::Unavailable { reason: JvmInspectUnavailable::NotSuspended }; + +#[tokio::test] +async fn instances_need_a_suspended_session() { + let h = harness(Script { continued_after_stop: true, ..Script::default() }); + let id = h.start(java_config("launch")).await.unwrap(); + h.wait_event(|e| matches!(e, DebugEvent::Continued { .. })).await; + let fake = Arc::new(FakeInspector::default()); + h.manager.set_jvm_inspector(id, fake.clone()).unwrap(); + assert_eq!(h.manager.jvm_memory_instances(id, "A".into(), 10).await.unwrap(), NOT_SUSPENDED); + assert_eq!(fake.calls(), 0); +} + +#[tokio::test] +async fn instances_without_an_inspector_have_no_backend() { + let h = harness(Script::default()); + let id = h.start(java_config("launch")).await.unwrap(); + h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await; + assert_eq!( + h.manager.jvm_memory_instances(id, "A".into(), 10).await.unwrap(), + JvmInstancesResult::Unavailable { reason: JvmInspectUnavailable::NoBackend } + ); +} + +#[tokio::test] +async fn object_from_previous_stop_is_stale() { + let h = harness(Script::default()); + let fake = Arc::new(FakeInspector::default()); + fake.instances.lock().push(object(7)); + let id = stopped_with(&h, java_config("launch"), &fake).await; + let JvmInstancesResult::Ready { generation, instances, truncated } = + h.manager.jvm_memory_instances(id, "A".into(), 10).await.unwrap() + else { + panic!("instances unavailable"); + }; + assert_eq!((generation, instances, truncated), (1, vec![object(7)], false)); + + h.manager.session(id).unwrap().suspended.stopped(None, true); + let stale = JvmObjectRef { generation, id: 7 }; + assert_eq!( + h.manager.jvm_memory_fields(id, stale).await.unwrap(), + JvmFieldsResult::Unavailable { reason: JvmInspectUnavailable::Stale } + ); + assert_eq!( + h.manager.jvm_memory_referrers(id, stale, 10).await.unwrap(), + JvmReferrersResult::Unavailable { reason: JvmInspectUnavailable::Stale } + ); + assert_eq!(fake.calls(), 1, "a stale object never reaches the inspector"); + let current = JvmObjectRef { generation: 2, id: 7 }; + assert_eq!(h.manager.jvm_memory_fields(id, current).await.unwrap(), JvmFieldsResult::Ready { fields: Vec::new() }); +} + +#[tokio::test] +async fn instances_stay_readable_while_another_thread_is_stopped() { + let h = harness(Script::default()); + let fake = Arc::new(FakeInspector::default()); + fake.instances.lock().push(object(7)); + let id = stopped_with(&h, java_config("launch"), &fake).await; + let session = h.manager.session(id).unwrap(); + // Threads 20, 21, then 22 stop alone (SUSPEND_EVENT_THREAD). + session.suspended.continued(Some(1), true); + for thread in [20, 21, 22] { + session.suspended.stopped(Some(thread), false); + } + let instances = || h.manager.jvm_memory_instances(id, "A".into(), 10); + let result = instances().await.unwrap(); + let JvmInstancesResult::Ready { generation, .. } = result else { panic!("{result:?}") }; + let read_on_22 = JvmObjectRef { generation, id: 7 }; + + // An earlier thread resumes: the stop on 22 goes on, and so does what was read on it. + session.suspended.continued(Some(20), false); + assert_eq!( + h.manager.jvm_memory_fields(id, read_on_22).await.unwrap(), + JvmFieldsResult::Ready { fields: Vec::new() } + ); + + // The latest one resumes: 21 is still stopped, but what was read on 22 is stale. + session.suspended.continued(Some(22), false); + assert_eq!( + h.manager.jvm_memory_fields(id, read_on_22).await.unwrap(), + JvmFieldsResult::Unavailable { reason: JvmInspectUnavailable::Stale } + ); + let result = instances().await.unwrap(); + assert!(matches!(result, JvmInstancesResult::Ready { .. }), "{result:?}"); +} + +/// Starts `instances` on a held fake inspector; returns once the inspector was entered. +async fn held_request( + h: &Harness, + id: DebugSessionId, + fake: &Arc, +) -> tokio::task::JoinHandle> { + let manager = h.manager.clone(); + let request = tokio::spawn(async move { manager.jvm_memory_instances(id, "A".into(), 10).await }); + tokio::time::timeout(Duration::from_secs(2), fake.entered.notified()).await.unwrap(); + request +} + +#[tokio::test] +async fn stop_during_request_is_stale() { + let h = harness(Script::default()); + let fake = Arc::new(FakeInspector { hold: true, ..FakeInspector::default() }); + fake.instances.lock().push(object(7)); + let id = stopped_with(&h, java_config("launch"), &fake).await; + let request = held_request(&h, id, &fake).await; + h.manager.session(id).unwrap().suspended.stopped(None, true); + fake.release.notify_one(); + assert_eq!( + request.await.unwrap().unwrap(), + JvmInstancesResult::Unavailable { reason: JvmInspectUnavailable::Stale } + ); +} + +#[tokio::test] +async fn resume_during_request_is_stale() { + let h = harness(Script::default()); + let fake = Arc::new(FakeInspector { hold: true, ..FakeInspector::default() }); + let id = stopped_with(&h, java_config("launch"), &fake).await; + let request = held_request(&h, id, &fake).await; + h.manager.session(id).unwrap().suspended.continued(None, true); + fake.release.notify_one(); + assert_eq!( + request.await.unwrap().unwrap(), + JvmInstancesResult::Unavailable { reason: JvmInspectUnavailable::Stale } + ); +} + +#[tokio::test] +async fn one_request_at_a_time_per_session() { + let h = harness(Script::default()); + let fake = Arc::new(FakeInspector { hold: true, ..FakeInspector::default() }); + let id = stopped_with(&h, java_config("launch"), &fake).await; + let first = held_request(&h, id, &fake).await; + let manager = h.manager.clone(); + let second = tokio::spawn(async move { manager.jvm_memory_instances(id, "B".into(), 10).await }); + tokio::time::sleep(Duration::from_millis(50)).await; + assert_eq!(fake.calls(), 1, "the second request waits for the first"); + fake.release.notify_one(); + first.await.unwrap().unwrap(); + tokio::time::timeout(Duration::from_secs(2), fake.entered.notified()).await.unwrap(); + fake.release.notify_one(); + second.await.unwrap().unwrap(); + assert_eq!(fake.calls(), 2); +} + +#[tokio::test] +async fn session_end_lets_go_of_a_waiting_inspector() { + let h = harness(Script::default()); + let fake = Arc::new(FakeInspector { hold: true, ..FakeInspector::default() }); + let id = stopped_with(&h, java_config("launch"), &fake).await; + let request = held_request(&h, id, &fake).await; + h.manager.session(id).unwrap().kill_processes(); + assert_eq!( + tokio::time::timeout(Duration::from_secs(2), request).await.unwrap().unwrap().unwrap(), + JvmInstancesResult::Unavailable { reason: JvmInspectUnavailable::BackendClosed } + ); + assert_eq!(Arc::strong_count(&fake), 1, "no clone of the inspector outlives the session"); +} + +#[tokio::test] +async fn target_carries_pid_for_launch() { + let h = harness(Script { debuggee_pid: Some(4242), java_process_id: true, ..Script::default() }); + let fake = Arc::new(FakeInspector::default()); + let id = stopped_with(&h, java_config("launch"), &fake).await; + h.manager.jvm_memory_instances(id, "A".into(), 10).await.unwrap(); + assert_eq!( + *fake.targets.lock(), + [VmTarget { pid: Some(4242), attach_host: None, attach_port: None, generation: 1 }] + ); +} + +#[tokio::test] +async fn target_carries_attach_port_and_host() { + let h = harness(Script::default()); + let project = tempfile::tempdir().unwrap(); + let fake = Arc::new(FakeInspector::default()); + let id = stopped_with(&h, remote_attach("java-debug", project.path()), &fake).await; + h.manager.jvm_memory_instances(id, "A".into(), 10).await.unwrap(); + assert_eq!( + *fake.targets.lock(), + [VmTarget { pid: None, attach_host: Some("10.0.0.5".into()), attach_port: Some(5005), generation: 1 }] + ); +} + +#[tokio::test] +async fn unsupported_backend_turns_instances_off() { + let h = harness(Script::default()); + let fake = Arc::new(FakeInspector::failing(InspectError::Unsupported("x".into()))); + let id = stopped_with(&h, java_config("launch"), &fake).await; + let before = debug_events(&h).len(); + assert_eq!( + h.manager.jvm_memory_instances(id, "A".into(), 10).await.unwrap(), + JvmInstancesResult::Unavailable { reason: JvmInspectUnavailable::Unsupported { detail: "x".into() } } + ); + let after: Vec<_> = debug_events(&h).split_off(before); + assert!( + matches!( + after.as_slice(), + [DebugEvent::JvmBackend { state: JvmBackendState::Unsupported { detail } }, DebugEvent::Capabilities] + if detail == "x" + ), + "{after:?}" + ); + assert!(!h.manager.capabilities(id).unwrap().jvm_instances); + assert_eq!( + h.manager.jvm_memory_instances(id, "A".into(), 10).await.unwrap(), + JvmInstancesResult::Unavailable { reason: JvmInspectUnavailable::NoBackend } + ); + assert_eq!(fake.calls(), 1); +} + +/// `error` is reported but leaves the backend on. +async fn keeps_the_backend(error: InspectError, reason: JvmInspectUnavailable) { + let h = harness(Script::default()); + let fake = Arc::new(FakeInspector::failing(error)); + let id = stopped_with(&h, java_config("launch"), &fake).await; + let object = JvmObjectRef { generation: 1, id: 7 }; + assert_eq!(h.manager.jvm_memory_fields(id, object).await.unwrap(), JvmFieldsResult::Unavailable { reason }); + assert!(h.manager.capabilities(id).unwrap().jvm_instances); + assert!(backend_events(&h).is_empty()); + assert!(h.manager.session(id).unwrap().jvm_inspector.lock().is_some()); +} + +#[tokio::test] +async fn collected_keeps_the_backend() { + keeps_the_backend(InspectError::Collected, JvmInspectUnavailable::Collected).await; +} + +#[tokio::test] +async fn timed_out_keeps_the_backend() { + keeps_the_backend(InspectError::TimedOut, JvmInspectUnavailable::TimedOut).await; +} + +#[tokio::test] +async fn failed_inspection_is_an_error() { + let h = harness(Script::default()); + let fake = Arc::new(FakeInspector::failing(InspectError::Failed("boom".into()))); + let id = stopped_with(&h, java_config("launch"), &fake).await; + let err = h.manager.jvm_memory_instances(id, "A".into(), 10).await.unwrap_err(); + assert!(err.to_string().contains("boom"), "{err}"); + assert!(h.manager.capabilities(id).unwrap().jvm_instances); +} + +#[tokio::test] +async fn limits_are_clamped() { + let h = harness(Script::default()); + let fake = Arc::new(FakeInspector::default()); + let id = stopped_with(&h, java_config("launch"), &fake).await; + for limit in [5000, 0] { + h.manager.jvm_memory_instances(id, "A".into(), limit).await.unwrap(); + } + let object = JvmObjectRef { generation: 1, id: 7 }; + for limit in [0, 900, 20] { + h.manager.jvm_memory_referrers(id, object, limit).await.unwrap(); + } + assert_eq!(*fake.limits.lock(), [1000, 1, 50, 200, 20]); +} + +#[tokio::test] +async fn a_full_page_is_truncated() { + let h = harness(Script::default()); + let fake = Arc::new(FakeInspector::default()); + fake.instances.lock().extend([object(1), object(2)]); + let id = stopped_with(&h, java_config("launch"), &fake).await; + let truncated = |r: JvmInstancesResult| match r { + JvmInstancesResult::Ready { truncated, .. } => truncated, + other => panic!("{other:?}"), + }; + assert!(truncated(h.manager.jvm_memory_instances(id, "A".into(), 2).await.unwrap())); + assert!(!truncated(h.manager.jvm_memory_instances(id, "A".into(), 3).await.unwrap())); +} + +#[tokio::test] +async fn capability_follows_the_inspector() { + let h = harness(Script::default()); + let id = h.start(java_config("launch")).await.unwrap(); + assert!(!h.manager.capabilities(id).unwrap().jvm_instances); + let before = debug_events(&h).len(); + h.manager.set_jvm_inspector(id, Arc::new(FakeInspector::default())).unwrap(); + assert!(h.manager.capabilities(id).unwrap().jvm_instances); + assert!(matches!(debug_events(&h).split_off(before).as_slice(), [DebugEvent::Capabilities])); +} + +#[tokio::test] +async fn remote_attach_counts_classes_through_the_debugger() { + let h = harness(Script::default()); + let project = tempfile::tempdir().unwrap(); + let fake = Arc::new(FakeInspector::default()); + fake.counts.lock().push(ide_jvm_inspect::ClassCount { name: "A".into(), instances: 3 }); + let id = stopped_with(&h, remote_attach("java-debug", project.path()), &fake).await; + let result = h.manager.jvm_memory_classes(id).await.unwrap(); + assert!( + matches!(&result, JvmMemoryResult::Ready { snapshot } + if snapshot.source == JvmMemorySource::Debugger && snapshot.rows.len() == 1), + "{result:?}" + ); + assert_eq!(fake.targets.lock()[0].attach_port, Some(5005)); +} + +const HANDSHAKE: &[u8; 14] = b"JDWP-Handshake"; + +/// A Kotlin attach whose link reaches a fake JVM: (adapter end, JVM end) after both handshakes. +async fn linked_kotlin(h: &Harness) -> (DebugSessionId, tokio::net::TcpStream, tokio::net::TcpStream) { + let jvm = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let project = tempfile::tempdir().unwrap(); + let id = h.start(kotlin_attach(project.path(), "127.0.0.1", jvm.local_addr().unwrap().port())).await.unwrap(); + let link_port = h.request("attach")["arguments"]["port"].as_u64().unwrap() as u16; + let mut adapter = tokio::net::TcpStream::connect(("127.0.0.1", link_port)).await.unwrap(); + adapter.write_all(HANDSHAKE).await.unwrap(); + let (mut jvm, _) = tokio::time::timeout(Duration::from_secs(2), jvm.accept()).await.unwrap().unwrap(); + let mut buf = [0; 14]; + jvm.read_exact(&mut buf).await.unwrap(); + jvm.write_all(HANDSHAKE).await.unwrap(); + adapter.read_exact(&mut buf).await.unwrap(); + (id, adapter, jvm) +} + +/// A command the adapter sends with an id from the link's own range: the link then only forwards. +async fn degrade(adapter: &mut tokio::net::TcpStream) { + let mut packet = 11u32.to_be_bytes().to_vec(); + packet.extend(ide_jdwp::OWN_IDS.start.to_be_bytes()); + packet.extend([0, 1, 1]); + adapter.write_all(&packet).await.unwrap(); +} + +async fn wait_backend(h: &Harness, want: impl Fn(&JvmBackendState) -> bool) { + h.wait_event(|e| matches!(e, DebugEvent::JvmBackend { state } if want(state))).await; +} + +#[tokio::test] +async fn kotlin_link_reports_degraded_then_closed() { + let h = harness(Script::default()); + let (id, mut adapter, jvm) = linked_kotlin(&h).await; + degrade(&mut adapter).await; + wait_backend(&h, |s| matches!(s, JvmBackendState::Degraded { .. })).await; + drop(jvm); + wait_backend(&h, |s| *s == JvmBackendState::Closed).await; + assert_eq!(backend_events(&h).len(), 2, "{:?}", backend_events(&h)); + let session = h.manager.session(id).unwrap(); + assert!(session.jdwp_link.lock().is_some(), "a closed link is kept until the session ends"); +} + +#[tokio::test] +async fn kotlin_link_is_quiet_once_the_program_ended() { + let h = harness(Script::default()); + let (id, _adapter, jvm) = linked_kotlin(&h).await; + let session = h.manager.session(id).unwrap(); + // The adapter reported the end; the session keeps its link until it shuts down. + session.terminated.store(true, Ordering::Release); + drop(jvm); + for _ in 0..100 { + if session.jdwp_link.lock().as_ref().is_some_and(|l| l.state() == ide_jdwp::LinkState::Closed) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + tokio::time::sleep(Duration::from_millis(50)).await; + assert!(backend_events(&h).is_empty(), "{:?}", backend_events(&h)); +} diff --git a/crates/ide-host/Cargo.toml b/crates/ide-host/Cargo.toml index 730e8f766..5f26e90d8 100644 --- a/crates/ide-host/Cargo.toml +++ b/crates/ide-host/Cargo.toml @@ -27,6 +27,7 @@ ide-search.workspace = true ide-lsp.workspace = true ide-vcs.workspace = true ide-dap.workspace = true +ide-jvm-attach.workspace = true ide-build.workspace = true ide-analysis.workspace = true ide-coverage.workspace = true diff --git a/crates/ide-host/src/ops/debug.rs b/crates/ide-host/src/ops/debug.rs index f35ae1ab1..f132f2a1f 100644 --- a/crates/ide-host/src/ops/debug.rs +++ b/crates/ide-host/src/ops/debug.rs @@ -9,7 +9,7 @@ use std::path::PathBuf; /// Starts a session owned by this window: its events (and those of child sessions) go here only. pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> IdeResult { host.require_trust(window)?; - let mut config = match_java_debug_encoding(config, cfg!(windows)); + let mut config = match_java_debug_encoding(start_java_attach_listener(config, cfg!(windows)), cfg!(windows)); if config.adapter == "debugpy" && config.request == "launch" && let Ok(project) = host.project(window) @@ -93,11 +93,67 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide Some(path.to_string_lossy().into_owned()) }; host.require_trust(window)?; + let server_port = debug_server_port(&config); let result = host.debugger.start_with(config, owner, custom).await; flush_output(); + if let (Ok(id), Some(port)) = (&result, server_port) { + attach_jvm_inspector(host, window, *id, port); + } result } +/// The port of the java-debug server this machine's jdtls started; `debugServer` as `host:port` +/// names an adapter on another machine, whose jdtls is not ours to ask. +fn debug_server_port(config: &LaunchConfig) -> Option { + if config.adapter != "java-debug" { + return None; + } + let extra: serde_json::Value = serde_json::from_str(config.extra_json.as_deref()?).ok()?; + extra.get("debugServer")?.as_u64().and_then(|port| u16::try_from(port).ok()).filter(|port| *port != 0) +} + +/// Lets the session ask the jdtls that runs it about its JVM. Without a module (no jdtls, or a +/// debug server it did not start) the session keeps the debugger-only memory view. +fn attach_jvm_inspector(host: &Host, window: &str, session: DebugSessionId, port: u16) { + let Ok(project) = host.project(window) else { return }; + let Some(module) = project.lsp.java_debug_server_module(port) else { return }; + let inspector = std::sync::Arc::new(ide_lsp::JdtJvmInspector::new(project.lsp.clone(), module)); + if let Err(e) = host.debugger.set_jvm_inspector(session, inspector) { + tracing::debug!("jvm inspector for session {session:?}: {e}"); + } +} + +/// The JVM memory tab reads the class table over the attach socket, which only exists once the +/// JVM has started its attach listener. The flag goes first so the user's arguments still win. +/// Not on Windows (`windows`: the JVM runs there), which the memory view does not support. +fn start_java_attach_listener(mut config: LaunchConfig, windows: bool) -> LaunchConfig { + if windows || config.adapter != "java-debug" || config.request != "launch" { + return config; + } + let Some(serde_json::Value::Object(mut extra)) = + config.extra_json.as_deref().and_then(|e| serde_json::from_str(e).ok()) + else { + return config; + }; + // java-debug also takes an array; rewriting one would drop the user's arguments. + let vm_args = match extra.get("vmArgs") { + None | Some(serde_json::Value::Null) => "", + Some(serde_json::Value::String(args)) => args, + Some(_) => return config, + }; + if !ide_jvm_attach::wants_listener_flag(vm_args) { + return config; + } + let vm_args = if vm_args.is_empty() { + ide_jvm_attach::START_LISTENER_FLAG.to_string() + } else { + format!("{} {vm_args}", ide_jvm_attach::START_LISTENER_FLAG) + }; + extra.insert("vmArgs".into(), vm_args.into()); + config.extra_json = Some(serde_json::Value::Object(extra).to_string()); + config +} + /// A JVM launched by java-debug writes what java-debug decodes its output as /// (see [`ide_core::output_encoding::java_debug_vm_args`]). fn match_java_debug_encoding(mut config: LaunchConfig, windows: bool) -> LaunchConfig { @@ -381,6 +437,51 @@ pub async fn debug_redefine_classes(host: &Host, _window: &str, session: DebugSe host.debugger.redefine_classes(session).await } +/// The debuggee JVM's loaded classes, or why they cannot be read. +pub async fn debug_jvm_memory_classes( + host: &Host, + window: &str, + session: DebugSessionId, +) -> IdeResult { + host.require_trust(window)?; + host.debugger.jvm_memory_classes(session).await +} + +/// Up to `limit` live instances of the class in the stopped JVM. +pub async fn debug_jvm_memory_instances( + host: &Host, + window: &str, + session: DebugSessionId, + class_name: String, + limit: u32, +) -> IdeResult { + host.require_trust(window)?; + host.debugger.jvm_memory_instances(session, class_name, limit).await +} + +/// The fields (or array elements) of an instance listed by [`debug_jvm_memory_instances`]. +pub async fn debug_jvm_memory_fields( + host: &Host, + window: &str, + session: DebugSessionId, + object: JvmObjectRef, +) -> IdeResult { + host.require_trust(window)?; + host.debugger.jvm_memory_fields(session, object).await +} + +/// The objects that hold a reference to `object`; `limit` 0 selects the default. +pub async fn debug_jvm_memory_referrers( + host: &Host, + window: &str, + session: DebugSessionId, + object: JvmObjectRef, + limit: u32, +) -> IdeResult { + host.require_trust(window)?; + host.debugger.jvm_memory_referrers(session, object, limit).await +} + /// Commands of this module the remote server answers. pub(crate) fn register(d: &mut Dispatcher) { d.add("debug_start", |c| async move { debug_start(&c.host, &c.window, c.arg("config")?).await }); @@ -509,6 +610,18 @@ pub(crate) fn register(d: &mut Dispatcher) { "debug_redefine_classes", |c| async move { debug_redefine_classes(&c.host, &c.window, c.arg("session")?).await }, ); + d.add("debug_jvm_memory_classes", |c| async move { + debug_jvm_memory_classes(&c.host, &c.window, c.arg("session")?).await + }); + d.add("debug_jvm_memory_instances", |c| async move { + debug_jvm_memory_instances(&c.host, &c.window, c.arg("session")?, c.arg("className")?, c.arg("limit")?).await + }); + d.add("debug_jvm_memory_fields", |c| async move { + debug_jvm_memory_fields(&c.host, &c.window, c.arg("session")?, c.arg("object")?).await + }); + d.add("debug_jvm_memory_referrers", |c| async move { + debug_jvm_memory_referrers(&c.host, &c.window, c.arg("session")?, c.arg("object")?, c.arg("limit")?).await + }); } #[cfg(test)] @@ -553,6 +666,58 @@ mod tests { } } + /// The flag as added for a JVM on macOS or Linux. + fn on_unix(config: LaunchConfig) -> LaunchConfig { + start_java_attach_listener(config, false) + } + + #[test] + fn java_debug_launches_start_the_attach_listener() { + let fixed = on_unix(launch("java-debug", "launch", serde_json::json!({"vmArgs": "-Xmx1g"}))); + assert_eq!(vm_args(&fixed), "-XX:+StartAttachListener -Xmx1g"); + assert_eq!( + vm_args(&on_unix(launch("java-debug", "launch", serde_json::json!({})))), + "-XX:+StartAttachListener" + ); + for extra in [ + serde_json::json!({"vmArgs": "-XX:-StartAttachListener"}), + serde_json::json!({"vmArgs": "-XX:+DisableAttachMechanism"}), + ] { + assert_eq!(vm_args(&on_unix(launch("java-debug", "launch", extra.clone()))), extra["vmArgs"]); + } + assert_eq!( + vm_args(&on_unix(launch("java-debug", "attach", serde_json::json!({"vmArgs": "-Xmx1g"})))), + "-Xmx1g" + ); + } + + #[test] + fn java_debug_launches_on_windows_get_no_attach_listener() { + let config = launch("java-debug", "launch", serde_json::json!({"vmArgs": "-Xmx1g"})); + assert_eq!(vm_args(&start_java_attach_listener(config, true)), "-Xmx1g"); + } + + #[test] + fn java_debug_array_vm_args_are_left_alone() { + let config = launch("java-debug", "launch", serde_json::json!({"vmArgs": ["-Xmx1g", "-Dk=v"]})); + assert_eq!(on_unix(config.clone()).extra_json, config.extra_json); + assert_eq!( + vm_args(&on_unix(launch("java-debug", "launch", serde_json::json!({"vmArgs": null})))), + "-XX:+StartAttachListener" + ); + } + + #[test] + fn debug_server_port_only_for_local_java_debug() { + let port = |adapter: &str, request: &str, extra| debug_server_port(&launch(adapter, request, extra)); + assert_eq!(port("java-debug", "launch", serde_json::json!({"debugServer": 4711})), Some(4711)); + assert_eq!(port("java-debug", "attach", serde_json::json!({"debugServer": 4711})), Some(4711)); + assert_eq!(port("java-debug", "launch", serde_json::json!({"debugServer": "10.0.0.5:4711"})), None); + assert_eq!(port(ide_dap::kotlin::ADAPTER, "launch", serde_json::json!({"debugServer": 4711})), None); + assert_eq!(port("java-debug", "launch", serde_json::json!({})), None); + assert_eq!(port("java-debug", "launch", serde_json::json!({"debugServer": 0})), None); + } + #[test] fn java_debug_launches_respect_environment_encoding() { for key in ["JDK_JAVA_OPTIONS", "JAVA_TOOL_OPTIONS"] { diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 42402b32f..c6ce8d001 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -210,6 +210,7 @@ impl Host { let bundles: Vec = ide_tools::managed_bundle(&data_dir, "java-debug") .into_iter() .chain(ide_sdk::java::usages_bundle(&data_dir)) + .chain(ide_sdk::java::jvm_inspect_bundle(&data_dir)) .chain(ide_sdk::java::kotlin_bridge_bundle(&data_dir)) .collect(); let min_java = ide_sdk::java::jdtls_min_java(cfg); diff --git a/crates/ide-host/tests/debug_jvm_memory_ipc.rs b/crates/ide-host/tests/debug_jvm_memory_ipc.rs new file mode 100644 index 000000000..43aac0211 --- /dev/null +++ b/crates/ide-host/tests/debug_jvm_memory_ipc.rs @@ -0,0 +1,90 @@ +//! The JVM memory commands resolve the caller's project through the window label, so a client +//! that sends no real window (the desktop shell once passed `""`) never reaches the debugger, +//! and a project in Restricted Mode never reads the debuggee's memory. + +use ide_core::{IdeError, NullSink}; +use ide_host::rpc::{self, Dispatcher, Invoke}; +use ide_host::{Host, HostConfig}; +use ide_jsonrpc::{Handler, Peer}; +use serde_json::{Value, json}; +use std::sync::{Arc, OnceLock}; + +struct Silent; +impl Handler for Silent { + fn on_notification(&self, _: &str, _: Value) {} + fn on_request(&self, method: &str, _: Value) -> Result { + Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, method.to_owned())) + } +} + +async fn invoke(peer: &Peer, window: &str, cmd: &str, args: Value) -> Result { + let call = Invoke { window: window.into(), cmd: cmd.into(), args }; + peer.request::<_, Value>("invoke", call, None).await.map_err(rpc::error_from_reply) +} + +const NO_SESSION: i64 = 4242; + +fn calls() -> [(&'static str, Value); 4] { + let object = json!({"generation": 1, "id": 7}); + [ + ("debug_jvm_memory_classes", json!({"session": NO_SESSION})), + ("debug_jvm_memory_instances", json!({"session": NO_SESSION, "className": "java.lang.String", "limit": 10})), + ("debug_jvm_memory_fields", json!({"session": NO_SESSION, "object": object})), + ("debug_jvm_memory_referrers", json!({"session": NO_SESSION, "object": object, "limit": 10})), + ] +} + +/// A host with project window "main", trusted or not; keep the temp dir alive. +fn host(trusted: bool) -> (Arc, tempfile::TempDir) { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("project"); + std::fs::create_dir(&root).unwrap(); + let host = Arc::new(Host::new(HostConfig { + config_dir: tmp.path().join("config"), + data_dir: tmp.path().join("data"), + local_data_dir: tmp.path().join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })); + host.trust.decide(&root, trusted, false).unwrap(); + host.open_project("main", &root).unwrap(); + (host, tmp) +} + +async fn serve(host: Arc) -> Peer { + let (client_io, server_io) = tokio::io::duplex(1 << 20); + let (sr, sw) = tokio::io::split(server_io); + tokio::spawn(rpc::serve(host, Dispatcher::standard(), Arc::new(OnceLock::new()), sr, sw)); + let (cr, cw) = tokio::io::split(client_io); + let peer = Peer::connect(cr, cw, Arc::new(Silent)); + peer.request::<_, Value>("initialize", json!({"version": rpc::PROTOCOL_VERSION}), None).await.unwrap(); + peer +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn memory_commands_reach_the_debugger_from_a_trusted_window() { + let (host, _tmp) = host(true); + let peer = serve(host).await; + for (command, args) in calls() { + let error = invoke(&peer, "main", command, args.clone()).await.unwrap_err(); + assert!( + error.message.contains(&format!("no debug session {NO_SESSION}")), + "{command} should get as far as the session lookup: {error:?}" + ); + let error = invoke(&peer, "", command, args).await.unwrap_err(); + assert!(error.message.contains("no project"), "{command} without a window label: {error:?}"); + } +} + +/// The commands check trust themselves too (spec §13), not only through the IPC gate. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn class_table_is_refused_in_restricted_mode() { + let (host, _tmp) = host(false); + let session = ide_core::DebugSessionId(NO_SESSION as u32); + let error = ide_host::ops::debug::debug_jvm_memory_classes(&host, "main", session).await.unwrap_err(); + assert!(error.message.contains("Restricted Mode"), "{error:?}"); +} diff --git a/crates/ide-jdwp/Cargo.toml b/crates/ide-jdwp/Cargo.toml new file mode 100644 index 000000000..4c3a3eec5 --- /dev/null +++ b/crates/ide-jdwp/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "ide-jdwp" +description = "JDWP codec and a transparent debugger link that counts every object ID it receives, so it can release them exactly" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +ide-jvm-inspect.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["net"] } +tracing.workspace = true + +[dev-dependencies] +proptest.workspace = true +tempfile.workspace = true diff --git a/crates/ide-jdwp/src/codec.rs b/crates/ide-jdwp/src/codec.rs new file mode 100644 index 000000000..3170142eb --- /dev/null +++ b/crates/ide-jdwp/src/codec.rs @@ -0,0 +1,320 @@ +use crate::ids::IdSizes; + +pub const HEADER_LEN: usize = 11; +pub const FLAG_REPLY: u8 = 0x80; +/// Largest packet the debugger accepts from the adapter side. +pub const MAX_ADAPTER_PACKET: usize = 16 * 1024 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum CodecError { + #[error("packet is shorter than its contents require")] + Truncated, + #[error("unknown value tag {0:#04x}")] + BadTag(u8), + #[error("invalid length or size {0}")] + BadLength(u32), +} + +/// JDWP packet header. `a`/`b` are (command set, command) in a command and the big-endian +/// error code in a reply. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Header { + pub length: u32, + pub id: u32, + pub flags: u8, + pub a: u8, + pub b: u8, +} + +impl Header { + pub fn parse(bytes: &[u8; HEADER_LEN]) -> Header { + Header { + length: u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]), + id: u32::from_be_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]), + flags: bytes[8], + a: bytes[9], + b: bytes[10], + } + } + + pub fn is_reply(&self) -> bool { + self.flags & FLAG_REPLY != 0 + } + + pub fn command(&self) -> (u8, u8) { + (self.a, self.b) + } + + pub fn error(&self) -> u16 { + u16::from_be_bytes([self.a, self.b]) + } + + pub fn command_bytes(id: u32, set: u8, cmd: u8, body_len: usize) -> [u8; HEADER_LEN] { + let length = (HEADER_LEN + body_len) as u32; + let mut out = [0u8; HEADER_LEN]; + out[0..4].copy_from_slice(&length.to_be_bytes()); + out[4..8].copy_from_slice(&id.to_be_bytes()); + out[9] = set; + out[10] = cmd; + out + } +} + +/// A decoded JDWP value. A null object reference is `Null` whatever its tag was. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Value { + Null, + Primitive { tag: u8, raw: u64 }, + Object { tag: u8, id: u64 }, +} + +pub fn is_object_tag(tag: u8) -> bool { + matches!(tag, b'L' | b's' | b't' | b'g' | b'l' | b'c' | b'[') +} + +/// Byte width of a primitive value, `None` for an object or unknown tag. +pub(crate) fn primitive_size(tag: u8) -> Option { + match tag { + b'B' | b'Z' => Some(1), + b'C' | b'S' => Some(2), + b'F' | b'I' => Some(4), + b'J' | b'D' => Some(8), + _ => None, + } +} + +/// Text of a primitive the way Java prints it. +pub fn primitive_text(tag: u8, raw: u64) -> String { + match tag { + b'Z' => (raw != 0).to_string(), + b'B' => (raw as u8 as i8).to_string(), + b'C' => char::from_u32(u32::from(raw as u16)).unwrap_or('\u{FFFD}').to_string(), + b'S' => (raw as u16 as i16).to_string(), + b'I' => (raw as u32 as i32).to_string(), + b'J' => (raw as i64).to_string(), + b'F' => java_float(f32::from_bits(raw as u32).into(), format!("{:?}", f32::from_bits(raw as u32))), + b'D' => java_float(f64::from_bits(raw), format!("{:?}", f64::from_bits(raw))), + b'V' => "void".to_string(), + _ => String::new(), + } +} + +/// `rust_debug` is Rust's `{:?}` of the same number, which already keeps a `.0` on integral +/// values; only NaN/infinity spelling and the exponent form differ from Java. +fn java_float(value: f64, rust_debug: String) -> String { + if value.is_nan() { + return "NaN".to_string(); + } + if value.is_infinite() { + return if value > 0.0 { "Infinity" } else { "-Infinity" }.to_string(); + } + match rust_debug.split_once('e') { + Some((mantissa, exp)) if mantissa.contains('.') => format!("{mantissa}E{exp}"), + Some((mantissa, exp)) => format!("{mantissa}.0E{exp}"), + None => rust_debug, + } +} + +/// Bounds-checked cursor over a packet body. +pub struct Reader<'a> { + bytes: &'a [u8], + pos: usize, +} + +impl<'a> Reader<'a> { + pub fn new(bytes: &'a [u8]) -> Self { + Reader { bytes, pos: 0 } + } + + pub fn remaining(&self) -> usize { + self.bytes.len() - self.pos + } + + fn take(&mut self, n: usize) -> Result<&'a [u8], CodecError> { + if n > self.remaining() { + return Err(CodecError::Truncated); + } + let out = &self.bytes[self.pos..self.pos + n]; + self.pos += n; + Ok(out) + } + + pub fn skip(&mut self, n: usize) -> Result<(), CodecError> { + self.take(n).map(|_| ()) + } + + pub fn u8(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + + pub fn i32(&mut self) -> Result { + let b = self.take(4)?; + Ok(i32::from_be_bytes([b[0], b[1], b[2], b[3]])) + } + + pub fn i64(&mut self) -> Result { + let b = self.take(8)?; + Ok(i64::from_be_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]])) + } + + /// An `int` element count; a negative one is malformed. + pub fn count(&mut self) -> Result { + let n = self.i32()?; + usize::try_from(n).map_err(|_| CodecError::BadLength(n as u32)) + } + + /// A big-endian unsigned id of `size` bytes (1..=8). + pub fn id(&mut self, size: u8) -> Result { + if !(1..=8).contains(&size) { + return Err(CodecError::BadLength(u32::from(size))); + } + Ok(self.take(usize::from(size))?.iter().fold(0u64, |acc, &b| (acc << 8) | u64::from(b))) + } + + pub fn string(&mut self) -> Result { + let n = self.i32()? as u32 as usize; + Ok(String::from_utf8_lossy(self.take(n)?).into_owned()) + } + + /// The untagged value of type `tag`. + pub fn value(&mut self, tag: u8, sizes: &IdSizes) -> Result { + if let Some(size) = primitive_size(tag) { + return Ok(Value::Primitive { tag, raw: self.id(size)? }); + } + if tag == b'V' { + return Ok(Value::Primitive { tag, raw: 0 }); + } + if is_object_tag(tag) { + return Ok(match self.id(sizes.object)? { + 0 => Value::Null, + id => Value::Object { tag, id }, + }); + } + Err(CodecError::BadTag(tag)) + } + + /// A tag byte followed by the value it names. + pub fn tagged_value(&mut self, sizes: &IdSizes) -> Result { + let tag = self.u8()?; + self.value(tag, sizes) + } +} + +#[derive(Debug, Default)] +pub struct Writer { + bytes: Vec, +} + +impl Writer { + pub fn new() -> Self { + Self::default() + } + + pub fn u8(&mut self, v: u8) { + self.bytes.push(v); + } + + pub fn i32(&mut self, v: i32) { + self.bytes.extend_from_slice(&v.to_be_bytes()); + } + + pub fn i64(&mut self, v: i64) { + self.bytes.extend_from_slice(&v.to_be_bytes()); + } + + /// The low `size` bytes of `v`, big-endian. + pub fn id(&mut self, v: u64, size: u8) { + for i in (0..u32::from(size)).rev() { + self.bytes.push(v.checked_shr(8 * i).unwrap_or(0) as u8); + } + } + + pub fn string(&mut self, s: &str) { + self.bytes.extend_from_slice(&(s.len() as u32).to_be_bytes()); + self.bytes.extend_from_slice(s.as_bytes()); + } + + pub fn into_vec(self) -> Vec { + self.bytes + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn header_round_trip() { + let bytes = Header::command_bytes(0x7000_0001, 1, 21, 4); + let h = Header::parse(&bytes); + assert_eq!(h.id, 0x7000_0001); + assert_eq!(h.command(), (1, 21)); + assert_eq!(h.length, 15); + assert!(!h.is_reply()); + } + + #[test] + fn reply_header_carries_error_code() { + let h = Header::parse(&[0, 0, 0, 11, 0, 0, 0, 7, FLAG_REPLY, 0, 20]); + assert!(h.is_reply()); + assert_eq!(h.error(), 20); + } + + #[test] + fn primitive_texts() { + assert_eq!(primitive_text(b'Z', 1), "true"); + assert_eq!(primitive_text(b'Z', 0), "false"); + assert_eq!(primitive_text(b'C', 'a' as u64), "a"); + assert_eq!(primitive_text(b'I', u64::from(u32::MAX)), "-1"); + assert_eq!(primitive_text(b'B', 0xff), "-1"); + assert_eq!(primitive_text(b'S', 0xfffe), "-2"); + assert_eq!(primitive_text(b'J', u64::MAX - 4), "-5"); + assert_eq!(primitive_text(b'D', 1.5f64.to_bits()), "1.5"); + assert_eq!(primitive_text(b'D', 2.0f64.to_bits()), "2.0"); + assert_eq!(primitive_text(b'F', 1.5f32.to_bits().into()), "1.5"); + assert_eq!(primitive_text(b'D', f64::NAN.to_bits()), "NaN"); + assert_eq!(primitive_text(b'D', f64::NEG_INFINITY.to_bits()), "-Infinity"); + assert_eq!(primitive_text(b'F', f32::INFINITY.to_bits().into()), "Infinity"); + } + + #[test] + fn object_tags() { + for t in *b"Lstglc[" { + assert!(is_object_tag(t)); + } + for t in *b"BCFIJSZDV" { + assert!(!is_object_tag(t)); + } + } + + #[test] + fn reader_rejects_truncation_and_bad_lengths() { + assert_eq!(Reader::new(&[]).u8(), Err(CodecError::Truncated)); + assert_eq!(Reader::new(&[0, 0, 1]).i32(), Err(CodecError::Truncated)); + assert_eq!(Reader::new(&[0; 7]).i64(), Err(CodecError::Truncated)); + assert_eq!(Reader::new(&[0; 8]).id(0), Err(CodecError::BadLength(0))); + assert_eq!(Reader::new(&[0; 16]).id(9), Err(CodecError::BadLength(9))); + assert_eq!(Reader::new(&[0; 3]).id(4), Err(CodecError::Truncated)); + // length prefix larger than the bytes that follow + assert_eq!(Reader::new(&[0, 0, 0, 9, b'a']).string(), Err(CodecError::Truncated)); + assert_eq!(Reader::new(&[0xff, 0xff, 0xff, 0xff]).string(), Err(CodecError::Truncated)); + } + + #[test] + fn writer_reader_round_trip() { + let mut w = Writer::new(); + w.u8(7); + w.i32(-3); + w.i64(1 << 40); + w.id(0xABCD, 2); + w.string("héllo"); + let bytes = w.into_vec(); + let mut r = Reader::new(&bytes); + assert_eq!(r.u8(), Ok(7)); + assert_eq!(r.i32(), Ok(-3)); + assert_eq!(r.i64(), Ok(1 << 40)); + assert_eq!(r.id(2), Ok(0xABCD)); + assert_eq!(r.string().as_deref(), Ok("héllo")); + assert_eq!(r.remaining(), 0); + } +} diff --git a/crates/ide-jdwp/src/ids.rs b/crates/ide-jdwp/src/ids.rs new file mode 100644 index 000000000..f044969cc --- /dev/null +++ b/crates/ide-jdwp/src/ids.rs @@ -0,0 +1,407 @@ +use crate::codec::{CodecError, Reader, Value, Writer, is_object_tag, primitive_size}; + +/// `VirtualMachine.IDSizes` (1/7) reply: byte widths of the ids in every later packet. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct IdSizes { + pub field: u8, + pub method: u8, + pub object: u8, + pub reference_type: u8, + pub frame: u8, +} + +impl IdSizes { + pub fn parse(reply_body: &[u8]) -> Result { + let mut r = Reader::new(reply_body); + let mut next = || -> Result { + let n = r.i32()?; + match u8::try_from(n) { + Ok(size @ 1..=8) => Ok(size), + _ => Err(CodecError::BadLength(n as u32)), + } + }; + Ok(IdSizes { field: next()?, method: next()?, object: next()?, reference_type: next()?, frame: next()? }) + } +} + +/// Where a reply carries ObjectIDs. Each one is a reference the VM counted for the receiver, +/// so [`object_ids`] reports every occurrence, not every distinct id. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReplyIds { + /// No ObjectID. Also right for replies that carry only ReferenceTypeIDs, which are never disposed. + None, + /// `int n; (tag, objectID)*` — `ReferenceType.Instances` (2/16), `ObjectReference.ReferringObjects` (9/10). + TaggedObjects, + /// `int n; value*` (tagged) — `ReferenceType.GetValues` (2/6), `ObjectReference.GetValues` (9/2). + Values, + /// `byte tag; int n;` then untagged values for a primitive tag, tagged values for an object tag + /// — `ArrayReference.GetValues` (13/2). + ArrayRegion, +} + +/// ObjectIDs of `body` in encounter order, one element per occurrence; null (0) is skipped. +/// +/// Strict on purpose: these ids feed `DisposeObjects`, so a body that does not match `layout` +/// exactly (trailing bytes, a tag that cannot occur there) is an `Err`, which only leaks. +pub fn object_ids(layout: ReplyIds, body: &[u8], sizes: &IdSizes) -> Result, CodecError> { + let mut out = Vec::new(); + let mut r = Reader::new(body); + match layout { + ReplyIds::None => return Ok(out), + ReplyIds::TaggedObjects => { + for _ in 0..r.count()? { + let tag = r.u8()?; + if !is_object_tag(tag) { + return Err(CodecError::BadTag(tag)); + } + push_id(&mut out, r.id(sizes.object)?); + } + } + ReplyIds::Values => { + for _ in 0..r.count()? { + push_value(&mut out, tagged_value(&mut r, sizes, |tag| tag != b'V')?); + } + } + ReplyIds::ArrayRegion => { + let tag = r.u8()?; + let n = r.count()?; + if is_object_tag(tag) { + for _ in 0..n { + push_value(&mut out, tagged_value(&mut r, sizes, is_object_tag)?); + } + } else { + let size = primitive_size(tag).ok_or(CodecError::BadTag(tag))?; + r.skip(n.checked_mul(usize::from(size)).ok_or(CodecError::Truncated)?)?; + } + } + } + match r.remaining() { + 0 => Ok(out), + extra => Err(CodecError::BadLength(u32::try_from(extra).unwrap_or(u32::MAX))), + } +} + +fn tagged_value(r: &mut Reader<'_>, sizes: &IdSizes, allowed: fn(u8) -> bool) -> Result { + let tag = r.u8()?; + if !allowed(tag) { + return Err(CodecError::BadTag(tag)); + } + r.value(tag, sizes) +} + +fn push_id(out: &mut Vec, id: u64) { + if id != 0 { + out.push(id); + } +} + +fn push_value(out: &mut Vec, value: Value) { + if let Value::Object { id, .. } = value { + push_id(out, id); + } +} + +/// The first event of a `Composite` (64/100) event packet, whatever its suspend policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Stop { + pub suspend_policy: u8, + /// `None` for events that carry no thread (`CLASS_UNLOAD`, `VM_DEATH`) or the null thread. + pub thread: Option, +} + +pub(crate) const EVENT_CLASS_PREPARE: u8 = 8; +const EVENT_CLASS_UNLOAD: u8 = 9; +const EVENT_VM_DEATH: u8 = 99; + +/// The kind of a `Composite` event's first event; `None` when it holds none or is malformed. +pub(crate) fn event_kind(event_body: &[u8]) -> Option { + let mut r = Reader::new(event_body); + r.u8().ok()?; + if r.count().ok()? == 0 { + return None; + } + r.u8().ok() +} + +/// `None` when the packet holds no event or is malformed. +pub fn stop_of(event_body: &[u8], sizes: &IdSizes) -> Option { + let mut r = Reader::new(event_body); + let suspend_policy = r.u8().ok()?; + if r.count().ok()? == 0 { + return None; + } + let kind = r.u8().ok()?; + r.i32().ok()?; + let thread = match kind { + EVENT_CLASS_UNLOAD | EVENT_VM_DEATH => None, + _ => Some(r.id(sizes.object).ok()?).filter(|&thread| thread != 0), + }; + Some(Stop { suspend_policy, thread }) +} + +/// `VirtualMachine.DisposeObjects` (1/14) body: `int n; (objectID, int refCnt)*`. +pub fn dispose_body(entries: &[(u64, u32)], sizes: &IdSizes) -> Vec { + let mut w = Writer::new(); + w.i32(entries.len() as i32); + for &(id, count) in entries { + w.id(id, sizes.object); + w.i32(count as i32); + } + w.into_vec() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::codec::{Value, Writer}; + use proptest::collection::vec; + use proptest::prelude::*; + + const S8: IdSizes = IdSizes { field: 8, method: 8, object: 8, reference_type: 8, frame: 8 }; + + fn values_body(values: &[(u8, u64)], sizes: &IdSizes) -> Vec { + let mut w = Writer::new(); + w.i32(values.len() as i32); + for &(tag, raw) in values { + w.u8(tag); + w.id(raw, value_size(tag, sizes)); + } + w.into_vec() + } + + fn value_size(tag: u8, sizes: &IdSizes) -> u8 { + match tag { + b'B' | b'Z' => 1, + b'C' | b'S' => 2, + b'F' | b'I' => 4, + b'J' | b'D' => 8, + _ => sizes.object, + } + } + + #[test] + fn id_sizes_from_reply() { + let mut w = Writer::new(); + for _ in 0..5 { + w.i32(8); + } + assert_eq!(IdSizes::parse(&w.into_vec()), Ok(S8)); + } + + #[test] + fn id_sizes_reject_bad_input() { + let mut short = [0, 0, 0, 8].repeat(4); + short.extend([0, 0, 0]); + assert_eq!(IdSizes::parse(&short), Err(CodecError::Truncated)); + let mut w = Writer::new(); + for n in [8, 8, 0, 8, 8] { + w.i32(n); + } + assert_eq!(IdSizes::parse(&w.into_vec()), Err(CodecError::BadLength(0))); + } + + #[test] + fn counts_every_occurrence() { + let body = values_body(&[(b'L', 0x11), (b'L', 0x11), (b'I', 5), (b's', 0x22), (b'L', 0)], &S8); + assert_eq!(object_ids(ReplyIds::Values, &body, &S8), Ok(vec![0x11, 0x11, 0x22])); + } + + #[test] + fn tagged_objects_counts_each_entry_and_skips_null() { + let mut w = Writer::new(); + w.i32(3); + for id in [9u64, 9, 0] { + w.u8(b'L'); + w.id(id, 8); + } + assert_eq!(object_ids(ReplyIds::TaggedObjects, &w.into_vec(), &S8), Ok(vec![9, 9])); + } + + #[test] + fn layouts_reject_trailing_bytes() { + let mut body = values_body(&[(b'L', 0x11)], &S8); + body.push(0); + assert_eq!(object_ids(ReplyIds::Values, &body, &S8), Err(CodecError::BadLength(1))); + + let mut w = Writer::new(); + w.i32(1); + w.u8(b'L'); + w.id(9, 8); + w.u8(0); + assert_eq!(object_ids(ReplyIds::TaggedObjects, &w.into_vec(), &S8), Err(CodecError::BadLength(1))); + + let mut w = Writer::new(); + w.u8(b'I'); + w.i32(1); + w.id(1, 4); + w.i32(0); + assert_eq!(object_ids(ReplyIds::ArrayRegion, &w.into_vec(), &S8), Err(CodecError::BadLength(4))); + } + + #[test] + fn tagged_objects_reject_a_primitive_tag() { + let mut w = Writer::new(); + w.i32(1); + w.u8(b'I'); + w.id(9, 8); + assert_eq!(object_ids(ReplyIds::TaggedObjects, &w.into_vec(), &S8), Err(CodecError::BadTag(b'I'))); + } + + #[test] + fn values_reject_void() { + let mut w = Writer::new(); + w.i32(1); + w.u8(b'V'); + assert_eq!(object_ids(ReplyIds::Values, &w.into_vec(), &S8), Err(CodecError::BadTag(b'V'))); + } + + #[test] + fn object_array_region_rejects_void_and_primitive_elements() { + for inner in [b'V', b'I'] { + let mut w = Writer::new(); + w.u8(b'['); + w.i32(1); + w.u8(inner); + w.id(1, 4); + assert_eq!(object_ids(ReplyIds::ArrayRegion, &w.into_vec(), &S8), Err(CodecError::BadTag(inner))); + } + let mut w = Writer::new(); + w.u8(b'V'); + w.i32(0); + assert_eq!(object_ids(ReplyIds::ArrayRegion, &w.into_vec(), &S8), Err(CodecError::BadTag(b'V'))); + } + + #[test] + fn none_layout_counts_nothing() { + assert_eq!(object_ids(ReplyIds::None, &[1, 2, 3], &S8), Ok(vec![])); + } + + #[test] + fn array_region_of_objects_and_primitives() { + let mut w = Writer::new(); + w.u8(b'I'); + w.i32(3); + w.id(1, 4); + w.id(2, 4); + w.id(3, 4); + assert_eq!(object_ids(ReplyIds::ArrayRegion, &w.into_vec(), &S8), Ok(vec![])); + + let mut w = Writer::new(); + w.u8(b'L'); + w.i32(2); + for _ in 0..2 { + w.u8(b'L'); + w.id(0xA, 8); + } + assert_eq!(object_ids(ReplyIds::ArrayRegion, &w.into_vec(), &S8), Ok(vec![0xA, 0xA])); + } + + #[test] + fn array_region_primitive_count_larger_than_body_is_truncated() { + let mut w = Writer::new(); + w.u8(b'J'); + w.i32(i32::MAX); + w.id(1, 8); + assert_eq!(object_ids(ReplyIds::ArrayRegion, &w.into_vec(), &S8), Err(CodecError::Truncated)); + } + + #[test] + fn truncated_body_is_an_error() { + let body = values_body(&[(b'L', 0x11), (b'I', 5), (b'L', 0x22)], &S8); + assert_eq!(object_ids(ReplyIds::Values, &body, &S8), Ok(vec![0x11, 0x22])); + for cut in 0..body.len() { + assert_eq!(object_ids(ReplyIds::Values, &body[..cut], &S8), Err(CodecError::Truncated), "cut {cut}"); + } + let bad = values_body(&[(b'Q', 1)], &S8); + assert_eq!(object_ids(ReplyIds::Values, &bad, &S8), Err(CodecError::BadTag(b'Q'))); + let negative = (-1i32).to_be_bytes(); + assert_eq!(object_ids(ReplyIds::Values, &negative, &S8), Err(CodecError::BadLength(u32::MAX))); + } + + #[test] + fn stop_of_breakpoint_and_vm_death() { + let event = |policy: u8, kind: u8, thread: Option| { + let mut w = Writer::new(); + w.u8(policy); + w.i32(1); + w.u8(kind); + w.i32(42); + if let Some(t) = thread { + w.id(t, 8); + } + w.into_vec() + }; + assert_eq!(stop_of(&event(2, 2, Some(0x77)), &S8), Some(Stop { suspend_policy: 2, thread: Some(0x77) })); + assert_eq!(stop_of(&event(2, 99, None), &S8), Some(Stop { suspend_policy: 2, thread: None })); + assert_eq!(stop_of(&event(1, 9, None), &S8), Some(Stop { suspend_policy: 1, thread: None })); + assert_eq!(stop_of(&event(0, 2, Some(0x77)), &S8), Some(Stop { suspend_policy: 0, thread: Some(0x77) })); + // CLASS_PREPARE from a thread the VM does not expose carries the null thread + assert_eq!(stop_of(&event(1, 8, Some(0)), &S8), Some(Stop { suspend_policy: 1, thread: None })); + assert_eq!(stop_of(&event(2, 2, None), &S8), None); + assert_eq!(stop_of(&[], &S8), None); + } + + #[test] + fn stop_of_without_events_is_none() { + assert_eq!(stop_of(&[2, 0, 0, 0, 0], &S8), None); + } + + #[test] + fn dispose_body_layout() { + let body = dispose_body(&[(5, 2)], &S8); + assert_eq!(body, [0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 2]); + assert_eq!(dispose_body(&[], &S8), [0, 0, 0, 0]); + } + + fn any_value() -> impl Strategy { + prop_oneof![ + Just(Value::Null), + (prop::sample::select(b"Lstglc[".to_vec()), 1..=u64::MAX).prop_map(|(tag, id)| Value::Object { tag, id }), + (prop::sample::select(b"BCFIJSZD".to_vec()), any::()) + .prop_map(|(tag, raw)| Value::Primitive { tag, raw }), + ] + } + + proptest! { + #[test] + fn object_ids_match_encoded(values in vec(any_value(), 0..64), object_size in 1u8..=8) { + let sizes = IdSizes { object: object_size, ..S8 }; + let mask = if object_size == 8 { u64::MAX } else { (1u64 << (object_size * 8)) - 1 }; + let mut w = Writer::new(); + w.i32(values.len() as i32); + let mut expected = Vec::new(); + for v in &values { + match *v { + Value::Null => { + w.u8(b'L'); + w.id(0, object_size); + } + Value::Object { tag, id } => { + // an id that does not survive the size mask would change identity (or become null) + let id = id & mask; + w.u8(tag); + w.id(id, object_size); + if id != 0 { + expected.push(id); + } + } + Value::Primitive { tag, raw } => { + w.u8(tag); + w.id(raw, value_size(tag, &sizes)); + } + } + } + prop_assert_eq!(object_ids(ReplyIds::Values, &w.into_vec(), &sizes), Ok(expected)); + } + + #[test] + fn arbitrary_bytes_never_panic(body in vec(any::(), 0..128), object_size in 0u8..=12) { + let sizes = IdSizes { object: object_size, ..S8 }; + for layout in [ReplyIds::None, ReplyIds::TaggedObjects, ReplyIds::Values, ReplyIds::ArrayRegion] { + let _ = object_ids(layout, &body, &sizes); + } + let _ = stop_of(&body, &sizes); + let _ = IdSizes::parse(&body); + } + } +} diff --git a/crates/ide-jdwp/src/inspector.rs b/crates/ide-jdwp/src/inspector.rs new file mode 100644 index 000000000..4c643ddc6 --- /dev/null +++ b/crates/ide-jdwp/src/inspector.rs @@ -0,0 +1,108 @@ +//! [`JvmInspector`] over a [`JdwpLink`]: reads instances, fields and referrers with JDWP commands +//! injected while the VM is stopped. Nothing here runs code in the VM. +//! +//! The link orders the commands and releases the object ids they return. `target` is not +//! looked at: the caller (ide-dap) checks that an object belongs to the current stop. + +use crate::ids::{IdSizes, ReplyIds}; +use crate::link::{JdwpLink, LinkError}; +use cache::Caches; +use ide_jvm_inspect::{ + ClassCount, InspectError, InspectFuture, JvmField, JvmInspector, JvmReferrer, JvmValue, ObjectId, VmTarget, +}; +use std::sync::Mutex; +use std::time::Duration; + +mod cache; +mod classes; +mod fields; +mod lookup; +mod preview; +mod referrers; +mod wire; + +#[cfg(test)] +mod fake; +#[cfg(test)] +mod tests; + +/// `ReferringObjects` walks the whole heap, so it is the slowest command after `InstanceCounts`. +const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); +/// `InstanceCounts` takes seconds on a heap of millions of objects (JDK 21: 5-8 s for 10M). +const COUNTS_TIMEOUT: Duration = Duration::from_secs(120); + +pub struct LinkInspector { + link: JdwpLink, + caches: Mutex, +} + +impl LinkInspector { + pub fn new(link: JdwpLink) -> Self { + LinkInspector { link, caches: Mutex::default() } + } + + fn sizes(&self) -> Result { + // The link learns the sizes right after the handshake, before it can see a stop. + self.link.id_sizes().ok_or(InspectError::NotSuspended) + } + + async fn call(&self, set: u8, cmd: u8, body: Vec, ids: ReplyIds) -> Result, InspectError> { + self.call_within(set, cmd, body, ids, COMMAND_TIMEOUT).await + } + + async fn call_within( + &self, + set: u8, + cmd: u8, + body: Vec, + ids: ReplyIds, + timeout: Duration, + ) -> Result, InspectError> { + self.link.command(set, cmd, body, ids, timeout).await.map_err(map_link_error) + } +} + +fn map_link_error(error: LinkError) -> InspectError { + match error { + LinkError::NotSuspended => InspectError::NotSuspended, + LinkError::Stale => InspectError::Stale, + LinkError::Degraded => InspectError::Unsupported("the JDWP link stopped inspecting".into()), + LinkError::Closed => InspectError::Closed, + // The stopped thread was running the adapter's code through every retry: ask again later. + LinkError::TimedOut | LinkError::Busy => InspectError::TimedOut, + // INVALID_OBJECT + LinkError::Jdwp(20) => InspectError::Collected, + // INVALID_CLASS + LinkError::Jdwp(21) => InspectError::ClassNotLoaded, + LinkError::Jdwp(code) => InspectError::Failed(format!("JDWP error {code}")), + LinkError::Protocol(message) => InspectError::Failed(format!("malformed JDWP reply: {message}")), + } +} + +impl JvmInspector for LinkInspector { + fn class_counts<'a>(&'a self, _target: &'a VmTarget) -> InspectFuture<'a, Vec> { + Box::pin(self.counts()) + } + + fn instances<'a>( + &'a self, + _target: &'a VmTarget, + class_name: &'a str, + limit: u32, + ) -> InspectFuture<'a, Vec> { + Box::pin(self.list_instances(class_name, limit)) + } + + fn fields<'a>(&'a self, _target: &'a VmTarget, object: ObjectId) -> InspectFuture<'a, Vec> { + Box::pin(self.list_fields(object)) + } + + fn referrers<'a>( + &'a self, + _target: &'a VmTarget, + object: ObjectId, + limit: u32, + ) -> InspectFuture<'a, Vec> { + Box::pin(self.list_referrers(object, limit)) + } +} diff --git a/crates/ide-jdwp/src/inspector/cache.rs b/crates/ide-jdwp/src/inspector/cache.rs new file mode 100644 index 000000000..323b6d287 --- /dev/null +++ b/crates/ide-jdwp/src/inspector/cache.rs @@ -0,0 +1,29 @@ +use super::wire::FieldInfo; +use std::collections::HashMap; +use std::sync::Arc; + +/// What was learnt about classes. Reference types stay valid while their class is loaded, which +/// a stop does not guarantee for the next one, so everything is dropped when the link's stop +/// changes. +#[derive(Default)] +pub(super) struct Caches { + generation: u64, + pub(super) names: HashMap, + pub(super) fields: HashMap>>, + pub(super) superclasses: HashMap, +} + +impl Caches { + /// The caches of `generation`, emptied first if they belong to another stop. + pub(super) fn at(&mut self, generation: u64) -> &mut Caches { + if self.generation != generation { + *self = Caches { generation, ..Caches::default() }; + } + self + } + + /// Whether an answer read in `generation` still belongs in the caches. + pub(super) fn accepts(&self, generation: u64) -> bool { + self.generation == generation + } +} diff --git a/crates/ide-jdwp/src/inspector/classes.rs b/crates/ide-jdwp/src/inspector/classes.rs new file mode 100644 index 000000000..3165eec47 --- /dev/null +++ b/crates/ide-jdwp/src/inspector/classes.rs @@ -0,0 +1,67 @@ +//! The class table and the instances of one class. + +use super::{COUNTS_TIMEOUT, LinkInspector, wire}; +use crate::ids::ReplyIds; +use ide_jvm_inspect::{ClassCount, InspectError, JvmValue, MAX_INSTANCES, name_to_signature, signature_to_name}; + +impl LinkInspector { + /// Every loaded class with at least one instance, from one `InstanceCounts` for all of them. + pub(super) async fn counts(&self) -> Result, InspectError> { + let sizes = self.sizes()?; + let reply = self.call(1, 3, Vec::new(), ReplyIds::None).await?; + let classes = wire::all_classes(&reply, &sizes)?; + if classes.is_empty() { + return Ok(Vec::new()); + } + let ids: Vec = classes.iter().map(|(id, _)| *id).collect(); + let body = wire::instance_counts_body(&ids, &sizes); + let reply = self.call_within(1, 21, body, ReplyIds::None, COUNTS_TIMEOUT).await?; + let counts = wire::instance_counts(&reply)?; + if counts.len() != classes.len() { + return Err(InspectError::Failed(format!("{} counts for {} classes", counts.len(), classes.len()))); + } + let mut out = Vec::new(); + for ((_, signature), instances) in classes.into_iter().zip(counts) { + if instances == 0 { + continue; + } + let name = signature_to_name(&signature) + .ok_or_else(|| InspectError::Failed(format!("malformed class signature {signature:?}")))?; + out.push(ClassCount { name, instances }); + } + Ok(out) + } + + /// Up to `min(limit, MAX_INSTANCES)` instances. One that was collected before its preview + /// is left out; any other error fails the list. + pub(super) async fn list_instances(&self, class_name: &str, limit: u32) -> Result, InspectError> { + let signature = name_to_signature(class_name).ok_or(InspectError::ClassNotLoaded)?; + let sizes = self.sizes()?; + let reply = self.call(1, 2, wire::signature_body(&signature), ReplyIds::None).await?; + let classes = wire::classes_by_signature(&reply, &sizes)?; + if classes.is_empty() { + return Err(InspectError::ClassNotLoaded); + } + let wanted = limit.min(MAX_INSTANCES) as usize; + let mut ids = Vec::new(); + for class in classes { + // JDWP reads a maximum of 0 as "all of them". + let remaining = wanted - ids.len(); + if remaining == 0 { + break; + } + let body = wire::id_ints_body(class, sizes.reference_type, &[remaining as u32]); + let reply = self.call(2, 16, body, ReplyIds::TaggedObjects).await?; + ids.extend(wire::tagged_objects(&reply, &sizes)?.into_iter().take(remaining)); + } + let mut out = Vec::with_capacity(ids.len()); + for id in ids { + match self.preview_object(id).await { + Ok(value) => out.push(value), + Err(InspectError::Collected) => {} + Err(e) => return Err(e), + } + } + Ok(out) + } +} diff --git a/crates/ide-jdwp/src/inspector/fake.rs b/crates/ide-jdwp/src/inspector/fake.rs new file mode 100644 index 000000000..da00cd7e9 --- /dev/null +++ b/crates/ide-jdwp/src/inspector/fake.rs @@ -0,0 +1,529 @@ +//! A fake JVM for the inspector tests: a small heap model that answers the JDWP commands the +//! inspector sends (ids are 8 bytes wide) and records every one of them. The link's +//! `SuspendCount` checks are answered with 1 and not recorded. + +use super::LinkInspector; +use crate::codec::{FLAG_REPLY, HEADER_LEN, Header, Reader, Writer}; +use crate::link::{JdwpLink, LinkOptions, LinkState}; +use ide_jvm_inspect::VmTarget; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt, DuplexStream, ReadHalf, WriteHalf}; +use tokio::sync::Notify; +use tokio::task::JoinHandle; + +const HANDSHAKE: &[u8; 14] = b"JDWP-Handshake"; +const WAIT: Duration = Duration::from_secs(5); +const THREAD: u64 = 9; +const UNHANDLED: u16 = 0x7fff; +const STATIC: i32 = 0x0008; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) enum Val { + Null, + Int(i32), + Byte(u8), + Obj(u64), +} + +struct Field { + id: u64, + name: String, + signature: String, + mod_bits: i32, +} + +struct Class { + id: u64, + signature: String, + superclass: u64, + fields: Vec, +} + +enum Object { + Instance { class: u64, values: HashMap }, + Array { class: u64, length: u32, elements: Vec }, +} + +impl Object { + fn class(&self) -> u64 { + match self { + Object::Instance { class, .. } | Object::Array { class, .. } => *class, + } + } +} + +pub(super) struct Heap { + next: u64, + classes: Vec, + objects: BTreeMap, + texts: HashMap, + instances: HashMap>, + errors: HashMap<(u8, u8, u64), u16>, + holds: HashSet<(u8, u8, u64)>, + pub(super) object_class: u64, +} + +impl Heap { + pub(super) fn new() -> Heap { + let mut heap = Heap { + next: 0x100, + classes: Vec::new(), + objects: BTreeMap::new(), + texts: HashMap::new(), + instances: HashMap::new(), + errors: HashMap::new(), + holds: HashSet::new(), + object_class: 0, + }; + heap.object_class = heap.class("Ljava/lang/Object;", 0, &[]); + let object = heap.object_class; + heap.class( + "Ljava/lang/String;", + object, + &[("value", "[B", false), ("coder", "B", false), ("hash", "I", false), ("COMPACT_STRINGS", "Z", true)], + ); + heap.class("Ljava/lang/Integer;", object, &[("value", "I", false), ("MAX_VALUE", "I", true)]); + heap + } + + fn fresh(&mut self) -> u64 { + self.next += 1; + self.next + } + + pub(super) fn class_count(&self) -> usize { + self.classes.len() + } + + pub(super) fn class_id(&self, signature: &str) -> u64 { + self.classes.iter().find(|c| c.signature == signature).map_or(0, |c| c.id) + } + + /// `fields` is `(name, signature, is_static)`. + pub(super) fn class(&mut self, signature: &str, superclass: u64, fields: &[(&str, &str, bool)]) -> u64 { + let id = self.fresh(); + let fields = fields + .iter() + .map(|&(name, signature, is_static)| Field { + id: self.fresh(), + name: name.into(), + signature: signature.into(), + mod_bits: if is_static { STATIC } else { 0 }, + }) + .collect(); + self.classes.push(Class { id, signature: signature.into(), superclass, fields }); + id + } + + fn array_class(&mut self, signature: &str) -> u64 { + match self.class_id(signature) { + 0 => self.class(signature, self.object_class, &[]), + id => id, + } + } + + fn field_id(&self, class: u64, name: &str) -> u64 { + let mut current = class; + while let Some(c) = self.classes.iter().find(|c| c.id == current) { + if let Some(f) = c.fields.iter().find(|f| f.name == name && f.mod_bits & STATIC == 0) { + return f.id; + } + current = c.superclass; + } + panic!("no instance field {name}"); + } + + pub(super) fn object(&mut self, class: u64, values: &[(&str, Val)]) -> u64 { + let values = values.iter().map(|&(name, v)| (self.field_id(class, name), v)).collect(); + let id = self.fresh(); + self.objects.insert(id, Object::Instance { class, values }); + id + } + + pub(super) fn array(&mut self, signature: &str, length: u32, elements: Vec) -> u64 { + let class = self.array_class(signature); + let id = self.fresh(); + self.objects.insert(id, Object::Array { class, length, elements }); + id + } + + pub(super) fn string(&mut self, text: &str) -> u64 { + let bytes = text.bytes().map(Val::Byte).collect(); + let value = self.array("[B", text.len() as u32, bytes); + let id = self.string_object(value, 0); + self.texts.insert(id, text.into()); + id + } + + /// A `String` whose backing array has `array_length` elements, none of them readable. + pub(super) fn long_string(&mut self, array_length: u32, coder: u8) -> u64 { + let value = self.array("[B", array_length, Vec::new()); + self.string_object(value, coder) + } + + fn string_object(&mut self, value: u64, coder: u8) -> u64 { + let class = self.class_id("Ljava/lang/String;"); + self.object(class, &[("value", Val::Obj(value)), ("coder", Val::Byte(coder))]) + } + + pub(super) fn integer(&mut self, v: i32) -> u64 { + let class = self.class_id("Ljava/lang/Integer;"); + self.object(class, &[("value", Val::Int(v))]) + } + + /// What `ReferenceType.Instances` returns for `class`, instead of the objects of that class. + pub(super) fn list_instances(&mut self, class: u64, ids: &[u64]) { + self.instances.insert(class, ids.to_vec()); + } + + /// The command answers `code` when its body starts with the id `first`. + pub(super) fn fail(&mut self, set: u8, cmd: u8, first: u64, code: u16) { + self.errors.insert((set, cmd, first), code); + } + + /// The command is never answered when its body starts with the id `first`. + pub(super) fn hold(&mut self, set: u8, cmd: u8, first: u64) { + self.holds.insert((set, cmd, first)); + } + + fn class_of(&self, id: u64) -> &Class { + self.classes.iter().find(|c| c.id == id).unwrap_or_else(|| panic!("no class {id:#x}")) + } + + fn instances_of(&self, class: u64) -> Vec { + self.instances + .get(&class) + .cloned() + .unwrap_or_else(|| self.objects.iter().filter(|(_, o)| o.class() == class).map(|(&id, _)| id).collect()) + } + + fn put_tagged(&self, w: &mut Writer, v: Val) { + match v { + Val::Null => { + w.u8(b'L'); + w.id(0, 8); + } + Val::Int(i) => { + w.u8(b'I'); + w.i32(i); + } + Val::Byte(b) => { + w.u8(b'B'); + w.u8(b); + } + Val::Obj(id) => { + w.u8(self.object_tag(id)); + w.id(id, 8); + } + } + } + + fn object_tag(&self, id: u64) -> u8 { + match self.objects.get(&id) { + Some(Object::Array { .. }) => b'[', + _ => b'L', + } + } + + fn lookup(&self, id: u64) -> Result<&Object, u16> { + self.objects.get(&id).ok_or(20) + } + + fn answer(&self, set: u8, cmd: u8, body: &[u8]) -> Result, u16> { + let mut r = Reader::new(body); + let mut w = Writer::new(); + match (set, cmd) { + (1, 2) => { + let signature = r.string().unwrap(); + let found: Vec<_> = self.classes.iter().filter(|c| c.signature == signature).collect(); + w.i32(found.len() as i32); + for c in found { + w.u8(class_tag(&c.signature)); + w.id(c.id, 8); + w.i32(7); + } + } + (1, 3) => { + w.i32(self.classes.len() as i32); + for c in &self.classes { + w.u8(class_tag(&c.signature)); + w.id(c.id, 8); + w.string(&c.signature); + w.i32(7); + } + } + (1, 21) => { + let n = r.count().unwrap(); + w.i32(n as i32); + for _ in 0..n { + w.i64(self.instances_of(r.id(8).unwrap()).len() as i64); + } + } + (1, 9 | 14) => {} + (2, 1) => w.string(&self.class_of(r.id(8).unwrap()).signature), + (2, 4) => { + let class = self.class_of(r.id(8).unwrap()); + w.i32(class.fields.len() as i32); + for f in &class.fields { + w.id(f.id, 8); + w.string(&f.name); + w.string(&f.signature); + w.i32(f.mod_bits); + } + } + (2, 16) => { + let ids = self.instances_of(r.id(8).unwrap()); + let max = r.i32().unwrap(); + assert!(max > 0, "maxInstances 0 would mean all"); + let ids = &ids[..ids.len().min(max as usize)]; + w.i32(ids.len() as i32); + for &id in ids { + w.u8(self.object_tag(id)); + w.id(id, 8); + } + } + (3, 1) => w.id(self.class_of(r.id(8).unwrap()).superclass, 8), + (9, 1) => { + let object = self.lookup(r.id(8).unwrap())?; + w.u8(class_tag(&self.class_of(object.class()).signature)); + w.id(object.class(), 8); + } + (9, 2) => { + let Object::Instance { values, .. } = self.lookup(r.id(8).unwrap())? else { + return Err(UNHANDLED); + }; + let n = r.count().unwrap(); + w.i32(n as i32); + for _ in 0..n { + self.put_tagged(&mut w, values.get(&r.id(8).unwrap()).copied().ok_or(UNHANDLED)?); + } + } + (9, 10) => { + let target = r.id(8).unwrap(); + self.lookup(target)?; + let max = r.i32().unwrap(); + assert!(max > 0, "maxReferrers 0 would mean all"); + let referrers: Vec = self + .objects + .iter() + .filter(|(_, o)| match o { + Object::Instance { values, .. } => values.values().any(|v| *v == Val::Obj(target)), + Object::Array { elements, .. } => elements.contains(&Val::Obj(target)), + }) + .map(|(&id, _)| id) + .take(max as usize) + .collect(); + w.i32(referrers.len() as i32); + for id in referrers { + w.u8(self.object_tag(id)); + w.id(id, 8); + } + } + (10, 1) => w.string(self.texts.get(&r.id(8).unwrap()).ok_or(UNHANDLED)?), + (13, 1) => { + let Object::Array { length, .. } = self.lookup(r.id(8).unwrap())? else { + return Err(UNHANDLED); + }; + w.i32(*length as i32); + } + (13, 2) => { + let Object::Array { class, elements, .. } = self.lookup(r.id(8).unwrap())? else { + return Err(UNHANDLED); + }; + let first = r.i32().unwrap() as usize; + let length = r.i32().unwrap() as usize; + let region = elements.get(first..first + length).ok_or(UNHANDLED)?; + let tag = match self.class_of(*class).signature.as_str() { + "[I" => b'I', + "[B" => b'B', + _ => b'L', + }; + w.u8(tag); + w.i32(length as i32); + for &v in region { + match (tag, v) { + (b'L', v) => self.put_tagged(&mut w, v), + (_, Val::Int(i)) => w.i32(i), + (_, Val::Byte(b)) => w.u8(b), + _ => return Err(UNHANDLED), + } + } + } + _ => return Err(UNHANDLED), + } + Ok(w.into_vec()) + } +} + +fn class_tag(signature: &str) -> u8 { + if signature.starts_with('[') { 3 } else { 1 } +} + +pub(super) fn packet(id: u32, set: u8, cmd: u8, body: &[u8]) -> Vec { + let mut out = Header::command_bytes(id, set, cmd, body.len()).to_vec(); + out.extend_from_slice(body); + out +} + +fn reply(id: u32, error: u16, body: &[u8]) -> Vec { + let mut out = packet(id, 0, 0, body); + out[8] = FLAG_REPLY; + out[9..11].copy_from_slice(&error.to_be_bytes()); + out +} + +async fn read_packet(stream: &mut (impl AsyncRead + Unpin)) -> std::io::Result> { + let mut header = [0; HEADER_LEN]; + stream.read_exact(&mut header).await?; + let mut out = vec![0; Header::parse(&header).length as usize]; + out[..HEADER_LEN].copy_from_slice(&header); + stream.read_exact(&mut out[HEADER_LEN..]).await?; + Ok(out) +} + +async fn within(what: &str, f: impl Future) -> T { + tokio::time::timeout(WAIT, f).await.unwrap_or_else(|_| panic!("timed out: {what}")) +} + +fn breakpoint() -> Vec { + let mut w = Writer::new(); + w.u8(2); + w.i32(1); + w.u8(2); + w.i32(3); + w.id(THREAD, 8); + packet(0x100, 64, 100, &w.into_vec()) +} + +/// `(command set, command, body)` of each request the JVM received. +type Log = Arc)>>>; + +pub(super) struct Fake { + pub(super) inspector: LinkInspector, + pub(super) link: JdwpLink, + pub(super) adapter: DuplexStream, + /// Signalled when a request in `Heap::hold` arrives. + pub(super) held: Arc, + log: Log, + jvm: Arc>>, + task: JoinHandle<()>, +} + +impl Drop for Fake { + fn drop(&mut self) { + self.task.abort(); + } +} + +pub(super) fn target() -> VmTarget { + VmTarget { pid: None, attach_host: None, attach_port: None, generation: 0 } +} + +impl Fake { + /// A link whose VM stopped at a breakpoint of thread 9 (generation 1). + pub(super) async fn start(heap: Heap) -> Fake { + let (mut adapter, link_adapter) = tokio::io::duplex(1 << 20); + let (link_jvm, mut jvm) = tokio::io::duplex(1 << 20); + let link = JdwpLink::spawn_over(link_adapter, link_jvm, LinkOptions { hold_resumes: false }); + adapter.write_all(HANDSHAKE).await.unwrap(); + let mut buf = [0; 14]; + within("adapter handshake", jvm.read_exact(&mut buf)).await.unwrap(); + jvm.write_all(HANDSHAKE).await.unwrap(); + within("jvm handshake", adapter.read_exact(&mut buf)).await.unwrap(); + let own = within("IDSizes", read_packet(&mut jvm)).await.unwrap(); + let mut w = Writer::new(); + for _ in 0..5 { + w.i32(8); + } + jvm.write_all(&reply(Header::parse(own[..HEADER_LEN].try_into().unwrap()).id, 0, &w.into_vec())).await.unwrap(); + jvm.write_all(&breakpoint()).await.unwrap(); + + let (rd, wr) = tokio::io::split(jvm); + let jvm = Arc::new(tokio::sync::Mutex::new(wr)); + let log = Arc::new(Mutex::new(Vec::new())); + let held = Arc::new(Notify::new()); + let task = tokio::spawn(serve(heap, rd, jvm.clone(), log.clone(), held.clone())); + let fake = Fake { inspector: LinkInspector::new(link.clone()), link, adapter, held, log, jvm, task }; + fake.wait_suspended(1).await; + fake + } + + async fn wait_suspended(&self, generation: u64) { + let want = LinkState::Suspended { generation, thread: Some(THREAD) }; + let mut states = self.link.subscribe(); + let seen = tokio::time::timeout(WAIT, states.wait_for(|s| *s == want)).await; + assert!(matches!(seen, Ok(Ok(_))), "state {:?}, wanted {want:?}", self.link.state()); + } + + /// The adapter resumes the VM, which then stops again at a breakpoint. + pub(super) async fn next_stop(&mut self) { + let generation = self.link.generation() + 1; + let resumes = self.count(1, 9); + self.adapter.write_all(&packet(50, 1, 9, &[])).await.unwrap(); + // A JVM stops again only after the resume reaches it; the link reads both directions apart. + within("the resume", async { + while self.count(1, 9) == resumes { + tokio::task::yield_now().await; + } + }) + .await; + let mut event = breakpoint(); + event[4..8].copy_from_slice(&0x101u32.to_be_bytes()); + self.jvm.lock().await.write_all(&event).await.unwrap(); + self.wait_suspended(generation).await; + } + + /// Bodies of the requests with this command, in order. + pub(super) fn requests(&self, set: u8, cmd: u8) -> Vec> { + let log = self.log.lock().unwrap(); + log.iter().filter(|(s, c, _)| (*s, *c) == (set, cmd)).map(|(_, _, body)| body.clone()).collect() + } + + pub(super) fn count(&self, set: u8, cmd: u8) -> usize { + self.requests(set, cmd).len() + } + + pub(super) fn commands(&self) -> Vec<(u8, u8)> { + self.log.lock().unwrap().iter().map(|(s, c, _)| (*s, *c)).collect() + } +} + +async fn serve( + heap: Heap, + mut rd: ReadHalf, + wr: Arc>>, + log: Log, + held: Arc, +) { + while let Ok(packet) = read_packet(&mut rd).await { + let h = Header::parse(packet[..HEADER_LEN].try_into().unwrap()); + if h.is_reply() { + continue; + } + let (set, cmd) = h.command(); + let body = &packet[HEADER_LEN..]; + let out = if (set, cmd) == (11, 12) { + Ok(1i32.to_be_bytes().to_vec()) + } else { + log.lock().unwrap().push((set, cmd, body.to_vec())); + let first = Reader::new(body).id(8).unwrap_or(0); + if heap.holds.contains(&(set, cmd, first)) { + held.notify_one(); + continue; + } + match heap.errors.get(&(set, cmd, first)) { + Some(&code) => Err(code), + None => heap.answer(set, cmd, body), + } + }; + let bytes = match out { + Ok(body) => reply(h.id, 0, &body), + Err(code) => reply(h.id, code, &[]), + }; + if wr.lock().await.write_all(&bytes).await.is_err() { + return; + } + } +} diff --git a/crates/ide-jdwp/src/inspector/fields.rs b/crates/ide-jdwp/src/inspector/fields.rs new file mode 100644 index 000000000..0b0c8f524 --- /dev/null +++ b/crates/ide-jdwp/src/inspector/fields.rs @@ -0,0 +1,48 @@ +//! The fields of an object or the leading elements of an array. + +use super::LinkInspector; +use crate::codec::Value; +use ide_jvm_inspect::{ARRAY_PREVIEW, InspectError, JvmField}; + +impl LinkInspector { + /// The previewed fields, `[i]` for the first [`ARRAY_PREVIEW`] elements of an array. + pub(super) async fn list_fields(&self, object: u64) -> Result, InspectError> { + let class = self.class_of(object).await?; + let named = if self.class_name(class).await?.ends_with("[]") { + let length = self.array_length(object).await?; + let elements = self.array_elements(object, 0, length.min(ARRAY_PREVIEW)).await?; + index_names(elements) + } else { + self.instance_fields(object, class).await? + }; + let mut out = Vec::with_capacity(named.len()); + for (name, value) in named { + out.push(JvmField { name, value: self.preview(value).await? }); + } + Ok(out) + } + + /// Every instance field of `object`, its own class first, then each superclass; static + /// fields are skipped. Values are not previewed. + pub(super) async fn instance_fields(&self, object: u64, class: u64) -> Result, InspectError> { + let mut names = Vec::new(); + let mut ids = Vec::new(); + let mut current = class; + while current != 0 { + for field in self.declared_fields(current).await?.iter().filter(|f| !f.is_static) { + names.push(field.name.clone()); + ids.push(field.id); + } + current = self.superclass_of(current).await?; + } + if ids.is_empty() { + return Ok(Vec::new()); + } + let values = self.read_values(object, &ids).await?; + Ok(names.into_iter().zip(values).collect()) + } +} + +pub(super) fn index_names(elements: Vec) -> Vec<(String, Value)> { + elements.into_iter().enumerate().map(|(i, value)| (format!("[{i}]"), value)).collect() +} diff --git a/crates/ide-jdwp/src/inspector/lookup.rs b/crates/ide-jdwp/src/inspector/lookup.rs new file mode 100644 index 000000000..6570cd89c --- /dev/null +++ b/crates/ide-jdwp/src/inspector/lookup.rs @@ -0,0 +1,109 @@ +//! Single JDWP lookups. Class facts are cached for the stop they were read in. + +use super::{LinkInspector, wire}; +use crate::codec::Value; +use crate::ids::ReplyIds; +use ide_jvm_inspect::{InspectError, signature_to_name}; +use std::sync::{Arc, PoisonError}; +use wire::FieldInfo; + +impl LinkInspector { + fn with_caches(&self, generation: u64, f: impl FnOnce(&mut super::Caches) -> R) -> R { + let mut caches = self.caches.lock().unwrap_or_else(PoisonError::into_inner); + f(caches.at(generation)) + } + + /// Keeps `f`'s entry unless the stop changed since `generation`. + fn remember(&self, generation: u64, f: impl FnOnce(&mut super::Caches)) { + let mut caches = self.caches.lock().unwrap_or_else(PoisonError::into_inner); + if caches.accepts(generation) { + f(&mut caches); + } + } + + /// `ObjectReference.ReferenceType`: the class of `object`. + pub(super) async fn class_of(&self, object: u64) -> Result { + let sizes = self.sizes()?; + let reply = self.call(9, 1, wire::id_body(object, sizes.object), ReplyIds::None).await?; + wire::reference_type(&reply, &sizes) + } + + /// Display name of a class (`int[]`, `com.x.Foo$Bar`). + pub(super) async fn class_name(&self, class: u64) -> Result { + let generation = self.link.generation(); + if let Some(name) = self.with_caches(generation, |c| c.names.get(&class).cloned()) { + return Ok(name); + } + let sizes = self.sizes()?; + let reply = self.call(2, 1, wire::id_body(class, sizes.reference_type), ReplyIds::None).await?; + let signature = wire::string(&reply)?; + let name = signature_to_name(&signature) + .ok_or_else(|| InspectError::Failed(format!("malformed class signature {signature:?}")))?; + self.remember(generation, |c| { + c.names.insert(class, name.clone()); + }); + Ok(name) + } + + /// The fields a class declares, statics included. + pub(super) async fn declared_fields(&self, class: u64) -> Result>, InspectError> { + let generation = self.link.generation(); + if let Some(fields) = self.with_caches(generation, |c| c.fields.get(&class).cloned()) { + return Ok(fields); + } + let sizes = self.sizes()?; + let reply = self.call(2, 4, wire::id_body(class, sizes.reference_type), ReplyIds::None).await?; + let fields = Arc::new(wire::fields(&reply, &sizes)?); + self.remember(generation, |c| { + c.fields.insert(class, fields.clone()); + }); + Ok(fields) + } + + /// The superclass of a class, 0 for `java.lang.Object`. + pub(super) async fn superclass_of(&self, class: u64) -> Result { + let generation = self.link.generation(); + if let Some(superclass) = self.with_caches(generation, |c| c.superclasses.get(&class).copied()) { + return Ok(superclass); + } + let sizes = self.sizes()?; + let reply = self.call(3, 1, wire::id_body(class, sizes.reference_type), ReplyIds::None).await?; + let superclass = wire::superclass(&reply, &sizes)?; + self.remember(generation, |c| { + c.superclasses.insert(class, superclass); + }); + Ok(superclass) + } + + /// `ObjectReference.GetValues`, in the order of `fields`. + pub(super) async fn read_values(&self, object: u64, fields: &[u64]) -> Result, InspectError> { + let sizes = self.sizes()?; + let body = wire::get_values_body(object, fields, &sizes); + let reply = self.call(9, 2, body, ReplyIds::Values).await?; + let values = wire::values(&reply, &sizes)?; + if values.len() != fields.len() { + return Err(InspectError::Failed(format!("{} values for {} fields", values.len(), fields.len()))); + } + Ok(values) + } + + pub(super) async fn array_length(&self, array: u64) -> Result { + let sizes = self.sizes()?; + wire::length(&self.call(13, 1, wire::id_body(array, sizes.object), ReplyIds::None).await?) + } + + /// `length` elements from `first`; nothing is sent for an empty range. + pub(super) async fn array_elements(&self, array: u64, first: u32, length: u32) -> Result, InspectError> { + if length == 0 { + return Ok(Vec::new()); + } + let sizes = self.sizes()?; + let body = wire::id_ints_body(array, sizes.object, &[first, length]); + let reply = self.call(13, 2, body, ReplyIds::ArrayRegion).await?; + let elements = wire::array_region(&reply, &sizes)?; + if elements.len() != length as usize { + return Err(InspectError::Failed(format!("{} elements for a range of {length}", elements.len()))); + } + Ok(elements) + } +} diff --git a/crates/ide-jdwp/src/inspector/preview.rs b/crates/ide-jdwp/src/inspector/preview.rs new file mode 100644 index 000000000..678557423 --- /dev/null +++ b/crates/ide-jdwp/src/inspector/preview.rs @@ -0,0 +1,81 @@ +//! One-line previews of values. They only read: no `toString`. + +use super::{LinkInspector, wire}; +use crate::codec::{Value, primitive_text}; +use crate::ids::ReplyIds; +use ide_jvm_inspect::{InspectError, JvmValue, STRING_PREVIEW_CHARS, is_boxed, signature_to_name, string_length}; + +const STRING: &str = "java.lang.String"; + +impl LinkInspector { + pub(super) async fn preview(&self, value: Value) -> Result { + match value { + Value::Null => Ok(JvmValue::Null), + Value::Primitive { tag, raw } => primitive(tag, raw), + Value::Object { id, .. } => self.preview_object(id).await, + } + } + + /// `Err(Collected)` when the object is gone. + pub(super) async fn preview_object(&self, id: u64) -> Result { + let class = self.class_of(id).await?; + let type_name = self.class_name(class).await?; + if type_name.ends_with("[]") { + let length = self.array_length(id).await?; + return Ok(JvmValue::Array { id, type_name, length }); + } + if type_name == STRING { + return self.preview_string(id, class).await; + } + if is_boxed(&type_name) { + let text = self.boxed_text(id, class).await?; + return Ok(JvmValue::Boxed { id, type_name, text }); + } + Ok(JvmValue::Object { id, type_name }) + } + + /// Reads the length of the backing array, and the text only when it is short. + async fn preview_string(&self, id: u64, class: u64) -> Result { + let fields = self.declared_fields(class).await?; + let field = |name: &str| fields.iter().find(|f| f.name == name && !f.is_static).map(|f| f.id); + let value = field("value").ok_or_else(|| InspectError::Failed(format!("{STRING} has no value field")))?; + // JDK 8 has no `coder`: one `char` per element. + let wanted: Vec = std::iter::once(value).chain(field("coder")).collect(); + let read = self.read_values(id, &wanted).await?; + let array_length = match read[0] { + Value::Object { id: array, .. } => self.array_length(array).await?, + _ => 0, + }; + let coder = match read.get(1) { + None => None, + Some(Value::Primitive { raw, .. }) => Some(*raw as u8), + Some(other) => return Err(InspectError::Failed(format!("unexpected String.coder {other:?}"))), + }; + let length = string_length(array_length, coder); + let text = if length <= STRING_PREVIEW_CHARS { + let sizes = self.sizes()?; + Some(wire::string(&self.call(10, 1, wire::id_body(id, sizes.object), ReplyIds::None).await?)?) + } else { + None + }; + Ok(JvmValue::Str { id, text, length }) + } + + async fn boxed_text(&self, id: u64, class: u64) -> Result { + let fields = self.declared_fields(class).await?; + let value = fields + .iter() + .find(|f| f.name == "value" && !f.is_static) + .ok_or_else(|| InspectError::Failed("a boxed type has no value field".into()))?; + match self.read_values(id, &[value.id]).await?[0] { + Value::Primitive { tag, raw } => Ok(primitive_text(tag, raw)), + other => Err(InspectError::Failed(format!("unexpected boxed value {other:?}"))), + } + } +} + +fn primitive(tag: u8, raw: u64) -> Result { + let type_name = signature_to_name(&char::from(tag).to_string()) + .ok_or_else(|| InspectError::Failed(format!("unknown primitive tag {tag:#04x}")))?; + Ok(JvmValue::Primitive { type_name, text: primitive_text(tag, raw) }) +} diff --git a/crates/ide-jdwp/src/inspector/referrers.rs b/crates/ide-jdwp/src/inspector/referrers.rs new file mode 100644 index 000000000..e802d8dcb --- /dev/null +++ b/crates/ide-jdwp/src/inspector/referrers.rs @@ -0,0 +1,52 @@ +//! The objects that refer to an object, and through which field or slot. + +use super::{LinkInspector, fields::index_names, wire}; +use crate::codec::Value; +use crate::ids::ReplyIds; +use ide_jvm_inspect::{InspectError, JvmReferrer, JvmValue, MAX_REFERRERS, REFERRER_ARRAY_SCAN}; + +impl LinkInspector { + /// Up to `min(limit, MAX_REFERRERS)` referrers. One that was collected before it could be + /// read is left out; any other error fails the list. + pub(super) async fn list_referrers(&self, object: u64, limit: u32) -> Result, InspectError> { + let wanted = limit.min(MAX_REFERRERS); + // JDWP reads a maximum of 0 as "all of them". + if wanted == 0 { + return Ok(Vec::new()); + } + let sizes = self.sizes()?; + let body = wire::id_ints_body(object, sizes.object, &[wanted]); + let reply = self.call(9, 10, body, ReplyIds::TaggedObjects).await?; + let ids = wire::tagged_objects(&reply, &sizes)?; + let mut out = Vec::with_capacity(ids.len()); + for id in ids.into_iter().take(wanted as usize) { + match self.referrer(id, object).await { + Ok(referrer) => out.push(referrer), + Err(InspectError::Collected) => {} + Err(e) => return Err(e), + } + } + Ok(out) + } + + async fn referrer(&self, id: u64, target: u64) -> Result { + let value = self.preview_object(id).await?; + let slots = match &value { + JvmValue::Array { length, .. } if *length <= REFERRER_ARRAY_SCAN => { + index_names(self.array_elements(id, 0, *length).await?) + } + // too large to look for the index + JvmValue::Array { .. } => Vec::new(), + _ => { + let class = self.class_of(id).await?; + self.instance_fields(id, class).await? + } + }; + let via = slots + .into_iter() + .filter(|(_, slot)| matches!(slot, Value::Object { id, .. } if *id == target)) + .map(|(name, _)| name) + .collect(); + Ok(JvmReferrer { value, via }) + } +} diff --git a/crates/ide-jdwp/src/inspector/tests.rs b/crates/ide-jdwp/src/inspector/tests.rs new file mode 100644 index 000000000..24a45911d --- /dev/null +++ b/crates/ide-jdwp/src/inspector/tests.rs @@ -0,0 +1,398 @@ +use super::fake::{Fake, Heap, Val, packet, target}; +use super::*; +use crate::codec::Reader; +use ide_jvm_inspect::{JvmInspector, REFERRER_ARRAY_SCAN, STRING_PREVIEW_CHARS}; +use std::time::Duration; +use tokio::io::AsyncWriteExt; + +const WAIT: Duration = Duration::from_secs(10); + +async fn within(f: impl Future) -> T { + tokio::time::timeout(WAIT, f).await.expect("timed out") +} + +fn object(id: u64, type_name: &str) -> JvmValue { + JvmValue::Object { id, type_name: type_name.into() } +} + +fn int(n: i32) -> JvmValue { + JvmValue::Primitive { type_name: "int".into(), text: n.to_string() } +} + +/// `(id, id, int)` of a request body: the object or class, then the two numbers that follow. +fn ids_and_ints(body: &[u8]) -> (u64, i32, i32) { + let mut r = Reader::new(body); + (r.id(8).unwrap(), r.i32().unwrap_or(-1), r.i32().unwrap_or(-1)) +} + +fn foo_heap(count: usize) -> (Heap, Vec) { + let mut heap = Heap::new(); + let foo = heap.class("Lcom/x/Foo;", heap.object_class, &[]); + let objects = (0..count).map(|_| heap.object(foo, &[])).collect(); + (heap, objects) +} + +#[tokio::test] +async fn previews_strings_boxed_objects_arrays() { + let mut heap = Heap::new(); + let foo = heap.class("Lcom/x/Foo;", heap.object_class, &[]); + let s = heap.string("hi"); + let i = heap.integer(42); + let o = heap.object(foo, &[]); + let a = heap.array("[I", 3, vec![Val::Int(1), Val::Int(2), Val::Int(3)]); + heap.list_instances(heap.object_class, &[s, i, o, a]); + let fake = Fake::start(heap).await; + + let values = within(fake.inspector.instances(&target(), "java.lang.Object", 1000)).await.unwrap(); + assert_eq!( + values, + vec![ + JvmValue::Str { id: s, text: Some("hi".into()), length: 2 }, + JvmValue::Boxed { id: i, type_name: "java.lang.Integer".into(), text: "42".into() }, + object(o, "com.x.Foo"), + JvmValue::Array { id: a, type_name: "int[]".into(), length: 3 }, + ] + ); +} + +#[tokio::test] +async fn long_string_preview_reads_length_only() { + let mut heap = Heap::new(); + let long = heap.long_string(100_000_000, 1); + heap.list_instances(heap.class_id("Ljava/lang/String;"), &[long]); + let fake = Fake::start(heap).await; + + let values = within(fake.inspector.instances(&target(), "java.lang.String", 1000)).await.unwrap(); + assert_eq!(values, vec![JvmValue::Str { id: long, text: None, length: 50_000_000 }]); + assert_eq!(fake.count(10, 1), 0, "the text of a long string is never read"); + assert_eq!(fake.count(13, 2), 0); +} + +#[tokio::test] +async fn string_text_is_read_up_to_the_preview_limit() { + let mut heap = Heap::new(); + let ok = heap.string(&"x".repeat(STRING_PREVIEW_CHARS as usize)); + let over = heap.string(&"y".repeat(STRING_PREVIEW_CHARS as usize + 1)); + heap.list_instances(heap.class_id("Ljava/lang/String;"), &[ok, over]); + let fake = Fake::start(heap).await; + + let values = within(fake.inspector.instances(&target(), "java.lang.String", 1000)).await.unwrap(); + assert_eq!( + values, + vec![ + JvmValue::Str { id: ok, text: Some("x".repeat(1000)), length: 1000 }, + JvmValue::Str { id: over, text: None, length: 1001 }, + ] + ); + assert_eq!(fake.count(10, 1), 1); +} + +#[tokio::test] +async fn fields_walk_superclasses_and_skip_statics() { + let mut heap = Heap::new(); + let base = heap.class("Lcom/x/Base;", heap.object_class, &[("id", "I", false), ("COUNT", "I", true)]); + let sub = heap.class( + "Lcom/x/Sub;", + base, + &[("name", "Ljava/lang/String;", false), ("next", "Lcom/x/Sub;", false), ("NAMES", "Ljava/util/List;", true)], + ); + let name = heap.string("bob"); + let target_object = heap.object(sub, &[("name", Val::Obj(name)), ("next", Val::Null), ("id", Val::Int(7))]); + let fake = Fake::start(heap).await; + + let fields = within(fake.inspector.fields(&target(), target_object)).await.unwrap(); + let shown: Vec<_> = fields.iter().map(|f| (f.name.as_str(), f.value.clone())).collect(); + assert_eq!( + shown, + vec![ + ("name", JvmValue::Str { id: name, text: Some("bob".into()), length: 3 }), + ("next", JvmValue::Null), + ("id", int(7)), + ] + ); + let reads = fake.requests(9, 2); + assert_eq!(reads.len(), 2, "one read for the object's fields, one for the string's"); + assert_eq!(ids_and_ints(&reads[0]).1, 3, "no static field is read"); + // Sub, Base, Object: the walk ends at the class without a superclass + assert_eq!(fake.count(3, 1), 3); +} + +#[tokio::test] +async fn array_fields_show_first_100() { + let mut heap = Heap::new(); + let array = heap.array("[I", 5000, (0..5000).map(Val::Int).collect()); + let fake = Fake::start(heap).await; + + let fields = within(fake.inspector.fields(&target(), array)).await.unwrap(); + assert_eq!(fields.len(), 100); + assert_eq!(fields[0].name, "[0]"); + assert_eq!(fields[99].name, "[99]"); + assert_eq!(fields[99].value, int(99)); + let reads = fake.requests(13, 2); + assert_eq!(reads.len(), 1); + assert_eq!(ids_and_ints(&reads[0]), (array, 0, 100)); +} + +#[tokio::test] +async fn short_and_empty_arrays_show_all_they_have() { + let mut heap = Heap::new(); + let owner = heap.class("Lcom/x/Owner;", heap.object_class, &[]); + let item = heap.object(owner, &[]); + let short = heap.array("[Ljava/lang/Object;", 2, vec![Val::Obj(item), Val::Null]); + let empty = heap.array("[I", 0, Vec::new()); + let fake = Fake::start(heap).await; + + let fields = within(fake.inspector.fields(&target(), short)).await.unwrap(); + let shown: Vec<_> = fields.iter().map(|f| (f.name.as_str(), f.value.clone())).collect(); + assert_eq!(shown, vec![("[0]", object(item, "com.x.Owner")), ("[1]", JvmValue::Null)]); + assert_eq!(within(fake.inspector.fields(&target(), empty)).await.unwrap(), vec![]); + assert_eq!(fake.count(13, 2), 1, "an empty array reads nothing"); +} + +#[tokio::test] +async fn referrers_name_the_field() { + let mut heap = Heap::new(); + let item_class = heap.class("Lcom/x/Item;", heap.object_class, &[]); + let owner_class = heap.class( + "Lcom/x/Owner;", + heap.object_class, + &[("item", "Lcom/x/Item;", false), ("other", "Lcom/x/Item;", false)], + ); + let item = heap.object(item_class, &[]); + let other = heap.object(item_class, &[]); + let owner = heap.object(owner_class, &[("item", Val::Obj(item)), ("other", Val::Obj(other))]); + let array = heap.array("[Ljava/lang/Object;", 3, vec![Val::Null, Val::Obj(item), Val::Null]); + let fake = Fake::start(heap).await; + + let referrers = within(fake.inspector.referrers(&target(), item, 50)).await.unwrap(); + assert_eq!( + referrers, + vec![ + JvmReferrer { value: object(owner, "com.x.Owner"), via: vec!["item".into()] }, + JvmReferrer { + value: JvmValue::Array { id: array, type_name: "java.lang.Object[]".into(), length: 3 }, + via: vec!["[1]".into()], + }, + ] + ); + assert_eq!(ids_and_ints(&fake.requests(9, 10)[0]), (item, 50, -1)); +} + +#[tokio::test] +async fn referrer_arrays_are_scanned_only_up_to_the_limit() { + let (mut heap, items) = foo_heap(1); + let item = items[0]; + let mut scanned = vec![Val::Null; REFERRER_ARRAY_SCAN as usize]; + scanned[9999] = Val::Obj(item); + let scanned_id = heap.array("[Ljava/lang/Object;", REFERRER_ARRAY_SCAN, scanned); + let big_id = heap.array("[Ljava/lang/Object;", REFERRER_ARRAY_SCAN + 1, vec![Val::Obj(item)]); + let fake = Fake::start(heap).await; + + let referrers = within(fake.inspector.referrers(&target(), item, 50)).await.unwrap(); + assert_eq!(referrers.len(), 2); + assert_eq!(referrers[0].value.object_id(), Some(scanned_id)); + assert_eq!(referrers[0].via, vec!["[9999]".to_string()]); + assert_eq!(referrers[1].value.object_id(), Some(big_id)); + assert_eq!(referrers[1].via, Vec::::new(), "too large to look for the index"); + assert_eq!(fake.count(13, 2), 1); +} + +#[tokio::test] +async fn class_counts_send_one_instance_counts_packet() { + let mut heap = Heap::new(); + let mut classes = Vec::new(); + while heap.class_count() < 599 { + let name = format!("Lcom/x/C{};", heap.class_count()); + classes.push(heap.class(&name, heap.object_class, &[])); + } + heap.array("[I", 0, Vec::new()); + assert_eq!(heap.class_count(), 600); + for _ in 0..2 { + heap.object(classes[0], &[]); + } + heap.object(classes[10], &[]); + let fake = Fake::start(heap).await; + + let counts = within(fake.inspector.class_counts(&target())).await.unwrap(); + assert_eq!( + counts, + vec![ + ClassCount { name: "com.x.C3".into(), instances: 2 }, + ClassCount { name: "com.x.C13".into(), instances: 1 }, + ClassCount { name: "int[]".into(), instances: 1 }, + ] + ); + let packets = fake.requests(1, 21); + assert_eq!(packets.len(), 1); + assert_eq!(Reader::new(&packets[0]).count().unwrap(), 600); + assert_eq!(packets[0].len(), 4 + 600 * 8); +} + +#[tokio::test] +async fn invalid_object_maps_to_collected() { + let (mut heap, objects) = foo_heap(2); + heap.fail(9, 1, objects[0], 20); + heap.fail(9, 10, objects[1], 20); + let fake = Fake::start(heap).await; + + assert_eq!(within(fake.inspector.fields(&target(), objects[0])).await, Err(InspectError::Collected)); + assert_eq!(within(fake.inspector.referrers(&target(), objects[1], 50)).await, Err(InspectError::Collected)); +} + +#[tokio::test] +async fn missing_class_is_not_loaded() { + let (mut heap, _) = foo_heap(1); + let foo = heap.class_id("Lcom/x/Foo;"); + heap.fail(2, 16, foo, 21); + let fake = Fake::start(heap).await; + + let t = target(); + let missing = fake.inspector.instances(&t, "com.x.Missing", 10); + assert_eq!(within(missing).await, Err(InspectError::ClassNotLoaded)); + assert_eq!(within(fake.inspector.instances(&t, "", 10)).await, Err(InspectError::ClassNotLoaded)); + assert_eq!(fake.count(2, 16), 0); + // JDWP INVALID_CLASS for a class that is known but not usable yet + let unusable = fake.inspector.instances(&t, "com.x.Foo", 10); + assert_eq!(within(unusable).await, Err(InspectError::ClassNotLoaded)); +} + +#[tokio::test] +async fn instances_skip_objects_gone_mid_preview() { + let (mut heap, objects) = foo_heap(3); + heap.fail(9, 1, objects[1], 20); + let fake = Fake::start(heap).await; + + let values = within(fake.inspector.instances(&target(), "com.x.Foo", 1000)).await.unwrap(); + assert_eq!(values, vec![object(objects[0], "com.x.Foo"), object(objects[2], "com.x.Foo")]); +} + +#[tokio::test] +async fn referrers_skip_objects_gone_mid_preview() { + let mut heap = Heap::new(); + let owner_class = heap.class("Lcom/x/Owner;", heap.object_class, &[("item", "Ljava/lang/Object;", false)]); + let target_object = heap.object(owner_class, &[]); + let owners: Vec = (0..4).map(|_| heap.object(owner_class, &[("item", Val::Obj(target_object))])).collect(); + heap.fail(9, 1, owners[1], 20); + heap.fail(9, 2, owners[2], 20); + let fake = Fake::start(heap).await; + + let referrers = within(fake.inspector.referrers(&target(), target_object, 50)).await.unwrap(); + let kept: Vec<_> = referrers.iter().map(|r| (r.value.object_id().unwrap(), r.via.clone())).collect(); + assert_eq!(kept, vec![(owners[0], vec!["item".to_string()]), (owners[3], vec!["item".to_string()])]); +} + +#[tokio::test] +async fn limits_are_capped_and_zero_asks_for_nothing() { + let (heap, objects) = foo_heap(3); + let fake = Fake::start(heap).await; + let t = target(); + + assert_eq!(within(fake.inspector.instances(&t, "com.x.Foo", 0)).await.unwrap(), vec![]); + assert_eq!(fake.count(2, 16), 0, "a count of 0 would mean every instance to the JVM"); + within(fake.inspector.instances(&t, "com.x.Foo", 5000)).await.unwrap(); + assert_eq!(ids_and_ints(&fake.requests(2, 16)[0]).1, 1000); + + assert_eq!(within(fake.inspector.referrers(&t, objects[0], 0)).await.unwrap(), vec![]); + assert_eq!(fake.count(9, 10), 0); + within(fake.inspector.referrers(&t, objects[0], 500)).await.unwrap(); + assert_eq!(ids_and_ints(&fake.requests(9, 10)[0]).1, 200); +} + +#[tokio::test] +async fn instances_continue_over_classes_until_the_limit() { + let mut heap = Heap::new(); + let first = heap.class("Lcom/x/Dup;", heap.object_class, &[]); + let second = heap.class("Lcom/x/Dup;", heap.object_class, &[]); + let a: Vec = (0..2).map(|_| heap.object(first, &[])).collect(); + let b: Vec = (0..2).map(|_| heap.object(second, &[])).collect(); + let fake = Fake::start(heap).await; + + let values = within(fake.inspector.instances(&target(), "com.x.Dup", 3)).await.unwrap(); + let ids: Vec<_> = values.iter().filter_map(JvmValue::object_id).collect(); + assert_eq!(ids, vec![a[0], a[1], b[0]]); + let asked: Vec<_> = fake.requests(2, 16).iter().map(|body| ids_and_ints(body)).collect(); + assert_eq!(asked, vec![(first, 3, -1), (second, 1, -1)]); +} + +#[tokio::test] +async fn a_resume_is_stale_not_a_skipped_object() { + let (mut heap, objects) = foo_heap(2); + heap.hold(9, 1, objects[1]); + let mut fake = Fake::start(heap).await; + + let t = target(); + let call = fake.inspector.instances(&t, "com.x.Foo", 1000); + let resume = async { + fake.held.notified().await; + fake.adapter.write_all(&packet(50, 1, 9, &[])).await.unwrap(); + }; + let (result, ()) = within(async { tokio::join!(call, resume) }).await; + assert_eq!(result, Err(InspectError::Stale)); +} + +#[tokio::test] +async fn class_and_field_lookups_are_cached_within_a_stop() { + let mut heap = Heap::new(); + let foo = heap.class("Lcom/x/Foo;", heap.object_class, &[]); + let objects: Vec = (0..3).map(|_| heap.object(foo, &[])).collect(); + let strings = [heap.string("a"), heap.string("b")]; + heap.list_instances(heap.class_id("Ljava/lang/String;"), &strings); + let mut fake = Fake::start(heap).await; + let t = target(); + + within(fake.inspector.instances(&t, "com.x.Foo", 1000)).await.unwrap(); + within(fake.inspector.instances(&t, "java.lang.String", 1000)).await.unwrap(); + within(fake.inspector.instances(&t, "com.x.Foo", 1000)).await.unwrap(); + assert_eq!(objects.len(), 3); + assert_eq!(fake.count(2, 1), 2, "one signature per class"); + assert_eq!(fake.count(2, 4), 1, "String's fields are listed once"); + + // reference types are only valid for the stop they were read in + fake.next_stop().await; + within(fake.inspector.instances(&t, "com.x.Foo", 1000)).await.unwrap(); + assert_eq!(fake.count(2, 1), 3); +} + +#[tokio::test] +async fn only_reads_are_sent() { + let mut heap = Heap::new(); + let owner_class = heap.class("Lcom/x/Owner;", heap.object_class, &[("item", "Ljava/lang/Object;", false)]); + let item = heap.object(owner_class, &[]); + let name = heap.string("n"); + let number = heap.integer(1); + let owner = heap.object(owner_class, &[("item", Val::Obj(item))]); + let array = heap.array("[Ljava/lang/Object;", 3, vec![Val::Obj(item), Val::Obj(name), Val::Obj(number)]); + let fake = Fake::start(heap).await; + let t = target(); + + within(fake.inspector.class_counts(&t)).await.unwrap(); + within(fake.inspector.instances(&t, "com.x.Owner", 1000)).await.unwrap(); + within(fake.inspector.fields(&t, owner)).await.unwrap(); + within(fake.inspector.fields(&t, array)).await.unwrap(); + within(fake.inspector.referrers(&t, item, 50)).await.unwrap(); + + // never an invoke, a resume, DisposeObjects or Enable/DisableCollection + const READS: [(u8, u8); 13] = + [(1, 2), (1, 3), (1, 21), (2, 1), (2, 4), (2, 16), (3, 1), (9, 1), (9, 2), (9, 10), (10, 1), (13, 1), (13, 2)]; + for command in fake.commands() { + assert!(READS.contains(&command), "unexpected command {command:?}"); + } +} + +#[test] +fn link_errors_map_to_inspect_errors() { + for (error, mapped) in [ + (LinkError::NotSuspended, InspectError::NotSuspended), + (LinkError::Stale, InspectError::Stale), + (LinkError::Degraded, InspectError::Unsupported("the JDWP link stopped inspecting".into())), + (LinkError::Closed, InspectError::Closed), + (LinkError::TimedOut, InspectError::TimedOut), + (LinkError::Busy, InspectError::TimedOut), + (LinkError::Jdwp(20), InspectError::Collected), + (LinkError::Jdwp(21), InspectError::ClassNotLoaded), + (LinkError::Jdwp(113), InspectError::Failed("JDWP error 113".into())), + (LinkError::Protocol("short".into()), InspectError::Failed("malformed JDWP reply: short".into())), + ] { + assert_eq!(map_link_error(error), mapped); + } +} diff --git a/crates/ide-jdwp/src/inspector/wire.rs b/crates/ide-jdwp/src/inspector/wire.rs new file mode 100644 index 000000000..d1b5c5008 --- /dev/null +++ b/crates/ide-jdwp/src/inspector/wire.rs @@ -0,0 +1,182 @@ +//! Request bodies and reply parsers for the JDWP commands the inspector uses. A reply that does +//! not parse exactly is an error, never a guess. + +use crate::codec::{CodecError, Reader, Value, Writer, is_object_tag}; +use crate::ids::IdSizes; +use ide_jvm_inspect::InspectError; + +/// `FieldModifier.STATIC`. +const STATIC: i32 = 0x0008; + +pub(super) struct FieldInfo { + pub(super) id: u64, + pub(super) name: String, + pub(super) is_static: bool, +} + +pub(super) fn malformed(e: CodecError) -> InspectError { + InspectError::Failed(format!("malformed JDWP reply: {e}")) +} + +/// Runs `read` over `body` and requires that it consumes all of it. +fn parse(body: &[u8], read: impl FnOnce(&mut Reader<'_>) -> Result) -> Result { + let mut r = Reader::new(body); + let out = read(&mut r).map_err(malformed)?; + match r.remaining() { + 0 => Ok(out), + extra => Err(InspectError::Failed(format!("malformed JDWP reply: {extra} bytes too many"))), + } +} + +pub(super) fn id_body(id: u64, size: u8) -> Vec { + let mut w = Writer::new(); + w.id(id, size); + w.into_vec() +} + +/// An id followed by `int`s: `Instances`, `ReferringObjects`, `ArrayReference.GetValues`. +pub(super) fn id_ints_body(id: u64, size: u8, ints: &[u32]) -> Vec { + let mut w = Writer::new(); + w.id(id, size); + for &n in ints { + w.i32(n as i32); + } + w.into_vec() +} + +pub(super) fn signature_body(signature: &str) -> Vec { + let mut w = Writer::new(); + w.string(signature); + w.into_vec() +} + +/// `ObjectReference.GetValues`: the object, then the fields to read. +pub(super) fn get_values_body(object: u64, fields: &[u64], sizes: &IdSizes) -> Vec { + let mut w = Writer::new(); + w.id(object, sizes.object); + w.i32(fields.len() as i32); + for &field in fields { + w.id(field, sizes.field); + } + w.into_vec() +} + +pub(super) fn instance_counts_body(classes: &[u64], sizes: &IdSizes) -> Vec { + let mut w = Writer::new(); + w.i32(classes.len() as i32); + for &class in classes { + w.id(class, sizes.reference_type); + } + w.into_vec() +} + +/// `ObjectReference.ReferenceType`: the class of the object. +pub(super) fn reference_type(body: &[u8], sizes: &IdSizes) -> Result { + parse(body, |r| { + r.u8()?; + r.id(sizes.reference_type) + }) +} + +pub(super) fn string(body: &[u8]) -> Result { + parse(body, |r| r.string()) +} + +pub(super) fn length(body: &[u8]) -> Result { + parse(body, |r| { + let n = r.i32()?; + u32::try_from(n).map_err(|_| CodecError::BadLength(n as u32)) + }) +} + +/// `ClassType.Superclass`; 0 when there is none. +pub(super) fn superclass(body: &[u8], sizes: &IdSizes) -> Result { + parse(body, |r| r.id(sizes.reference_type)) +} + +pub(super) fn fields(body: &[u8], sizes: &IdSizes) -> Result, InspectError> { + parse(body, |r| { + let mut out = Vec::new(); + for _ in 0..r.count()? { + let id = r.id(sizes.field)?; + let name = r.string()?; + r.string()?; + out.push(FieldInfo { id, name, is_static: r.i32()? & STATIC != 0 }); + } + Ok(out) + }) +} + +/// `GetValues`: `int n; (tag, value)*`. +pub(super) fn values(body: &[u8], sizes: &IdSizes) -> Result, InspectError> { + parse(body, |r| { + let mut out = Vec::new(); + for _ in 0..r.count()? { + out.push(r.tagged_value(sizes)?); + } + Ok(out) + }) +} + +/// `ArrayReference.GetValues`: the element tag, a count, then untagged primitives or tagged objects. +pub(super) fn array_region(body: &[u8], sizes: &IdSizes) -> Result, InspectError> { + parse(body, |r| { + let tag = r.u8()?; + let mut out = Vec::new(); + for _ in 0..r.count()? { + out.push(if is_object_tag(tag) { r.tagged_value(sizes)? } else { r.value(tag, sizes)? }); + } + Ok(out) + }) +} + +/// `ClassesBySignature`: the class ids. +pub(super) fn classes_by_signature(body: &[u8], sizes: &IdSizes) -> Result, InspectError> { + parse(body, |r| { + let mut out = Vec::new(); + for _ in 0..r.count()? { + r.u8()?; + out.push(r.id(sizes.reference_type)?); + r.i32()?; + } + Ok(out) + }) +} + +/// `AllClasses`: class id and signature. +pub(super) fn all_classes(body: &[u8], sizes: &IdSizes) -> Result, InspectError> { + parse(body, |r| { + let mut out = Vec::new(); + for _ in 0..r.count()? { + r.u8()?; + let id = r.id(sizes.reference_type)?; + let signature = r.string()?; + r.i32()?; + out.push((id, signature)); + } + Ok(out) + }) +} + +/// `Instances` and `ReferringObjects`: `int n; (tag, objectID)*`. +pub(super) fn tagged_objects(body: &[u8], sizes: &IdSizes) -> Result, InspectError> { + parse(body, |r| { + let mut out = Vec::new(); + for _ in 0..r.count()? { + r.u8()?; + out.push(r.id(sizes.object)?); + } + Ok(out) + }) +} + +pub(super) fn instance_counts(body: &[u8]) -> Result, InspectError> { + parse(body, |r| { + let mut out = Vec::new(); + for _ in 0..r.count()? { + let n = r.i64()?; + out.push(u64::try_from(n).map_err(|_| CodecError::BadLength(n as u32))?); + } + Ok(out) + }) +} diff --git a/crates/ide-jdwp/src/lib.rs b/crates/ide-jdwp/src/lib.rs new file mode 100644 index 000000000..f62c4eaa0 --- /dev/null +++ b/crates/ide-jdwp/src/lib.rs @@ -0,0 +1,15 @@ +//! JDWP packet codec and the debugger link built on it. Bounds-checked: malformed input is an +//! `Err`, never a panic. + +mod codec; +mod ids; +mod inspector; +mod link; + +pub use codec::{ + CodecError, FLAG_REPLY, HEADER_LEN, Header, MAX_ADAPTER_PACKET, Reader, Value, Writer, is_object_tag, + primitive_text, +}; +pub use ids::{IdSizes, ReplyIds, Stop, dispose_body, object_ids, stop_of}; +pub use inspector::LinkInspector; +pub use link::{JdwpLink, LinkError, LinkOptions, LinkState, OWN_IDS}; diff --git a/crates/ide-jdwp/src/link/command.rs b/crates/ide-jdwp/src/link/command.rs new file mode 100644 index 000000000..9b9bee0c3 --- /dev/null +++ b/crates/ide-jdwp/src/link/command.rs @@ -0,0 +1,196 @@ +//! The link's own commands, injected while the VM is stopped. + +use super::state::{Core, OwnCommand, Turn}; +use super::{JdwpLink, JvmWriter, Shared, upstream}; +use crate::codec::{Header, Reader, Writer}; +use crate::ids::{IdSizes, ReplyIds}; +use std::io; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::{Notify, oneshot, watch}; + +/// `SuspendCount` checks before a command gives up with [`LinkError::Busy`]. +const SUSPEND_CHECKS: usize = 5; +const CHECK_INTERVAL: Duration = Duration::from_millis(100); + +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum LinkError { + #[error("the VM is not suspended")] + NotSuspended, + #[error("the VM resumed while the command was waiting")] + Stale, + #[error("the JDWP link only forwards")] + Degraded, + #[error("the JDWP link is closed")] + Closed, + #[error("the JDWP command timed out")] + TimedOut, + /// The stopped thread kept running code for the debugger (an invoke, or a suspend count + /// of 0) through every check. + #[error("the debugger is running code in the VM")] + Busy, + #[error("JDWP error {0}")] + Jdwp(u16), + #[error("malformed JDWP reply: {0}")] + Protocol(String), +} + +impl JdwpLink { + /// Sends one command of the link's own and returns its reply body. Commands run one at a + /// time, only while the VM is stopped: the stopped thread's `SuspendCount` must be above 0 + /// and no adapter invoke pending, checked up to five times 100 ms apart. It runs in the stop + /// current at the call: once that stop ends, also while the command waits for an earlier + /// one, it fails with [`LinkError::Stale`]. + /// + /// The ObjectIDs at `ids` in the reply are counted when it arrives, even after the caller + /// gave up, and released with `DisposeObjects` once the adapter resumes the VM. The command + /// must be one the JVM answers without running the VM: never an invoke. + pub async fn command( + &self, + set: u8, + cmd: u8, + body: Vec, + ids: ReplyIds, + timeout: Duration, + ) -> Result, LinkError> { + debug_assert!(!upstream::is_invoke((set, cmd)), "the link never invokes"); + let shared = &self.inner.shared; + let target = shared.update(|core| core.target())?; + let injected = inject(shared, target, (set, cmd), &body, ids); + tokio::time::timeout(timeout, injected).await.unwrap_or(Err(LinkError::TimedOut)) + } + + pub fn id_sizes(&self) -> Option { + self.inner.shared.update(|core| core.sizes) + } +} + +async fn inject( + shared: &Shared, + (generation, thread, sizes): (u64, u64, IdSizes), + command: (u8, u8), + body: &[u8], + ids: ReplyIds, +) -> Result, LinkError> { + // The turn may stay taken past the end of the stop: an earlier command holds it until its + // reply arrives. + let mut stops = shared.stops.subscribe(); + let turn: Turn = tokio::select! { + turn = shared.turn.clone().lock_owned() => Arc::new(turn), + () = stop_ended(&mut stops, generation) => return Err(ended(shared, generation)), + }; + shared.update(|core| core.still_stopped(generation))?; + let mut w = Writer::new(); + w.id(thread, sizes.object); + let thread = w.into_vec(); + for attempt in 0..SUSPEND_CHECKS { + if attempt > 0 { + tokio::time::sleep(CHECK_INTERVAL).await; + } + // The check itself may go out during an invoke: that is when the count drops to 0. + let Some(count) = send(shared, &turn, generation, (11, 12), &thread, ReplyIds::None, false).await? else { + continue; + }; + if suspend_count(&count)? > 0 + && let Some(reply) = send(shared, &turn, generation, command, body, ids, true).await? + { + return Ok(reply); + } + } + Err(LinkError::Busy) +} + +fn suspend_count(body: &[u8]) -> Result { + let mut r = Reader::new(body); + let count = r.i32().map_err(|e| LinkError::Protocol(e.to_string()))?; + match r.remaining() { + 0 => Ok(count), + extra => Err(LinkError::Protocol(format!("{extra} bytes after SuspendCount"))), + } +} + +/// Writes one command and waits for its reply. `Ok(None)`: not sent, because `idle` was asked +/// and an adapter invoke is pending. +async fn send( + shared: &Shared, + turn: &Turn, + generation: u64, + (set, cmd): (u8, u8), + body: &[u8], + ids: ReplyIds, + idle: bool, +) -> Result>, LinkError> { + let (answer, reply) = oneshot::channel(); + { + let mut jvm = shared.jvm.lock().await; + let writer = jvm.as_mut().ok_or(LinkError::Closed)?; + // What a cancelled packet left unwritten goes first. + if let Err(e) = writer.finish().await { + return Err(broken(shared, &mut jvm, &e)); + } + // Under the writer lock: an adapter resume or invoke is either written already, and + // seen here, or waits until this command is written. + let id = shared.update(|core| { + core.still_stopped(generation)?; + if idle && !core.invokes.is_empty() { + return Ok(None); + } + Ok(Some(core.register_own(OwnCommand::Inject { ids, answer, _turn: turn.clone() }))) + })?; + let Some(id) = id else { + return Ok(None); + }; + let mut packet = Header::command_bytes(id, set, cmd, body.len()).to_vec(); + packet.extend_from_slice(body); + writer.queue(packet); + // A caller that gives up from here on leaves the rest of the packet to the relay; the + // entry stays until the reply. + let mut cancelled = WakeRelay(Some(&shared.unfinished)); + let written = writer.finish().await; + cancelled.0 = None; + if let Err(e) = written { + return Err(broken(shared, &mut jvm, &e)); + } + } + let mut stops = shared.stops.subscribe(); + tokio::select! { + reply = reply => { + // The sender only goes away when the link closes. + let reply = reply.map_err(|_| LinkError::Closed)?; + shared.update(|core| core.still_stopped(generation))?; + reply.map(Some) + } + () = stop_ended(&mut stops, generation) => Err(ended(shared, generation)), + } +} + +/// Wakes the relay when dropped while set. +struct WakeRelay<'a>(Option<&'a Notify>); + +impl Drop for WakeRelay<'_> { + fn drop(&mut self) { + if let Some(relay) = self.0 { + relay.notify_one(); + } + } +} + +/// A failed write may have left part of a packet with the JVM, so nothing else may follow it. +fn broken(shared: &Shared, jvm: &mut Option, e: &io::Error) -> LinkError { + tracing::debug!("JDWP link: writing a command of its own failed: {e}"); + shared.update(Core::close); + *jvm = None; + // The relay ends once it finds the writer gone. + shared.unfinished.notify_one(); + LinkError::Closed +} + +/// Why stop `generation` no longer lets a command run. +fn ended(shared: &Shared, generation: u64) -> LinkError { + shared.update(|core| core.still_stopped(generation)).err().unwrap_or(LinkError::Stale) +} + +async fn stop_ended(stops: &mut watch::Receiver>, generation: u64) { + // `let _` drops the borrow at once; holding it would block every state update. + let _ = stops.wait_for(|stop| *stop != Some(generation)).await; +} diff --git a/crates/ide-jdwp/src/link/command_tests.rs b/crates/ide-jdwp/src/link/command_tests.rs new file mode 100644 index 000000000..78cb8e8d9 --- /dev/null +++ b/crates/ide-jdwp/src/link/command_tests.rs @@ -0,0 +1,534 @@ +use super::ledger::DISPOSE_CHUNK; +use super::tests::{ + OPEN, Pair, WAIT, adapter_marker, command, connected, event, jvm_marker, read_packet, reply, stop, wait_state, + within, +}; +use super::*; +use crate::codec::{HEADER_LEN, Header, Reader, Writer}; +use crate::ids::{IdSizes, ReplyIds}; +use proptest::collection::vec; +use proptest::prelude::*; +use std::collections::HashMap; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::task::JoinHandle; + +pub(super) const THREAD: u64 = 9; +const SUSPENDED: LinkState = LinkState::Suspended { generation: 1, thread: Some(THREAD) }; +const QUIET: Duration = Duration::from_millis(200); + +pub(super) type Disposal = (u32, Vec<(u64, u32)>); + +pub(super) fn header(packet: &[u8]) -> Header { + Header::parse(packet[..HEADER_LEN].try_into().unwrap()) +} + +pub(super) fn tagged(ids: &[u64]) -> Vec { + let mut w = Writer::new(); + w.i32(ids.len() as i32); + for &id in ids { + w.u8(b'L'); + w.id(id, 8); + } + w.into_vec() +} + +pub(super) fn instances_body() -> Vec { + let mut w = Writer::new(); + w.id(0x42, 8); + w.i32(1000); + w.into_vec() +} + +pub(super) fn spawn_command(link: &JdwpLink, timeout: Duration) -> JoinHandle, LinkError>> { + let link = link.clone(); + tokio::spawn(async move { link.command(2, 16, instances_body(), ReplyIds::TaggedObjects, timeout).await }) +} + +pub(super) async fn result(task: JoinHandle, LinkError>>) -> Result, LinkError> { + within("command result", task).await.unwrap() +} + +async fn stopped() -> Pair { + suspended(connected(OPEN).await).await +} + +pub(super) async fn suspended(mut p: Pair) -> Pair { + stop(&mut p, 2, THREAD).await; + wait_state(&p.link, SUSPENDED).await; + p +} + +/// The JVM's next packet that is not the link's `SuspendCount` check; each check is answered 1. +pub(super) async fn next_own(p: &mut Pair) -> Vec { + loop { + let packet = read_packet(&mut p.jvm).await; + let h = header(&packet); + if !h.is_reply() && h.command() == (11, 12) && OWN_IDS.contains(&h.id) { + p.jvm.write_all(&reply(h.id, 0, &1i32.to_be_bytes())).await.unwrap(); + continue; + } + return packet; + } +} + +/// The link's `Instances` command as the JVM receives it; returns its id. +pub(super) async fn own_instances(p: &mut Pair) -> u32 { + let packet = next_own(p).await; + let h = header(&packet); + assert_eq!((h.command(), h.is_reply()), ((2, 16), false)); + assert!(OWN_IDS.contains(&h.id), "own id {:#x} outside OWN_IDS", h.id); + assert_eq!(&packet[HEADER_LEN..], instances_body()); + h.id +} + +/// Next packet must be the link's `SuspendCount` of the stopped thread; answers `count`. +async fn answer_check(p: &mut Pair, count: i32) { + let packet = read_packet(&mut p.jvm).await; + let h = header(&packet); + assert_eq!((h.command(), h.is_reply()), ((11, 12), false), "expected SuspendCount, got {packet:?}"); + assert!(OWN_IDS.contains(&h.id)); + assert_eq!(&packet[HEADER_LEN..], THREAD.to_be_bytes()); + p.jvm.write_all(&reply(h.id, 0, &count.to_be_bytes())).await.unwrap(); +} + +async fn assert_jvm_quiet(p: &mut Pair) { + let mut byte = [0; 1]; + let read = tokio::time::timeout(QUIET, p.jvm.read(&mut byte)).await; + assert!(read.is_err(), "the JVM received bytes: {read:?}"); +} + +fn parse_dispose(body: &[u8]) -> Vec<(u64, u32)> { + let mut r = Reader::new(body); + let entries = (0..r.count().unwrap()).map(|_| (r.id(8).unwrap(), r.i32().unwrap() as u32)).collect(); + assert_eq!(r.remaining(), 0); + entries +} + +/// Adapter `1/9` then a marker; the JVM must see the resume first, then only the link's +/// `DisposeObjects` packets before the marker. +pub(super) async fn resume(p: &mut Pair) -> Vec { + let resume = command(50, 1, 9, &[]); + let marker = command(0x99, 1, 1, &[]); + p.adapter.write_all(&resume).await.unwrap(); + p.adapter.write_all(&marker).await.unwrap(); + assert_eq!(read_packet(&mut p.jvm).await, resume); + let mut out = Vec::new(); + loop { + let packet = read_packet(&mut p.jvm).await; + if packet == marker { + return out; + } + let h = header(&packet); + assert_eq!((h.command(), h.is_reply()), ((1, 14), false), "unexpected packet {packet:?}"); + assert!(OWN_IDS.contains(&h.id)); + out.push((h.id, parse_dispose(&packet[HEADER_LEN..]))); + } +} + +pub(super) fn entries(disposals: &[Disposal]) -> Vec> { + disposals.iter().map(|(_, entries)| entries.clone()).collect() +} + +fn ledger_is_empty(link: &JdwpLink) -> bool { + link.inner.shared.update(|core| core.ledger.is_empty()) +} + +#[tokio::test] +async fn refuses_when_not_suspended() { + let mut p = connected(OPEN).await; + let refused = p.link.command(2, 16, instances_body(), ReplyIds::TaggedObjects, WAIT).await; + assert_eq!(refused, Err(LinkError::NotSuspended)); + + // a suspending event without a thread (CLASS_UNLOAD) cannot be checked with SuspendCount + let unload = event(2, 9, 0); + p.jvm.write_all(&unload).await.unwrap(); + assert_eq!(read_packet(&mut p.adapter).await, unload); + wait_state(&p.link, LinkState::Suspended { generation: 1, thread: None }).await; + let refused = p.link.command(2, 16, instances_body(), ReplyIds::TaggedObjects, WAIT).await; + assert_eq!(refused, Err(LinkError::NotSuspended)); + adapter_marker(&mut p).await; + + let mut p = stopped().await; + let reserved = command(OWN_IDS.start + 3, 1, 1, &[]); + p.adapter.write_all(&reserved).await.unwrap(); + assert_eq!(read_packet(&mut p.jvm).await, reserved); + wait_state(&p.link, LinkState::Degraded(RESERVED_ID.into())).await; + let refused = p.link.command(2, 16, instances_body(), ReplyIds::TaggedObjects, WAIT).await; + assert_eq!(refused, Err(LinkError::Degraded)); + adapter_marker(&mut p).await; + + let mut p = stopped().await; + let dispose = command(60, 1, 6, &[]); + p.adapter.write_all(&dispose).await.unwrap(); + assert_eq!(read_packet(&mut p.jvm).await, dispose); + wait_state(&p.link, LinkState::Closed).await; + let refused = p.link.command(2, 16, instances_body(), ReplyIds::TaggedObjects, WAIT).await; + assert_eq!(refused, Err(LinkError::Closed)); + adapter_marker(&mut p).await; +} + +#[tokio::test] +async fn one_internal_command_at_a_time() { + let mut p = stopped().await; + let first = spawn_command(&p.link, WAIT); + let second = spawn_command(&p.link, WAIT); + let id = own_instances(&mut p).await; + // not even the second command's SuspendCount goes out before the first reply + assert_jvm_quiet(&mut p).await; + p.jvm.write_all(&reply(id, 0, &tagged(&[1]))).await.unwrap(); + let next = own_instances(&mut p).await; + p.jvm.write_all(&reply(next, 0, &tagged(&[2]))).await.unwrap(); + let mut answers = vec![result(first).await.unwrap(), result(second).await.unwrap()]; + answers.sort(); + assert_eq!(answers, vec![tagged(&[1]), tagged(&[2])]); +} + +#[tokio::test] +async fn disposes_exact_counts_after_forwarded_resume() { + let mut p = stopped().await; + let task = spawn_command(&p.link, WAIT); + let id = own_instances(&mut p).await; + let body = tagged(&[0x11, 0x11, 0x22]); + p.jvm.write_all(&reply(id, 0, &body)).await.unwrap(); + assert_eq!(result(task).await, Ok(body)); + let disposals = resume(&mut p).await; + assert_eq!(entries(&disposals), vec![vec![(0x11, 2), (0x22, 1)]]); + // the JVM's answer to DisposeObjects is the link's, not the adapter's + p.jvm.write_all(&reply(disposals[0].0, 0, &[])).await.unwrap(); + jvm_marker(&mut p).await; + // released once: the next stop has nothing to release + stop(&mut p, 2, THREAD).await; + assert_eq!(resume(&mut p).await, vec![]); +} + +#[tokio::test] +async fn late_reply_after_resume_is_disposed_once() { + let mut p = stopped().await; + let task = spawn_command(&p.link, WAIT); + let id = own_instances(&mut p).await; + assert_eq!(resume(&mut p).await, vec![]); + assert_eq!(result(task).await, Err(LinkError::Stale)); + p.jvm.write_all(&reply(id, 0, &tagged(&[0x33]))).await.unwrap(); + jvm_marker(&mut p).await; + stop(&mut p, 2, THREAD).await; + assert_eq!(entries(&resume(&mut p).await), vec![vec![(0x33, 1)]]); + stop(&mut p, 2, THREAD).await; + assert_eq!(resume(&mut p).await, vec![]); +} + +#[tokio::test] +async fn queued_command_is_stale_once_its_stop_ends() { + let mut p = stopped().await; + let first = spawn_command(&p.link, WAIT); + let id = own_instances(&mut p).await; + let queued = spawn_command(&p.link, WAIT); + assert_jvm_quiet(&mut p).await; + assert_eq!(resume(&mut p).await, vec![]); + assert_eq!(result(first).await, Err(LinkError::Stale)); + // the resume cancels it at once, though the first command still holds the turn + assert_eq!(result(queued).await, Err(LinkError::Stale)); + p.jvm.write_all(&reply(id, 0, &tagged(&[0x33]))).await.unwrap(); + jvm_marker(&mut p).await; + assert_jvm_quiet(&mut p).await; + stop(&mut p, 2, THREAD).await; + assert_eq!(entries(&resume(&mut p).await), vec![vec![(0x33, 1)]]); +} + +#[tokio::test] +async fn queued_command_never_runs_in_the_next_stop() { + let mut p = stopped().await; + let first = spawn_command(&p.link, WAIT); + let id = own_instances(&mut p).await; + let queued = spawn_command(&p.link, WAIT); + assert_jvm_quiet(&mut p).await; + assert_eq!(resume(&mut p).await, vec![]); + assert_eq!(result(first).await, Err(LinkError::Stale)); + stop(&mut p, 2, THREAD).await; + wait_state(&p.link, LinkState::Suspended { generation: 2, thread: Some(THREAD) }).await; + // the turn comes free only in stop 2 + p.jvm.write_all(&reply(id, 0, &tagged(&[0x33]))).await.unwrap(); + jvm_marker(&mut p).await; + // neither a SuspendCount check nor a second Instances + assert_jvm_quiet(&mut p).await; + assert_eq!(result(queued).await, Err(LinkError::Stale)); + assert_eq!(entries(&resume(&mut p).await), vec![vec![(0x33, 1)]]); +} + +#[tokio::test] +async fn timed_out_or_cancelled_reply_is_still_counted() { + let mut p = stopped().await; + let timed_out = spawn_command(&p.link, Duration::from_millis(100)); + let id = own_instances(&mut p).await; + assert_eq!(result(timed_out).await, Err(LinkError::TimedOut)); + // the abandoned command still holds the turn until its reply + let cancelled = spawn_command(&p.link, WAIT); + assert_jvm_quiet(&mut p).await; + p.jvm.write_all(&reply(id, 0, &tagged(&[0x44]))).await.unwrap(); + + let id = own_instances(&mut p).await; + cancelled.abort(); + assert!(within("abort", cancelled).await.unwrap_err().is_cancelled()); + p.jvm.write_all(&reply(id, 0, &tagged(&[0x55]))).await.unwrap(); + jvm_marker(&mut p).await; + assert_eq!(entries(&resume(&mut p).await), vec![vec![(0x44, 1), (0x55, 1)]]); +} + +#[tokio::test] +async fn malformed_reply_is_leaked_not_disposed() { + let mut p = stopped().await; + let task = spawn_command(&p.link, WAIT); + let id = own_instances(&mut p).await; + let mut cut = tagged(&[0x11, 0x22]); + cut.truncate(cut.len() - 3); + p.jvm.write_all(&reply(id, 0, &cut)).await.unwrap(); + assert!(matches!(result(task).await, Err(LinkError::Protocol(_)))); + assert_eq!(resume(&mut p).await, vec![]); +} + +#[tokio::test] +async fn jdwp_error_reply_counts_nothing() { + let mut p = stopped().await; + let task = spawn_command(&p.link, WAIT); + let id = own_instances(&mut p).await; + // a body the link must not read, though it would parse + p.jvm.write_all(&reply(id, 20, &tagged(&[0x11]))).await.unwrap(); + assert_eq!(result(task).await, Err(LinkError::Jdwp(20))); + assert!(ledger_is_empty(&p.link)); + assert_eq!(resume(&mut p).await, vec![]); +} + +#[tokio::test] +async fn invalid_object_keeps_link_healthy() { + let mut p = stopped().await; + let task = spawn_command(&p.link, WAIT); + let id = own_instances(&mut p).await; + p.jvm.write_all(&reply(id, 20, &[])).await.unwrap(); + assert_eq!(result(task).await, Err(LinkError::Jdwp(20))); + assert_eq!(p.link.state(), SUSPENDED); + let task = spawn_command(&p.link, WAIT); + let id = own_instances(&mut p).await; + p.jvm.write_all(&reply(id, 0, &tagged(&[0x11]))).await.unwrap(); + assert_eq!(result(task).await, Ok(tagged(&[0x11]))); +} + +#[tokio::test] +async fn no_dispose_after_vm_dispose_or_close() { + let mut p = stopped().await; + let task = spawn_command(&p.link, WAIT); + let id = own_instances(&mut p).await; + p.jvm.write_all(&reply(id, 0, &tagged(&[0x11]))).await.unwrap(); + assert!(result(task).await.is_ok()); + let dispose = command(60, 1, 6, &[]); + p.adapter.write_all(&dispose).await.unwrap(); + assert_eq!(read_packet(&mut p.jvm).await, dispose); + wait_state(&p.link, LinkState::Closed).await; + assert_jvm_quiet(&mut p).await; + // forwarding continues, but the ledger is gone + assert_eq!(resume(&mut p).await, vec![]); + + let mut p = stopped().await; + let task = spawn_command(&p.link, WAIT); + let id = own_instances(&mut p).await; + p.jvm.write_all(&reply(id, 0, &tagged(&[0x11]))).await.unwrap(); + assert!(result(task).await.is_ok()); + let Pair { link, adapter, mut jvm } = p; + drop(adapter); + wait_state(&link, LinkState::Closed).await; + let mut rest = Vec::new(); + within("jvm eof", jvm.read_to_end(&mut rest)).await.unwrap(); + assert_eq!(rest, Vec::::new(), "the JVM received bytes after the adapter left"); +} + +#[tokio::test] +async fn disposes_in_chunks_of_1000() { + let mut p = stopped().await; + let task = spawn_command(&p.link, WAIT); + let id = own_instances(&mut p).await; + let ids: Vec = (1..=2500).collect(); + p.jvm.write_all(&reply(id, 0, &tagged(&ids))).await.unwrap(); + assert!(result(task).await.is_ok()); + let chunks = entries(&resume(&mut p).await); + assert_eq!(chunks.iter().map(Vec::len).collect::>(), vec![DISPOSE_CHUNK, DISPOSE_CHUNK, 500]); + let mut all: Vec<_> = chunks.concat(); + all.sort_unstable(); + assert_eq!(all, ids.iter().map(|&id| (id, 1)).collect::>()); +} + +#[tokio::test] +async fn suspend_count_zero_retries_then_runs() { + let mut p = stopped().await; + let task = spawn_command(&p.link, WAIT); + answer_check(&mut p, 0).await; + answer_check(&mut p, 1).await; + let id = own_instances(&mut p).await; + p.jvm.write_all(&reply(id, 0, &tagged(&[0x11]))).await.unwrap(); + assert_eq!(result(task).await, Ok(tagged(&[0x11]))); + + // never suspended: five checks, then Busy, and nothing else reaches the JVM + let task = spawn_command(&p.link, WAIT); + for _ in 0..5 { + answer_check(&mut p, 0).await; + } + assert_eq!(result(task).await, Err(LinkError::Busy)); + adapter_marker(&mut p).await; +} + +#[tokio::test] +async fn busy_while_adapter_invokes() { + let mut p = stopped().await; + let invoke = command(20, 9, 6, &[0; 24]); + p.adapter.write_all(&invoke).await.unwrap(); + assert_eq!(read_packet(&mut p.jvm).await, invoke); + let task = spawn_command(&p.link, WAIT); + // SuspendCount still says 1: the pending invoke alone keeps the command out + for _ in 0..5 { + answer_check(&mut p, 1).await; + } + assert_eq!(result(task).await, Err(LinkError::Busy)); + adapter_marker(&mut p).await; + assert!(ledger_is_empty(&p.link)); + let answer = reply(20, 0, &[b'I', 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + p.jvm.write_all(&answer).await.unwrap(); + assert_eq!(read_packet(&mut p.adapter).await, answer); + let task = spawn_command(&p.link, WAIT); + let id = own_instances(&mut p).await; + p.jvm.write_all(&reply(id, 0, &tagged(&[]))).await.unwrap(); + assert_eq!(result(task).await, Ok(tagged(&[]))); +} + +#[tokio::test] +async fn id_sizes_come_from_the_link_reply() { + let p = stopped().await; + assert_eq!(p.link.id_sizes(), Some(IdSizes { field: 8, method: 8, object: 8, reference_type: 8, frame: 8 })); +} + +#[tokio::test] +async fn ids_outlive_the_resume_of_another_stopped_thread() { + let mut p = connected(OPEN).await; + stop(&mut p, 1, 7).await; + stop(&mut p, 1, THREAD).await; + wait_state(&p.link, LinkState::Suspended { generation: 2, thread: Some(THREAD) }).await; + let task = spawn_command(&p.link, WAIT); + let id = own_instances(&mut p).await; + p.jvm.write_all(&reply(id, 0, &tagged(&[0x66]))).await.unwrap(); + assert_eq!(result(task).await, Ok(tagged(&[0x66]))); + // The stop on THREAD goes on, so do the ids read during it. + let resume_other = command(51, 11, 3, &7u64.to_be_bytes()); + p.adapter.write_all(&resume_other).await.unwrap(); + assert_eq!(read_packet(&mut p.jvm).await, resume_other); + adapter_marker(&mut p).await; + assert_eq!(entries(&resume(&mut p).await), vec![vec![(0x66, 1)]]); +} + +#[derive(Debug, Clone)] +enum Step { + Stop, + Command, + Reply { ids: Vec, error: bool }, + Resume, + Close, +} + +fn steps() -> impl Strategy> { + let step = prop_oneof![ + 3 => Just(Step::Stop), + 4 => Just(Step::Command), + 4 => (vec(1u64..6, 0..5), proptest::bool::weighted(0.2)).prop_map(|(ids, error)| Step::Reply { ids, error }), + 3 => Just(Step::Resume), + 1 => Just(Step::Close), + ]; + vec(step, 0..24) +} + +fn add(counts: &mut HashMap, entries: impl IntoIterator) { + for (id, n) in entries { + *counts.entry(id).or_default() += n; + } +} + +fn assert_within(disposed: &HashMap, received: &HashMap) { + for (id, &n) in disposed { + let got = received.get(id).copied().unwrap_or(0); + assert!(n <= got, "disposed {id:#x} {n} times, received it {got} times"); + } +} + +/// Resumes, answers each `DisposeObjects` and adds its entries to `disposed`. +async fn release(p: &mut Pair, disposed: &mut HashMap) { + for (id, entries) in resume(p).await { + p.jvm.write_all(&reply(id, 0, &[])).await.unwrap(); + add(disposed, entries); + } +} + +/// Plays `steps` against the link; checks that no id is ever disposed more often than the +/// JVM handed it to the link, and that a last stop and resume release every one of them. +async fn interleave(steps: Vec) { + let mut p = connected(OPEN).await; + let (mut stopped, mut closed) = (false, false); + let mut pending: Option = None; + let mut tasks = Vec::new(); + let (mut received, mut disposed) = (HashMap::new(), HashMap::new()); + for step in steps { + match step { + Step::Stop if !closed => { + stop(&mut p, 2, THREAD).await; + stopped = true; + } + Step::Command if stopped && !closed && pending.is_none() => { + tasks.push(spawn_command(&p.link, WAIT)); + pending = Some(own_instances(&mut p).await); + } + Step::Reply { ids, error } if pending.is_some() => { + let id = pending.take().unwrap(); + p.jvm.write_all(&reply(id, if error { 20 } else { 0 }, &tagged(&ids))).await.unwrap(); + if !error { + add(&mut received, ids.iter().map(|&id| (id, 1))); + } + } + Step::Resume => { + release(&mut p, &mut disposed).await; + stopped = false; + } + Step::Close if !closed => { + let dispose = command(60, 1, 6, &[]); + p.adapter.write_all(&dispose).await.unwrap(); + assert_eq!(read_packet(&mut p.jvm).await, dispose); + closed = true; + } + _ => {} + } + assert_within(&disposed, &received); + } + if let Some(id) = pending.take() { + p.jvm.write_all(&reply(id, 0, &tagged(&[1]))).await.unwrap(); + add(&mut received, [(1, 1)]); + } + if !closed { + stop(&mut p, 2, THREAD).await; + } + release(&mut p, &mut disposed).await; + assert_within(&disposed, &received); + if !closed { + assert_eq!(disposed, received); + } + // nothing the JVM sent the link reached the adapter + jvm_marker(&mut p).await; + for task in tasks { + let result = result(task).await; + assert!( + matches!(result, Ok(_) | Err(LinkError::Stale | LinkError::Closed | LinkError::Jdwp(20))), + "{result:?}" + ); + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + #[test] + fn never_disposes_more_than_received(steps in steps()) { + let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap(); + rt.block_on(interleave(steps)); + } +} diff --git a/crates/ide-jdwp/src/link/downstream.rs b/crates/ide-jdwp/src/link/downstream.rs new file mode 100644 index 000000000..8a1c31969 --- /dev/null +++ b/crates/ide-jdwp/src/link/downstream.rs @@ -0,0 +1,83 @@ +//! JVM → adapter. + +use super::{HANDSHAKE, Route, Shared, check_handshake}; +use crate::codec::{HEADER_LEN, Header}; +use std::io; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +/// Largest event packet parsed for a stop; a larger one is forwarded unread. +const MAX_EVENT: usize = 1024 * 1024; + +pub(super) async fn run( + shared: &Shared, + mut jvm: impl AsyncRead + Unpin, + mut adapter: impl AsyncWrite + Unpin, +) -> io::Result<()> { + let mut handshake = [0; HANDSHAKE.len()]; + jvm.read_exact(&mut handshake).await?; + check_handshake(&handshake)?; + adapter.write_all(&handshake).await?; + adapter.flush().await?; + shared.update(|core| core.handshaken = true); + let mut header = [0; HEADER_LEN]; + loop { + jvm.read_exact(&mut header).await?; + let h = Header::parse(&header); + let len = h.length as usize; + let body_len = len + .checked_sub(HEADER_LEN) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, format!("invalid JDWP packet length {len}")))?; + if h.is_reply() { + match shared.update(|core| core.route_reply(h.id)) { + Route::Own(own) => { + let body = read_body(&mut jvm, body_len).await?; + shared.update(|core| core.own_reply(own, &h, body)); + shared.own_answered.notify_waiters(); + continue; + } + Route::Nobody => { + tracing::warn!( + "JDWP link: dropped a reply to reserved id {:#x} that nobody sent or waits for", + h.id + ); + skip_body(&mut jvm, body_len).await?; + continue; + } + Route::Adapter => shared.update(|core| core.adapter_reply(h.id, h.error())), + } + } else if h.command() == (64, 100) && len <= MAX_EVENT { + let mut packet = vec![0; len]; + packet[..HEADER_LEN].copy_from_slice(&header); + jvm.read_exact(&mut packet[HEADER_LEN..]).await?; + // The state changes before the adapter can react to the event. + shared.update(|core| core.event(&packet[HEADER_LEN..])); + adapter.write_all(&packet).await?; + adapter.flush().await?; + continue; + } + adapter.write_all(&header).await?; + let copied = tokio::io::copy(&mut (&mut jvm).take(body_len as u64), &mut adapter).await?; + if copied != body_len as u64 { + return Err(io::ErrorKind::UnexpectedEof.into()); + } + adapter.flush().await?; + } +} + +async fn skip_body(jvm: &mut (impl AsyncRead + Unpin), len: usize) -> io::Result<()> { + let skipped = tokio::io::copy(&mut jvm.take(len as u64), &mut tokio::io::sink()).await?; + if skipped != len as u64 { + return Err(io::ErrorKind::UnexpectedEof.into()); + } + Ok(()) +} + +/// Grows the buffer as bytes arrive instead of trusting the length up front. +async fn read_body(jvm: &mut (impl AsyncRead + Unpin), len: usize) -> io::Result> { + let mut body = Vec::new(); + jvm.take(len as u64).read_to_end(&mut body).await?; + if body.len() != len { + return Err(io::ErrorKind::UnexpectedEof.into()); + } + Ok(body) +} diff --git a/crates/ide-jdwp/src/link/ledger.rs b/crates/ide-jdwp/src/link/ledger.rs new file mode 100644 index 000000000..b47a98390 --- /dev/null +++ b/crates/ide-jdwp/src/link/ledger.rs @@ -0,0 +1,63 @@ +//! How many references to each ObjectID the JVM handed the link, so `DisposeObjects` returns +//! exactly those. One reference too many released aborts the JVM. + +use std::collections::HashMap; + +/// Most ids in one `VirtualMachine.DisposeObjects` packet. +pub const DISPOSE_CHUNK: usize = 1000; + +#[derive(Default)] +pub(crate) struct Ledger { + counts: HashMap, +} + +impl Ledger { + /// One reference per occurrence, as the JVM counts them. + pub fn record(&mut self, ids: &[u64]) { + for &id in ids { + let count = self.counts.entry(id).or_default(); + // `DisposeObjects` takes an `int`; stopping short only leaks. + *count = (*count + 1).min(i32::MAX as u32); + } + } + + /// Everything recorded so far, by id, and forgets it. + pub fn drain(&mut self) -> Vec<(u64, u32)> { + let mut entries: Vec<_> = self.counts.drain().collect(); + entries.sort_unstable(); + entries + } + + #[cfg(test)] + pub fn is_empty(&self) -> bool { + self.counts.is_empty() + } + + /// Forgets everything without releasing it. + pub fn clear(&mut self) { + self.counts.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn counts_every_occurrence_and_drains_once() { + let mut ledger = Ledger::default(); + ledger.record(&[7, 3, 7]); + ledger.record(&[7]); + assert_eq!(ledger.drain(), vec![(3, 1), (7, 3)]); + assert!(ledger.is_empty()); + assert_eq!(ledger.drain(), vec![]); + } + + #[test] + fn count_stops_at_the_largest_jdwp_int() { + let mut ledger = Ledger::default(); + ledger.counts.insert(5, i32::MAX as u32); + ledger.record(&[5]); + assert_eq!(ledger.drain(), vec![(5, i32::MAX as u32)]); + } +} diff --git a/crates/ide-jdwp/src/link/mod.rs b/crates/ide-jdwp/src/link/mod.rs new file mode 100644 index 000000000..168f5aeba --- /dev/null +++ b/crates/ide-jdwp/src/link/mod.rs @@ -0,0 +1,234 @@ +//! Transparent relay between a debug adapter and a JVM that tracks when the VM is stopped and +//! can later inject its own commands (ids in [`OWN_IDS`]) without the adapter seeing them. + +use state::{Core, OwnCommand, Route}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::Duration; +use std::{future::Future, io}; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::watch; +use tokio::task::JoinHandle; + +mod command; +mod downstream; +mod ledger; +mod state; +mod suspends; +mod upstream; +mod writer; + +pub use command::LinkError; +use writer::JvmWriter; + +#[cfg(test)] +mod command_tests; +#[cfg(test)] +mod suspend_tests; +#[cfg(test)] +mod tests; +#[cfg(test)] +mod write_tests; + +/// Packet ids of the link's own commands; the adapter is expected never to use them. +pub const OWN_IDS: std::ops::Range = 0x7000_0000..0x7FFF_FFFF; + +const HANDSHAKE: &[u8; 14] = b"JDWP-Handshake"; +const ADAPTER_WAIT: Duration = Duration::from_secs(60); +const RESERVED_ID: &str = "adapter packet id in the reserved range"; + +#[derive(Clone, Copy, Debug)] +pub struct LinkOptions { + /// Hold `VirtualMachine.Resume` and `ThreadReference.Resume` until [`JdwpLink::release`]. + pub hold_resumes: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum LinkState { + Connecting, + Handshaking, + /// Running, with the adapter's resumes held until [`JdwpLink::release`]. + Held, + Ready, + /// `thread` is `None` when the stopping event names no thread, or arrived before the + /// link learnt the VM's id sizes and named a thread the link could not read yet. + Suspended { + generation: u64, + thread: Option, + }, + /// Only forwarding; the link sends nothing of its own. + Degraded(String), + Closed, +} + +#[derive(Clone)] +pub struct JdwpLink { + inner: Arc, +} + +struct Inner { + shared: Arc, + release: watch::Sender, + task: JoinHandle<()>, +} + +impl Drop for Inner { + fn drop(&mut self) { + // The task owns both sockets (the JVM writer through its `Arc`), so aborting it + // closes them. + self.task.abort(); + } +} + +struct Shared { + core: Mutex, + state: watch::Sender, + /// Every write to the JVM goes through this lock, so packets never interleave. + jvm: tokio::sync::Mutex>, + /// Signalled when a write of the link's own command stopped part way or failed: the relay + /// then finishes the packet, or ends once the writer is gone. + unfinished: tokio::sync::Notify, + /// Signalled after each reply to one of the link's own commands. + own_answered: tokio::sync::Notify, + /// One injected command at a time (see `state::Turn`). + turn: Arc>, + /// The current stop's generation; `None` while running or closed. Unlike `state`, not + /// hidden by `Degraded`. + stops: watch::Sender>, +} + +impl Shared { + fn new(options: LinkOptions) -> Shared { + let core = Core { hold: options.hold_resumes, next_own: OWN_IDS.start, ..Core::default() }; + let (state, _) = watch::channel(core.state()); + Shared { + core: Mutex::new(core), + state, + jvm: tokio::sync::Mutex::new(None), + unfinished: tokio::sync::Notify::new(), + own_answered: tokio::sync::Notify::new(), + turn: Arc::default(), + stops: watch::Sender::new(None), + } + } + + /// Runs `f` on the core and publishes the state if it changed. + fn update(&self, f: impl FnOnce(&mut Core) -> R) -> R { + let mut core = self.core.lock().unwrap_or_else(PoisonError::into_inner); + let out = f(&mut core); + publish(&self.state, core.state()); + publish(&self.stops, (core.stopped && !core.closed).then_some(core.generation)); + out + } + + async fn close(&self) { + self.update(Core::close); + self.jvm.lock().await.take(); + } +} + +impl JdwpLink { + /// Listens on a loopback port for the adapter (returned), then connects to the JVM. Closes + /// if the adapter does not connect within 60 s. + pub async fn start(jvm_host: String, jvm_port: u16, options: LinkOptions) -> io::Result<(JdwpLink, u16)> { + let listener = TcpListener::bind(("127.0.0.1", 0)).await?; + let port = listener.local_addr()?.port(); + let link = Self::spawn(options, async move { + let (adapter, _) = listener.accept().await?; + let jvm = TcpStream::connect((jvm_host.as_str(), jvm_port)).await?; + adapter.set_nodelay(true)?; + jvm.set_nodelay(true)?; + Ok((adapter, jvm)) + }); + Ok((link, port)) + } + + /// The link over already connected streams, for fake JVMs in tests. + #[cfg(test)] + pub(crate) fn spawn_over(adapter: A, jvm: J, options: LinkOptions) -> JdwpLink + where + A: AsyncRead + AsyncWrite + Send + Unpin + 'static, + J: AsyncRead + AsyncWrite + Send + Unpin + 'static, + { + Self::spawn(options, async move { Ok((adapter, jvm)) }) + } + + fn spawn(options: LinkOptions, connect: impl Future> + Send + 'static) -> JdwpLink + where + A: AsyncRead + AsyncWrite + Send + Unpin + 'static, + J: AsyncRead + AsyncWrite + Send + Unpin + 'static, + { + let shared = Arc::new(Shared::new(options)); + let (release, released) = watch::channel(false); + let task = tokio::spawn({ + let shared = shared.clone(); + async move { + match tokio::time::timeout(ADAPTER_WAIT, connect).await { + Ok(Ok((adapter, jvm))) => relay(&shared, adapter, jvm, released).await, + Ok(Err(e)) => tracing::debug!("JDWP link: connect failed: {e}"), + Err(_) => tracing::debug!("JDWP link: no adapter within {ADAPTER_WAIT:?}"), + } + shared.close().await; + } + }); + JdwpLink { inner: Arc::new(Inner { shared, release, task }) } + } + + /// Lets held resumes through, in the order the adapter sent them. + pub fn release(&self) { + self.inner.shared.update(|core| core.released = true); + self.inner.release.send_replace(true); + } + + pub fn state(&self) -> LinkState { + self.inner.shared.state.borrow().clone() + } + + pub fn subscribe(&self) -> watch::Receiver { + self.inner.shared.state.subscribe() + } + + /// Number of stops seen so far; the current stop's number while `Suspended`. + pub fn generation(&self) -> u64 { + self.inner.shared.update(|core| core.generation) + } + + /// Whether an adapter `InvokeMethod`/`NewInstance` is waiting for its reply. The stopped + /// thread runs during an invoke, so the link must not inject commands meanwhile. + pub fn adapter_invoking(&self) -> bool { + self.inner.shared.update(|core| !core.invokes.is_empty()) + } +} + +async fn relay(shared: &Shared, adapter: A, jvm: J, released: watch::Receiver) +where + A: AsyncRead + AsyncWrite + Send + Unpin + 'static, + J: AsyncRead + AsyncWrite + Send + Unpin + 'static, +{ + let (adapter_rx, adapter_tx) = tokio::io::split(adapter); + let (jvm_rx, jvm_tx) = tokio::io::split(jvm); + *shared.jvm.lock().await = Some(JvmWriter::new(jvm_tx)); + shared.update(|core| core.connected = true); + let hold = shared.update(|core| core.hold); + let result = tokio::select! { + r = upstream::run(shared, adapter_rx, released, hold) => r, + r = downstream::run(shared, jvm_rx, adapter_tx) => r, + }; + if let Err(e) = result { + tracing::debug!("JDWP link closed: {e}"); + } +} + +fn publish(sender: &watch::Sender, next: T) { + sender.send_if_modified(|value| { + let changed = *value != next; + if changed { + *value = next; + } + changed + }); +} + +fn check_handshake(bytes: &[u8; 14]) -> io::Result<()> { + if bytes == HANDSHAKE { Ok(()) } else { Err(io::Error::new(io::ErrorKind::InvalidData, "invalid JDWP handshake")) } +} diff --git a/crates/ide-jdwp/src/link/state.rs b/crates/ide-jdwp/src/link/state.rs new file mode 100644 index 000000000..eda6498bb --- /dev/null +++ b/crates/ide-jdwp/src/link/state.rs @@ -0,0 +1,383 @@ +//! The link's state machine, kept under `Shared::core`. + +use super::ledger::{DISPOSE_CHUNK, Ledger}; +use super::suspends::Suspends; +use super::{LinkError, LinkState, OWN_IDS}; +use crate::codec::{Header, Reader}; +use crate::ids::{EVENT_CLASS_PREPARE, IdSizes, ReplyIds, dispose_body, event_kind, object_ids, stop_of}; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use tokio::sync::{OwnedMutexGuard, oneshot}; + +/// The right to have an injected command in flight. Each pending command holds a clone, so the +/// next command waits for the previous reply even when its caller stopped waiting. +pub(super) type Turn = Arc>; + +pub(super) type Answer = oneshot::Sender, LinkError>>; + +/// A command of the link's own. None of them invokes a method or otherwise waits for the VM to +/// run: the JVM answers each one straight away, which `upstream::forward` relies on when an +/// adapter command reuses a pending id. +pub(super) enum OwnCommand { + IdSizes, + /// `VirtualMachine.DisposeObjects`; the reply is dropped. + Dispose, + /// `ids`: where the reply carries ObjectIDs the ledger must count. + Inject { + ids: ReplyIds, + answer: Answer, + _turn: Turn, + }, +} + +#[derive(Default)] +pub(super) struct Core { + pub(super) hold: bool, + pub(super) connected: bool, + pub(super) handshaken: bool, + pub(super) released: bool, + /// A suspending event started a stop, and some thread is still suspended. + pub(super) stopped: bool, + /// The thread the link's commands run on: the last of `event_threads`; `None` while running + /// or for a stop whose thread is unknown. + pub(super) thread: Option, + /// Threads an event stopped that are still suspended, oldest first. fwcd suspends only the + /// event's thread and resumes each thread alone, so earlier ones can stay stopped. + pub(super) event_threads: Vec, + pub(super) suspends: Suspends, + /// A suspending event whose thread the link could not read: any resume ends it, as it + /// cannot be told apart from the others. + pub(super) unattributed: bool, + /// Adapter `VirtualMachine.Suspend` (`None`) and `ThreadReference.Suspend` commands + /// counted on their way to the JVM and not answered yet, by packet id. + pub(super) suspending: HashMap>, + pub(super) generation: u64, + pub(super) degraded: Option, + pub(super) closed: bool, + pub(super) sizes: Option, + /// Body of the current stop's event when it arrived before `sizes`. + pub(super) unread_stop: Option>, + /// Ids of adapter invokes the JVM has not answered yet. + pub(super) invokes: HashSet, + /// The link's commands the JVM has not answered yet. An entry leaves only when its reply + /// arrives or the link closes: a caller that stops waiting leaves it here, so the late + /// reply is still counted and never taken for the adapter's. + pub(super) own: HashMap, + /// Adapter commands with an id in [`OWN_IDS`] the JVM has not answered yet, per id. + pub(super) adapter_reserved: HashMap, + pub(super) next_own: u32, + pub(super) ledger: Ledger, +} + +/// Who gets a reply from the JVM. +pub(super) enum Route { + Own(OwnCommand), + Adapter, + /// In [`OWN_IDS`] but nobody is waiting for it. + Nobody, +} + +impl Core { + pub(super) fn state(&self) -> LinkState { + if self.closed { + LinkState::Closed + } else if let Some(reason) = &self.degraded { + LinkState::Degraded(reason.clone()) + } else if !self.connected { + LinkState::Connecting + } else if !self.handshaken { + LinkState::Handshaking + } else if self.stopped { + LinkState::Suspended { generation: self.generation, thread: self.thread } + } else if self.hold && !self.released { + LinkState::Held + } else { + LinkState::Ready + } + } + + /// Call under the JVM writer lock, so the id is pending before an adapter command with the + /// same id can be written. `command` must not invoke (see [`OwnCommand`]). + pub(super) fn register_own(&mut self, command: OwnCommand) -> u32 { + loop { + let id = self.next_own; + self.next_own = if id + 1 >= OWN_IDS.end { OWN_IDS.start } else { id + 1 }; + if !self.own.contains_key(&id) && !self.adapter_reserved.contains_key(&id) { + self.own.insert(id, command); + return id; + } + } + } + + /// Two pending commands never share a reserved id (see `upstream::forward`), so a reply's + /// id alone names its owner. + pub(super) fn route_reply(&mut self, id: u32) -> Route { + if let Some(own) = self.own.remove(&id) { + return Route::Own(own); + } + if OWN_IDS.contains(&id) { + let Some(pending) = self.adapter_reserved.get_mut(&id) else { + return Route::Nobody; + }; + *pending -= 1; + if *pending == 0 { + self.adapter_reserved.remove(&id); + } + } + self.invokes.remove(&id); + Route::Adapter + } + + pub(super) fn own_reply(&mut self, command: OwnCommand, header: &Header, body: Vec) { + match command { + OwnCommand::IdSizes if header.error() == 0 => match IdSizes::parse(&body) { + Ok(sizes) => { + self.sizes = Some(sizes); + if let Some(event) = self.unread_stop.take() + && let Some(stop) = stop_of(&event, &sizes) + { + self.thread = stop.thread; + self.event_threads.extend(self.thread); + if self.count_event(stop.suspend_policy, stop.thread) { + self.unattributed = false; + } + } + } + Err(e) => tracing::warn!("JDWP link: malformed IDSizes reply: {e}"), + }, + OwnCommand::IdSizes => tracing::warn!("JDWP link: IDSizes failed with error {}", header.error()), + OwnCommand::Dispose => {} + OwnCommand::Inject { ids, answer, _turn } => { + let result = self.count(ids, header, body); + // The caller may have given up; the ids are counted either way. + let _ = answer.send(result); + } + } + } + + fn count(&mut self, layout: ReplyIds, header: &Header, body: Vec) -> Result, LinkError> { + if header.error() != 0 { + return Err(LinkError::Jdwp(header.error())); + } + let sizes = self.sizes.ok_or_else(|| LinkError::Protocol("no id sizes".into()))?; + match object_ids(layout, &body, &sizes) { + Ok(ids) => { + self.ledger.record(&ids); + Ok(body) + } + Err(e) => { + // Leaking ids is safe; releasing ids the JVM did not hand out aborts it. + tracing::warn!("JDWP link: reply {:#x} does not match {layout:?}, its ids leak: {e}", header.id); + Err(LinkError::Protocol(e.to_string())) + } + } + } + + /// Where an injected command would run: the current stop's generation and thread, and the + /// id sizes to write the thread with. + pub(super) fn target(&self) -> Result<(u64, u64, IdSizes), LinkError> { + if self.closed { + return Err(LinkError::Closed); + } + if self.degraded.is_some() { + return Err(LinkError::Degraded); + } + match (self.stopped, self.sizes, self.thread) { + (true, Some(sizes), Some(thread)) => Ok((self.generation, thread, sizes)), + _ => Err(LinkError::NotSuspended), + } + } + + /// Whether stop `generation` still lets the link write a command of its own. + pub(super) fn still_stopped(&self, generation: u64) -> Result<(), LinkError> { + if self.closed { + Err(LinkError::Closed) + } else if self.degraded.is_some() { + Err(LinkError::Degraded) + } else if self.stopped && self.generation == generation { + Ok(()) + } else { + Err(LinkError::Stale) + } + } + + /// `DisposeObjects` packets for everything in the ledger, to write right after the + /// adapter's resume. None while `Degraded`: the link sends nothing then, and leaks. + pub(super) fn disposals(&mut self) -> Vec> { + let Some(sizes) = self.sizes.filter(|_| !self.closed && self.degraded.is_none()) else { + return Vec::new(); + }; + let entries = self.ledger.drain(); + entries + .chunks(DISPOSE_CHUNK) + .map(|chunk| { + let body = dispose_body(chunk, &sizes); + let id = self.register_own(OwnCommand::Dispose); + let mut packet = Header::command_bytes(id, 1, 14, body.len()).to_vec(); + packet.extend_from_slice(&body); + packet + }) + .collect() + } + + /// Terminal. Pending commands fail and the ledger is dropped unreleased: after + /// `VirtualMachine.Dispose` the ids are gone anyway. + pub(super) fn close(&mut self) { + self.closed = true; + self.own.clear(); + self.ledger.clear(); + } + + /// A `Composite` event: any event whose policy suspends something starts a new stop, except + /// while a stop is current for one raised during an adapter invoke (the JVM runs threads for + /// the invoke and suspends them again once it returns) or a class prepare (fwcd resumes + /// those with `VirtualMachine.Resume` and shows no stop for them): that stop goes on. + pub(super) fn event(&mut self, body: &[u8]) { + match self.sizes { + Some(sizes) => { + if let Some(stop) = stop_of(body, &sizes).filter(|stop| stop.suspend_policy != 0) { + if self.stopped && event_kind(body) == Some(EVENT_CLASS_PREPARE) { + self.count_class_prepare(stop.suspend_policy, stop.thread); + return; + } + self.count_event(stop.suspend_policy, stop.thread); + if !(self.stopped && self.invoking()) { + self.begin_stop(stop.thread, None); + } + } + } + None => { + let mut r = Reader::new(body); + if r.u8().is_ok_and(|policy| policy != 0) && r.count().is_ok_and(|n| n > 0) { + self.unattributed = true; + self.begin_stop(None, Some(body.to_vec())); + } + } + } + } + + fn invoking(&self) -> bool { + !self.invokes.is_empty() + } + + /// Counts a suspending event of `thread`; returns whether it could (the thread is known). + fn count_event(&mut self, policy: u8, thread: Option) -> bool { + let Some(thread) = thread else { + self.unattributed = true; + return false; + }; + // During an invoke the thread runs while the counts still hold its suspensions. + if !self.invoking() { + self.suspends.ran(thread); + } + if policy == SUSPEND_EVENT_THREAD { + self.suspends.suspend(thread); + } else { + self.suspends.suspend_all(); + } + true + } + + /// A class prepare during a stop, which goes on: unlike [`Self::count_event`], one from a + /// thread the VM hides still counts when it suspends every thread, since only a VM-wide + /// resume can end it. + fn count_class_prepare(&mut self, policy: u8, thread: Option) { + match thread { + Some(_) => { + self.count_event(policy, thread); + } + None if policy != SUSPEND_EVENT_THREAD => self.suspends.suspend_all(), + None => {} + } + } + + /// The adapter's `VirtualMachine.Suspend` (1/8) or `ThreadReference.Suspend` (11/2) with + /// packet `id`. Counted before the write, like a resume, so a resume written right after it + /// sees it; a refusal takes it back ([`Self::adapter_reply`]). + pub(super) fn suspend(&mut self, id: u32, command: (u8, u8), body: &[u8]) { + if command == (1, 8) { + self.suspends.suspend_all(); + self.suspending.insert(id, None); + } else if let Some(thread) = self.sizes.and_then(|sizes| Reader::new(body).id(sizes.object).ok()) { + self.suspends.suspend(thread); + self.suspending.insert(id, Some(thread)); + } + } + + /// The JVM answered the adapter's packet `id` with `error`. A stop that a refused suspend + /// alone kept going ends here; its ids go out with the next resume. + pub(super) fn adapter_reply(&mut self, id: u32, error: u16) { + match self.suspending.remove(&id) { + Some(None) if error != 0 => self.suspends.unsuspend_all(), + Some(Some(thread)) if error != 0 => self.suspends.resume(thread), + _ => return, + } + self.settle(); + } + + fn begin_stop(&mut self, thread: Option, unread: Option>) { + self.generation += 1; + self.stopped = true; + self.thread = thread; + self.unread_stop = unread; + match thread { + Some(thread) => { + self.event_threads.retain(|&t| t != thread); + self.event_threads.push(thread); + } + // Its resume cannot be told apart from the others' either: any resume ends it. + None => self.event_threads.clear(), + } + } + + /// The adapter's `VirtualMachine.Resume` (1/9) or `ThreadReference.Resume` (11/3) with + /// `body`. Call before writing it, under the JVM writer lock: once the JVM has it, it may + /// stop again, and that stop must not be taken for the one the resume ends. Returns whether + /// the ids read so far may be released after it: not while the link's stop goes on. + pub(super) fn resume(&mut self, command: (u8, u8), body: &[u8]) -> bool { + let before = (self.stopped, self.generation); + self.unattributed = false; + self.unread_stop = None; + if command == (11, 3) { + match self.sizes.and_then(|sizes| Reader::new(body).id(sizes.object).ok()) { + Some(thread) => self.suspends.resume(thread), + // Which count went down is unknown: the link takes every thread for running. + None => self.suspends.clear(), + } + } else { + self.suspends.resume_all(); + } + self.settle(); + !self.stopped || (self.stopped, self.generation) != before + } + + fn end_stop(&mut self) { + self.stopped = false; + self.thread = None; + self.event_threads.clear(); + self.unread_stop = None; + } + + /// After a count went down: ends the stop once no thread is suspended. A thread the link + /// runs on that runs again moves the stop to the latest event thread still suspended, as a + /// new stop; with none left the VM-wide suspension keeps the stop, without a thread. + fn settle(&mut self) { + if !self.stopped { + return; + } + if !self.unattributed && !self.suspends.any() { + return self.end_stop(); + } + let suspends = &self.suspends; + self.event_threads.retain(|&t| suspends.count(t) > 0); + if self.thread.is_some_and(|t| suspends.count(t) == 0) { + self.thread = self.event_threads.last().copied(); + if self.thread.is_some() { + self.generation += 1; + } + } + } +} + +/// `SuspendPolicy.EVENT_THREAD`; `ALL` is 2. +const SUSPEND_EVENT_THREAD: u8 = 1; diff --git a/crates/ide-jdwp/src/link/suspend_tests.rs b/crates/ide-jdwp/src/link/suspend_tests.rs new file mode 100644 index 000000000..04fece47c --- /dev/null +++ b/crates/ide-jdwp/src/link/suspend_tests.rs @@ -0,0 +1,142 @@ +//! The stop follows the JVM's suspend counts: `VirtualMachine.Suspend` / `ThreadReference.Suspend` +//! keep it going through resumes that leave the VM suspended. + +use super::command_tests::{THREAD, entries, own_instances, result, resume, spawn_command, tagged}; +use super::tests::{OPEN, Pair, WAIT, adapter_marker, command, connected, read_packet, reply, stop, wait_state}; +use super::*; +use tokio::io::AsyncWriteExt; + +const STOPPED: LinkState = LinkState::Suspended { generation: 1, thread: Some(THREAD) }; + +/// Sends `packet` from the adapter; the JVM must get it unchanged. +async fn forward(p: &mut Pair, packet: &[u8]) { + p.adapter.write_all(packet).await.unwrap(); + assert_eq!(read_packet(&mut p.jvm).await, packet); +} + +/// One link command in the current stop, answered with `id`. +async fn read_object(p: &mut Pair, id: u64) { + let task = spawn_command(&p.link, WAIT); + let own = own_instances(p).await; + p.jvm.write_all(&reply(own, 0, &tagged(&[id]))).await.unwrap(); + assert_eq!(result(task).await, Ok(tagged(&[id]))); +} + +fn vm_suspend(id: u32) -> Vec { + command(id, 1, 8, &[]) +} + +fn thread_suspend(id: u32, thread: u64) -> Vec { + command(id, 11, 2, &thread.to_be_bytes()) +} + +fn thread_resume(id: u32, thread: u64) -> Vec { + command(id, 11, 3, &thread.to_be_bytes()) +} + +/// kotlin-lsp 263.7084.0 against a real JVM: the breakpoint suspends its thread, then the +/// adapter suspends the VM and resumes the thread, which stays suspended by the VM. +#[tokio::test] +async fn kotlin_lsp_thread_resume_under_vm_suspend_keeps_the_stop() { + let mut p = connected(OPEN).await; + stop(&mut p, 1, THREAD).await; + wait_state(&p.link, STOPPED).await; + forward(&mut p, &vm_suspend(70)).await; + read_object(&mut p, 0x11).await; + forward(&mut p, &thread_resume(71, THREAD)).await; + // no disposal went out with the thread resume + adapter_marker(&mut p).await; + assert_eq!(p.link.state(), STOPPED); + read_object(&mut p, 0x22).await; + assert_eq!(entries(&resume(&mut p).await), vec![vec![(0x11, 1), (0x22, 1)]]); + wait_state(&p.link, LinkState::Ready).await; + assert_eq!(p.link.generation(), 1); +} + +/// The JVM runs the invoking thread for an adapter invoke (kotlin-lsp's `toString` for +/// "Collecting data…"), so it can raise an event on a thread the link counts as suspended: a +/// breakpoint in the invoked code, a class prepare, a caught exception. +#[tokio::test] +async fn an_event_during_an_adapter_invoke_keeps_the_stop() { + let mut p = connected(OPEN).await; + stop(&mut p, 1, THREAD).await; + wait_state(&p.link, STOPPED).await; + forward(&mut p, &vm_suspend(70)).await; + forward(&mut p, &thread_resume(71, THREAD)).await; + read_object(&mut p, 0x11).await; + forward(&mut p, &command(72, 9, 6, &[0; 24])).await; + stop(&mut p, 1, THREAD).await; + forward(&mut p, &thread_resume(73, THREAD)).await; + let answer = reply(72, 0, &[b'I', 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + p.jvm.write_all(&answer).await.unwrap(); + assert_eq!(read_packet(&mut p.adapter).await, answer); + // no disposal went out with the event's resume + adapter_marker(&mut p).await; + assert_eq!(p.link.state(), STOPPED); + read_object(&mut p, 0x22).await; + assert_eq!(entries(&resume(&mut p).await), vec![vec![(0x11, 1), (0x22, 1)]]); + wait_state(&p.link, LinkState::Ready).await; + assert_eq!(p.link.generation(), 1); +} + +#[tokio::test] +async fn each_vm_suspend_needs_its_vm_resume() { + let mut p = connected(OPEN).await; + stop(&mut p, 2, THREAD).await; + wait_state(&p.link, STOPPED).await; + forward(&mut p, &vm_suspend(70)).await; + forward(&mut p, &vm_suspend(71)).await; + read_object(&mut p, 0x11).await; + for _ in 0..2 { + assert_eq!(resume(&mut p).await, vec![]); + assert_eq!(p.link.state(), STOPPED); + } + assert_eq!(entries(&resume(&mut p).await), vec![vec![(0x11, 1)]]); + wait_state(&p.link, LinkState::Ready).await; +} + +#[tokio::test] +async fn a_suspended_thread_stays_stopped_until_resumed_as_often() { + let mut p = connected(OPEN).await; + stop(&mut p, 1, THREAD).await; + wait_state(&p.link, STOPPED).await; + forward(&mut p, &thread_suspend(70, THREAD)).await; + read_object(&mut p, 0x11).await; + forward(&mut p, &thread_resume(71, THREAD)).await; + adapter_marker(&mut p).await; + assert_eq!(p.link.state(), STOPPED); + let last = thread_resume(72, THREAD); + p.adapter.write_all(&last).await.unwrap(); + assert_eq!(read_packet(&mut p.jvm).await, last); + let dispose = read_packet(&mut p.jvm).await; + assert_eq!(super::command_tests::header(&dispose).command(), (1, 14), "{dispose:?}"); + wait_state(&p.link, LinkState::Ready).await; +} + +#[tokio::test] +async fn a_suspend_all_event_ends_with_one_vm_resume() { + let mut p = connected(OPEN).await; + stop(&mut p, 2, THREAD).await; + wait_state(&p.link, STOPPED).await; + read_object(&mut p, 0x11).await; + assert_eq!(entries(&resume(&mut p).await), vec![vec![(0x11, 1)]]); + wait_state(&p.link, LinkState::Ready).await; +} + +#[tokio::test] +async fn a_refused_suspend_is_not_counted() { + let mut p = connected(OPEN).await; + stop(&mut p, 1, THREAD).await; + wait_state(&p.link, STOPPED).await; + for refused in [vm_suspend(70), thread_suspend(71, 5)] { + let id = super::command_tests::header(&refused).id; + forward(&mut p, &refused).await; + // VM_DEAD, INVALID_THREAD + let error = reply(id, if id == 70 { 112 } else { 10 }, &[]); + p.jvm.write_all(&error).await.unwrap(); + assert_eq!(read_packet(&mut p.adapter).await, error); + } + forward(&mut p, &thread_resume(72, 5)).await; + forward(&mut p, &thread_resume(73, THREAD)).await; + wait_state(&p.link, LinkState::Ready).await; +} diff --git a/crates/ide-jdwp/src/link/suspends.rs b/crates/ide-jdwp/src/link/suspends.rs new file mode 100644 index 000000000..57a4df81f --- /dev/null +++ b/crates/ide-jdwp/src/link/suspends.rs @@ -0,0 +1,80 @@ +//! JDWP suspend counts, as the adapter's commands and the JVM's events change them: a thread +//! runs only when its count is 0, and `VirtualMachine.Suspend` / `SUSPEND_ALL` events count for +//! every thread, also ones the link has not seen yet. + +use std::collections::HashMap; + +#[derive(Default, Debug)] +pub(super) struct Suspends { + /// VM-wide suspensions. + all: u32, + /// Each thread's count minus `all`; negative once the thread alone was resumed under a + /// VM-wide suspension. + threads: HashMap, +} + +impl Suspends { + pub(super) fn count(&self, thread: u64) -> i64 { + (i64::from(self.all) + self.threads.get(&thread).copied().unwrap_or(0)).max(0) + } + + /// Whether some thread is suspended. + pub(super) fn any(&self) -> bool { + self.all > 0 || self.threads.values().any(|&n| n > 0) + } + + pub(super) fn suspend_all(&mut self) { + self.all += 1; + } + + pub(super) fn suspend(&mut self, thread: u64) { + *self.threads.entry(thread).or_default() += 1; + } + + pub(super) fn resume(&mut self, thread: u64) { + if self.count(thread) > 0 { + *self.threads.entry(thread).or_default() -= 1; + } + self.threads.retain(|_, n| *n != 0); + } + + /// `VirtualMachine.Resume`: every thread's count goes down by one, so a thread's own + /// suspensions above the VM-wide ones stay. + pub(super) fn resume_all(&mut self) { + if self.all > 0 { + self.all -= 1; + self.floor(); + } else { + self.threads.values_mut().for_each(|n| *n = (*n - 1).max(0)); + self.threads.retain(|_, n| *n != 0); + } + } + + /// Takes back a `suspend_all` the JVM refused. + pub(super) fn unsuspend_all(&mut self) { + self.all = self.all.saturating_sub(1); + self.floor(); + } + + /// An event from `thread` outside an adapter invoke: it was running, so counts that say + /// otherwise are stale and counting starts again from this event. The thread suspensions + /// others hold stay. During an invoke the JVM runs suspended threads and suspends them again + /// afterwards, so such an event says nothing about the counts. + pub(super) fn ran(&mut self, thread: u64) { + if self.count(thread) > 0 { + self.all = 0; + self.threads.retain(|&t, n| t != thread && *n > 0); + } + } + + pub(super) fn clear(&mut self) { + *self = Self::default(); + } + + /// No count below 0 once `all` went down. + fn floor(&mut self) { + let floor = -i64::from(self.all); + self.threads.values_mut().for_each(|n| *n = (*n).max(floor)); + self.threads.retain(|_, n| *n != 0); + } +} diff --git a/crates/ide-jdwp/src/link/tests.rs b/crates/ide-jdwp/src/link/tests.rs new file mode 100644 index 000000000..2de689b47 --- /dev/null +++ b/crates/ide-jdwp/src/link/tests.rs @@ -0,0 +1,801 @@ +use super::*; +use crate::codec::{FLAG_REPLY, HEADER_LEN, Header, MAX_ADAPTER_PACKET, Writer}; +use proptest::collection::vec; +use proptest::prelude::*; +use std::time::Duration; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt, DuplexStream}; +use tokio::net::{TcpListener, TcpStream}; + +pub(super) const WAIT: Duration = Duration::from_secs(5); +const HANDSHAKE: &[u8; 14] = b"JDWP-Handshake"; +const HELD: LinkOptions = LinkOptions { hold_resumes: true }; +pub(super) const OPEN: LinkOptions = LinkOptions { hold_resumes: false }; + +pub(super) fn command(id: u32, set: u8, cmd: u8, body: &[u8]) -> Vec { + let mut out = Header::command_bytes(id, set, cmd, body.len()).to_vec(); + out.extend_from_slice(body); + out +} + +pub(super) fn reply(id: u32, error: u16, body: &[u8]) -> Vec { + let mut out = command(id, 0, 0, body); + out[8] = FLAG_REPLY; + out[9..11].copy_from_slice(&error.to_be_bytes()); + out +} + +/// Composite event with one event of `kind`; `thread` is written with 8 bytes. +pub(super) fn event(policy: u8, kind: u8, thread: u64) -> Vec { + let mut w = Writer::new(); + w.u8(policy); + w.i32(1); + w.u8(kind); + w.i32(3); + w.id(thread, 8); + command(0x100, 64, 100, &w.into_vec()) +} + +fn id_sizes_body() -> Vec { + let mut w = Writer::new(); + for _ in 0..5 { + w.i32(8); + } + w.into_vec() +} + +pub(super) async fn within(what: &str, f: impl Future) -> T { + tokio::time::timeout(WAIT, f).await.unwrap_or_else(|_| panic!("timed out: {what}")) +} + +pub(super) async fn read_packet(stream: &mut (impl AsyncRead + Unpin)) -> Vec { + within("packet", async { + let mut header = [0; HEADER_LEN]; + stream.read_exact(&mut header).await.expect("packet header"); + let len = Header::parse(&header).length as usize; + let mut out = vec![0; len]; + out[..HEADER_LEN].copy_from_slice(&header); + stream.read_exact(&mut out[HEADER_LEN..]).await.expect("packet body"); + out + }) + .await +} + +pub(super) async fn assert_eof(stream: &mut (impl AsyncRead + Unpin), side: &str) { + let mut byte = [0; 1]; + let read = within(side, stream.read(&mut byte)).await; + assert!(matches!(read, Ok(0) | Err(_)), "{side} still open: {read:?}"); +} + +pub(super) async fn wait_state(link: &JdwpLink, want: LinkState) { + let mut rx = link.subscribe(); + let seen = tokio::time::timeout(WAIT, rx.wait_for(|s| *s == want)).await; + assert!(matches!(seen, Ok(Ok(_))), "state {:?}, wanted {want:?}", link.state()); +} + +pub(super) struct Pair { + pub(super) link: JdwpLink, + pub(super) adapter: DuplexStream, + pub(super) jvm: DuplexStream, +} + +pub(super) fn pair(options: LinkOptions) -> Pair { + pair_over(options, |jvm| jvm) +} + +/// `wrap` turns the link's end of the JVM stream into the one the link uses. +pub(super) fn pair_over(options: LinkOptions, wrap: impl FnOnce(DuplexStream) -> J) -> Pair +where + J: AsyncRead + tokio::io::AsyncWrite + Send + Unpin + 'static, +{ + let (adapter, link_adapter) = tokio::io::duplex(64 * 1024); + let (link_jvm, jvm) = tokio::io::duplex(64 * 1024); + let link = JdwpLink::spawn_over(link_adapter, wrap(link_jvm), options); + Pair { link, adapter, jvm } +} + +/// Both handshakes; returns the id of the link's own `IDSizes` command without answering it. +async fn handshake(p: &mut Pair) -> u32 { + p.adapter.write_all(HANDSHAKE).await.unwrap(); + let mut buf = [0; 14]; + within("adapter handshake", p.jvm.read_exact(&mut buf)).await.unwrap(); + assert_eq!(&buf, HANDSHAKE); + p.jvm.write_all(HANDSHAKE).await.unwrap(); + within("jvm handshake", p.adapter.read_exact(&mut buf)).await.unwrap(); + assert_eq!(&buf, HANDSHAKE); + let own = read_packet(&mut p.jvm).await; + let h = Header::parse(own[..HEADER_LEN].try_into().unwrap()); + assert_eq!((h.command(), h.length, h.is_reply()), ((1, 7), 11, false)); + assert!(OWN_IDS.contains(&h.id), "IDSizes id {:#x} outside OWN_IDS", h.id); + h.id +} + +/// Handshake and answer the link's `IDSizes` with 8-byte ids. +pub(super) async fn connected(options: LinkOptions) -> Pair { + connect(pair(options)).await +} + +pub(super) async fn connect(mut p: Pair) -> Pair { + let own = handshake(&mut p).await; + p.jvm.write_all(&reply(own, 0, &id_sizes_body())).await.unwrap(); + p +} + +/// A packet the JVM sends after the bytes under test; the adapter must see it next. +pub(super) async fn jvm_marker(p: &mut Pair) { + let marker = event(0, 8, 0x55); + p.jvm.write_all(&marker).await.unwrap(); + assert_eq!(read_packet(&mut p.adapter).await, marker); +} + +/// A packet the adapter sends after the bytes under test; the JVM must see it next. +pub(super) async fn adapter_marker(p: &mut Pair) { + let marker = command(0x99, 1, 1, &[]); + p.adapter.write_all(&marker).await.unwrap(); + assert_eq!(read_packet(&mut p.jvm).await, marker); +} + +pub(super) async fn stop(p: &mut Pair, policy: u8, thread: u64) { + let bp = event(policy, 2, thread); + p.jvm.write_all(&bp).await.unwrap(); + assert_eq!(read_packet(&mut p.adapter).await, bp); +} + +#[tokio::test] +async fn holds_resumes_but_allows_setup_and_preserves_partial_reads() { + let mut p = pair(HELD); + let own = handshake(&mut p).await; + wait_state(&p.link, LinkState::Held).await; + let resume = command(1, 1, 9, &[]); + let thread_resume = command(2, 11, 3, &[0; 8]); + let setup = command(3, 1, 4, &[]); + p.adapter.write_all(&resume).await.unwrap(); + p.adapter.write_all(&thread_resume).await.unwrap(); + p.adapter.write_all(&setup).await.unwrap(); + // the link's IDSizes came first and the adapter's packets keep their own order + assert_eq!(read_packet(&mut p.jvm).await, setup); + p.jvm.write_all(&reply(own, 0, &id_sizes_body())).await.unwrap(); + let next = command(4, 1, 9, &[]); + p.adapter.write_all(&next[..6]).await.unwrap(); + // give the link time to start reading the partial packet before it is released + tokio::time::sleep(Duration::from_millis(50)).await; + p.link.release(); + assert_eq!(read_packet(&mut p.jvm).await, resume); + assert_eq!(read_packet(&mut p.jvm).await, thread_resume); + p.adapter.write_all(&next[6..]).await.unwrap(); + assert_eq!(read_packet(&mut p.jvm).await, next); + wait_state(&p.link, LinkState::Ready).await; +} + +#[tokio::test] +async fn resumes_pass_through_without_hold() { + let mut p = pair(OPEN); + handshake(&mut p).await; + wait_state(&p.link, LinkState::Ready).await; + let resume = command(1, 1, 9, &[]); + p.adapter.write_all(&resume).await.unwrap(); + assert_eq!(read_packet(&mut p.jvm).await, resume); + assert_eq!(p.link.state(), LinkState::Ready); +} + +#[tokio::test] +async fn dropping_link_closes_both_sockets() { + for options in [HELD, OPEN] { + let mut p = connected(options).await; + // the assignment drops the original handle; the clone must keep the link alive + p.link = p.link.clone(); + adapter_marker(&mut p).await; + let Pair { link, mut adapter, mut jvm } = p; + drop(link); + assert_eof(&mut adapter, "adapter").await; + assert_eof(&mut jvm, "jvm").await; + } +} + +#[tokio::test] +async fn one_side_closing_closes_the_other() { + let p = connected(OPEN).await; + let Pair { link, mut adapter, jvm } = p; + drop(jvm); + wait_state(&link, LinkState::Closed).await; + assert_eof(&mut adapter, "adapter").await; + + let p = connected(OPEN).await; + let Pair { link, adapter, mut jvm } = p; + drop(adapter); + wait_state(&link, LinkState::Closed).await; + assert_eof(&mut jvm, "jvm").await; +} + +#[tokio::test] +async fn start_relays_over_tcp_and_drop_closes_both() { + let jvm_listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let jvm_port = jvm_listener.local_addr().unwrap().port(); + let (link, port) = JdwpLink::start("127.0.0.1".into(), jvm_port, OPEN).await.unwrap(); + assert_eq!(link.state(), LinkState::Connecting); + let mut adapter = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + let (mut jvm, _) = within("jvm accept", jvm_listener.accept()).await.unwrap(); + adapter.write_all(HANDSHAKE).await.unwrap(); + let mut buf = [0; 14]; + within("handshake", jvm.read_exact(&mut buf)).await.unwrap(); + jvm.write_all(HANDSHAKE).await.unwrap(); + within("handshake", adapter.read_exact(&mut buf)).await.unwrap(); + wait_state(&link, LinkState::Ready).await; + drop(link); + assert_eof(&mut adapter, "adapter").await; + read_packet(&mut jvm).await; // the link's IDSizes + assert_eof(&mut jvm, "jvm").await; +} + +#[tokio::test] +async fn rejects_invalid_adapter_packet_lengths() { + for len in [0_u32, 10, MAX_ADAPTER_PACKET as u32 + 1] { + let mut p = connected(HELD).await; + let mut bytes = command(1, 1, 9, &[]); + bytes[..4].copy_from_slice(&len.to_be_bytes()); + p.adapter.write_all(&bytes).await.unwrap(); + wait_state(&p.link, LinkState::Closed).await; + assert_eof(&mut p.adapter, "adapter").await; + assert_eof(&mut p.jvm, "jvm").await; + } + // the upper bound itself is accepted + let Pair { link, mut adapter, mut jvm } = connected(OPEN).await; + let largest = command(1, 1, 1, &vec![7; MAX_ADAPTER_PACKET - HEADER_LEN]); + let sent = largest.clone(); + let writer = tokio::spawn(async move { adapter.write_all(&sent).await.map(|()| adapter) }); + assert!(read_packet(&mut jvm).await == largest); + writer.await.unwrap().unwrap(); + assert_eq!(link.state(), LinkState::Ready); +} + +#[tokio::test] +async fn too_many_held_resumes_close_the_link() { + let mut p = connected(HELD).await; + for id in 0..1024 { + p.adapter.write_all(&command(id, 11, 3, &[0; 8])).await.unwrap(); + } + adapter_marker(&mut p).await; + p.adapter.write_all(&command(1024, 11, 3, &[0; 8])).await.unwrap(); + wait_state(&p.link, LinkState::Closed).await; + assert_eof(&mut p.jvm, "jvm").await; +} + +#[tokio::test] +async fn forwards_large_jvm_packet_unchanged() { + let mut p = connected(OPEN).await; + let request = command(5, 13, 2, &[0; 16]); + p.adapter.write_all(&request).await.unwrap(); + assert_eq!(read_packet(&mut p.jvm).await, request); + let body: Vec = (0..20 * 1024 * 1024u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect(); + let big = reply(5, 0, &body); + let mut jvm = p.jvm; + let sent = big.clone(); + let writer = tokio::spawn(async move { + jvm.write_all(&sent).await.unwrap(); + jvm + }); + let mut got = vec![0; big.len()]; + within("20 MiB reply", p.adapter.read_exact(&mut got)).await.unwrap(); + p.jvm = writer.await.unwrap(); + assert!(got == big, "reply changed on the way"); + jvm_marker(&mut p).await; +} + +#[tokio::test] +async fn own_reply_never_reaches_adapter() { + let mut p = pair(OPEN); + let own = handshake(&mut p).await; + p.jvm.write_all(&reply(own, 0, &id_sizes_body())).await.unwrap(); + jvm_marker(&mut p).await; +} + +#[tokio::test] +async fn stop_event_starts_generation_and_resume_ends_it() { + let mut p = connected(OPEN).await; + assert_eq!(p.link.generation(), 0); + stop(&mut p, 2, 9).await; + wait_state(&p.link, LinkState::Suspended { generation: 1, thread: Some(9) }).await; + let resume = command(10, 1, 9, &[]); + p.adapter.write_all(&resume).await.unwrap(); + assert_eq!(read_packet(&mut p.jvm).await, resume); + wait_state(&p.link, LinkState::Ready).await; + + // policy NONE suspends nothing + stop(&mut p, 0, 4).await; + jvm_marker(&mut p).await; + assert_eq!(p.link.state(), LinkState::Ready); + + stop(&mut p, 1, 7).await; + wait_state(&p.link, LinkState::Suspended { generation: 2, thread: Some(7) }).await; + let thread_resume = command(11, 11, 3, &7u64.to_be_bytes()); + p.adapter.write_all(&thread_resume).await.unwrap(); + assert_eq!(read_packet(&mut p.jvm).await, thread_resume); + wait_state(&p.link, LinkState::Ready).await; + assert_eq!(p.link.generation(), 2); +} + +#[tokio::test] +async fn adapter_invoke_keeps_the_stop() { + let mut p = connected(OPEN).await; + stop(&mut p, 2, 9).await; + let stopped = LinkState::Suspended { generation: 1, thread: Some(9) }; + wait_state(&p.link, stopped.clone()).await; + for (id, set, cmd) in [(20, 9, 6), (21, 3, 3), (22, 3, 4), (23, 5, 1)] { + let invoke = command(id, set, cmd, &[0; 24]); + p.adapter.write_all(&invoke).await.unwrap(); + assert_eq!(read_packet(&mut p.jvm).await, invoke); + assert!(p.link.adapter_invoking(), "invoke {set}/{cmd} not tracked"); + let answer = reply(id, 0, &[b'I', 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + p.jvm.write_all(&answer).await.unwrap(); + assert_eq!(read_packet(&mut p.adapter).await, answer); + assert!(!p.link.adapter_invoking()); + assert_eq!(p.link.state(), stopped); + } + // the link sent nothing of its own while the invokes ran + adapter_marker(&mut p).await; + assert_eq!(p.link.generation(), 1); +} + +#[tokio::test] +async fn held_resume_ends_generation_only_when_released() { + let mut p = connected(HELD).await; + wait_state(&p.link, LinkState::Held).await; + stop(&mut p, 2, 1).await; + let stopped = LinkState::Suspended { generation: 1, thread: Some(1) }; + wait_state(&p.link, stopped.clone()).await; + let resume = command(30, 1, 9, &[]); + p.adapter.write_all(&resume).await.unwrap(); + adapter_marker(&mut p).await; + assert_eq!(p.link.state(), stopped); + p.link.release(); + assert_eq!(read_packet(&mut p.jvm).await, resume); + wait_state(&p.link, LinkState::Ready).await; + assert_eq!(p.link.generation(), 1); +} + +#[tokio::test] +async fn release_while_running_is_ready() { + let p = connected(HELD).await; + wait_state(&p.link, LinkState::Held).await; + p.link.release(); + wait_state(&p.link, LinkState::Ready).await; +} + +#[tokio::test] +async fn stop_before_id_sizes_learns_its_thread_from_the_reply() { + let mut p = pair(OPEN); + let own = handshake(&mut p).await; + stop(&mut p, 2, 9).await; + wait_state(&p.link, LinkState::Suspended { generation: 1, thread: None }).await; + p.jvm.write_all(&reply(own, 0, &id_sizes_body())).await.unwrap(); + wait_state(&p.link, LinkState::Suspended { generation: 1, thread: Some(9) }).await; +} + +/// `ThreadReference.Resume` of `thread`, through to the JVM. +async fn resume_thread(p: &mut Pair, thread: u64) { + let resume = command(40, 11, 3, &thread.to_be_bytes()); + p.adapter.write_all(&resume).await.unwrap(); + assert_eq!(read_packet(&mut p.jvm).await, resume); +} + +/// fwcd's breakpoints and steps suspend only their thread (`SUSPEND_EVENT_THREAD`): 7 stops, then 8. +async fn two_threads_stopped() -> Pair { + let mut p = connected(OPEN).await; + stop(&mut p, 1, 7).await; + stop(&mut p, 1, 8).await; + wait_state(&p.link, LinkState::Suspended { generation: 2, thread: Some(8) }).await; + p +} + +#[tokio::test] +async fn resuming_an_earlier_stopped_thread_keeps_the_latest_stop() { + let mut p = two_threads_stopped().await; + resume_thread(&mut p, 7).await; + adapter_marker(&mut p).await; + assert_eq!(p.link.state(), LinkState::Suspended { generation: 2, thread: Some(8) }); +} + +#[tokio::test] +async fn resuming_the_latest_stopped_thread_moves_to_the_one_still_stopped() { + let mut p = two_threads_stopped().await; + resume_thread(&mut p, 8).await; + adapter_marker(&mut p).await; + // A new generation: ids read on thread 8 are stale. + assert_eq!(p.link.state(), LinkState::Suspended { generation: 3, thread: Some(7) }); + resume_thread(&mut p, 7).await; + wait_state(&p.link, LinkState::Ready).await; +} + +#[tokio::test] +async fn resuming_a_thread_that_did_not_stop_changes_nothing() { + let mut p = connected(OPEN).await; + stop(&mut p, 1, 7).await; + let stopped = LinkState::Suspended { generation: 1, thread: Some(7) }; + wait_state(&p.link, stopped.clone()).await; + resume_thread(&mut p, 5).await; + adapter_marker(&mut p).await; + assert_eq!(p.link.state(), stopped); +} + +#[tokio::test] +async fn vm_resume_ends_the_stop_of_every_thread() { + let mut p = two_threads_stopped().await; + let resume = command(41, 1, 9, &[]); + p.adapter.write_all(&resume).await.unwrap(); + assert_eq!(read_packet(&mut p.jvm).await, resume); + wait_state(&p.link, LinkState::Ready).await; + // 7 and 8 are forgotten: resuming 7 leaves the next stop alone. + stop(&mut p, 1, 9).await; + let stopped = LinkState::Suspended { generation: 3, thread: Some(9) }; + wait_state(&p.link, stopped.clone()).await; + resume_thread(&mut p, 7).await; + adapter_marker(&mut p).await; + assert_eq!(p.link.state(), stopped); +} + +#[tokio::test] +async fn thread_resume_ends_a_stop_whose_thread_is_unknown() { + // Before the id sizes, the resumed thread cannot be read. + let mut p = pair(OPEN); + handshake(&mut p).await; + stop(&mut p, 1, 9).await; + wait_state(&p.link, LinkState::Suspended { generation: 1, thread: None }).await; + resume_thread(&mut p, 5).await; + wait_state(&p.link, LinkState::Ready).await; + + // A suspending event without a thread (CLASS_UNLOAD). + let mut p = connected(OPEN).await; + stop(&mut p, 1, 7).await; + let unload = event(2, 9, 0); + p.jvm.write_all(&unload).await.unwrap(); + assert_eq!(read_packet(&mut p.adapter).await, unload); + wait_state(&p.link, LinkState::Suspended { generation: 2, thread: None }).await; + resume_thread(&mut p, 7).await; + wait_state(&p.link, LinkState::Ready).await; +} + +#[tokio::test] +async fn a_stop_before_id_sizes_is_tracked_once_its_thread_is_read() { + let mut p = pair(OPEN); + let own = handshake(&mut p).await; + stop(&mut p, 1, 7).await; + p.jvm.write_all(&reply(own, 0, &id_sizes_body())).await.unwrap(); + wait_state(&p.link, LinkState::Suspended { generation: 1, thread: Some(7) }).await; + stop(&mut p, 1, 8).await; + resume_thread(&mut p, 8).await; + adapter_marker(&mut p).await; + assert_eq!(p.link.state(), LinkState::Suspended { generation: 3, thread: Some(7) }); +} + +/// kotlin-lsp's stop: a breakpoint suspending its thread, then the adapter's `VM.Suspend` and +/// method invokes; the link reads one object meanwhile. +async fn kotlin_lsp_stop() -> Pair { + use super::command_tests::{THREAD, own_instances, result, spawn_command, tagged}; + let mut p = connected(OPEN).await; + stop(&mut p, 1, THREAD).await; + let stopped = LinkState::Suspended { generation: 1, thread: Some(THREAD) }; + wait_state(&p.link, stopped.clone()).await; + let suspend = command(70, 1, 8, &[]); + p.adapter.write_all(&suspend).await.unwrap(); + assert_eq!(read_packet(&mut p.jvm).await, suspend); + assert_eq!(p.link.state(), stopped); + let invoke = command(71, 9, 6, &[0; 24]); + p.adapter.write_all(&invoke).await.unwrap(); + assert_eq!(read_packet(&mut p.jvm).await, invoke); + let answer = reply(71, 0, &[b'I', 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + p.jvm.write_all(&answer).await.unwrap(); + assert_eq!(read_packet(&mut p.adapter).await, answer); + assert_eq!(p.link.state(), stopped); + let task = spawn_command(&p.link, WAIT); + let id = own_instances(&mut p).await; + p.jvm.write_all(&reply(id, 0, &tagged(&[0x11]))).await.unwrap(); + assert_eq!(result(task).await, Ok(tagged(&[0x11]))); + assert_eq!(p.link.state(), stopped); + p +} + +/// Sends `packets` and a marker; returns what the JVM got before the marker. +async fn through_to_jvm(p: &mut Pair, packets: &[Vec]) -> Vec> { + let marker = command(0x99, 1, 1, &[]); + for packet in packets.iter().chain([&marker]) { + p.adapter.write_all(packet).await.unwrap(); + } + let mut seen = Vec::new(); + loop { + let packet = read_packet(&mut p.jvm).await; + if packet == marker { + return seen; + } + seen.push(packet); + } +} + +/// The link's `DisposeObjects` of the one object `kotlin_lsp_stop` read. +fn is_dispose_of_the_read_object(packet: &[u8]) -> bool { + let h = Header::parse(packet[..HEADER_LEN].try_into().unwrap()); + let mut body = Writer::new(); + body.i32(1); + body.id(0x11, 8); + body.i32(1); + h.command() == (1, 14) && !h.is_reply() && OWN_IDS.contains(&h.id) && packet[HEADER_LEN..] == body.into_vec() +} + +/// `VirtualMachine.Resume` takes back the adapter's `VirtualMachine.Suspend` only: the +/// breakpoint's own suspension of its thread stays until that thread is resumed. +#[tokio::test] +async fn kotlin_lsp_stop_outlasts_vm_resume_until_its_thread_resumes() { + let mut p = kotlin_lsp_stop().await; + let stopped = LinkState::Suspended { generation: 1, thread: Some(super::command_tests::THREAD) }; + let resume = command(72, 1, 9, &[]); + assert_eq!(through_to_jvm(&mut p, std::slice::from_ref(&resume)).await, [resume]); + assert_eq!(p.link.state(), stopped); + let thread_resume = command(73, 11, 3, &super::command_tests::THREAD.to_be_bytes()); + let seen = through_to_jvm(&mut p, std::slice::from_ref(&thread_resume)).await; + assert_eq!(seen.len(), 2, "{seen:?}"); + assert_eq!(seen[0], thread_resume); + assert!(is_dispose_of_the_read_object(&seen[1]), "{seen:?}"); + wait_state(&p.link, LinkState::Ready).await; + assert_eq!(p.link.generation(), 1); +} + +/// fwcd's class prepare requests stay enabled all session with JDI's default `SUSPEND_ALL`, and +/// it resumes their events with `VirtualMachine.Resume`: a class loaded on another thread while +/// one is stopped leaves that stop, and the objects read in it, alone. +#[tokio::test] +async fn fwcd_class_load_on_another_thread_keeps_the_stop_and_its_objects() { + use super::command_tests::{THREAD, own_instances, result, spawn_command, tagged}; + let mut p = connected(OPEN).await; + stop(&mut p, 1, THREAD).await; + let stopped = LinkState::Suspended { generation: 1, thread: Some(THREAD) }; + wait_state(&p.link, stopped.clone()).await; + let task = spawn_command(&p.link, WAIT); + let id = own_instances(&mut p).await; + p.jvm.write_all(&reply(id, 0, &tagged(&[0x11]))).await.unwrap(); + assert_eq!(result(task).await, Ok(tagged(&[0x11]))); + let prepare = event(2, 8, THREAD + 1); + p.jvm.write_all(&prepare).await.unwrap(); + assert_eq!(read_packet(&mut p.adapter).await, prepare); + let resume = command(72, 1, 9, &[]); + assert_eq!(through_to_jvm(&mut p, std::slice::from_ref(&resume)).await, [resume]); + assert_eq!(p.link.state(), stopped); + let thread_resume = command(73, 11, 3, &THREAD.to_be_bytes()); + let seen = through_to_jvm(&mut p, std::slice::from_ref(&thread_resume)).await; + assert_eq!(seen.len(), 2, "{seen:?}"); + assert!(is_dispose_of_the_read_object(&seen[1]), "{seen:?}"); + wait_state(&p.link, LinkState::Ready).await; +} + +/// A class prepare on a thread the VM hides carries the null thread; its suspension of every +/// thread still needs its `VirtualMachine.Resume`. +#[tokio::test] +async fn class_load_on_a_hidden_thread_keeps_the_stop() { + use super::command_tests::THREAD; + let mut p = connected(OPEN).await; + stop(&mut p, 1, THREAD).await; + let stopped = LinkState::Suspended { generation: 1, thread: Some(THREAD) }; + wait_state(&p.link, stopped.clone()).await; + let prepare = event(2, 8, 0); + p.jvm.write_all(&prepare).await.unwrap(); + assert_eq!(read_packet(&mut p.adapter).await, prepare); + let resume = command(72, 1, 9, &[]); + assert_eq!(through_to_jvm(&mut p, std::slice::from_ref(&resume)).await, [resume]); + assert_eq!(p.link.state(), stopped); + resume_thread(&mut p, THREAD).await; + wait_state(&p.link, LinkState::Ready).await; +} + +#[tokio::test] +async fn kotlin_lsp_thread_resume_before_vm_resume_ends_the_stop_once() { + let mut p = kotlin_lsp_stop().await; + let thread_resume = command(72, 11, 3, &super::command_tests::THREAD.to_be_bytes()); + let resume = command(73, 1, 9, &[]); + let seen = through_to_jvm(&mut p, &[thread_resume.clone(), resume.clone()]).await; + let (own, adapter): (Vec<_>, Vec<_>) = seen.iter().partition(|packet| is_dispose_of_the_read_object(packet)); + assert_eq!(adapter, [&thread_resume, &resume], "{seen:?}"); + assert_eq!(own.len(), 1, "{seen:?}"); + assert_eq!(seen[0], thread_resume, "the disposal follows the resume that ends the stop"); + wait_state(&p.link, LinkState::Ready).await; + assert_eq!(p.link.generation(), 1); +} + +#[tokio::test] +async fn adapter_id_in_reserved_range_degrades() { + let mut p = connected(OPEN).await; + let packet = command(0x7000_0005, 1, 1, &[]); + p.adapter.write_all(&packet).await.unwrap(); + assert_eq!(read_packet(&mut p.jvm).await, packet); + wait_state(&p.link, LinkState::Degraded("adapter packet id in the reserved range".into())).await; + // the link never sent that id, so its reply is the adapter's + let answer = reply(0x7000_0005, 0, &[1, 2, 3]); + p.jvm.write_all(&answer).await.unwrap(); + assert_eq!(read_packet(&mut p.adapter).await, answer); +} + +#[tokio::test] +async fn untracked_reserved_reply_never_reaches_adapter() { + let mut p = pair(OPEN); + let own = handshake(&mut p).await; + let id_sizes = reply(own, 0, &id_sizes_body()); + p.jvm.write_all(&id_sizes).await.unwrap(); + // A late duplicate of a reply the link already took, and an id nobody sent. + p.jvm.write_all(&id_sizes).await.unwrap(); + p.jvm.write_all(&reply(own + 7, 0, &[0xEE; 64])).await.unwrap(); + jvm_marker(&mut p).await; +} + +#[tokio::test] +async fn adapter_reserved_reply_reaches_adapter_once() { + let mut p = connected(OPEN).await; + let id = OWN_IDS.start + 5; + let invoke = command(id, 9, 6, &[0; 24]); + p.adapter.write_all(&invoke).await.unwrap(); + assert_eq!(read_packet(&mut p.jvm).await, invoke); + wait_state(&p.link, LinkState::Degraded(RESERVED_ID.into())).await; + assert!(p.link.adapter_invoking()); + let answer = reply(id, 0, &[b'I', 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + p.jvm.write_all(&answer).await.unwrap(); + assert_eq!(read_packet(&mut p.adapter).await, answer); + assert!(!p.link.adapter_invoking()); + // The adapter got its one answer; a second reply for the id is no longer its. + p.jvm.write_all(&answer).await.unwrap(); + jvm_marker(&mut p).await; +} + +#[tokio::test] +async fn adapter_command_reusing_a_pending_own_id_waits_for_the_own_reply() { + let mut p = pair(OPEN); + let own = handshake(&mut p).await; + let colliding = command(own, 1, 1, &[]); + p.adapter.write_all(&colliding).await.unwrap(); + wait_state(&p.link, LinkState::Degraded(RESERVED_ID.into())).await; + let mut byte = [0; 1]; + let early = tokio::time::timeout(Duration::from_millis(200), p.jvm.read(&mut byte)).await; + assert!(early.is_err(), "adapter packet reached the JVM while the link's {own:#x} was pending: {early:?}"); + p.jvm.write_all(&reply(own, 0, &id_sizes_body())).await.unwrap(); + assert_eq!(read_packet(&mut p.jvm).await, colliding); + let answer = reply(own, 0, b"adapter"); + p.jvm.write_all(&answer).await.unwrap(); + // The adapter's first packet is its own answer, and the link took the IDSizes reply. + assert_eq!(read_packet(&mut p.adapter).await, answer); + assert!(p.link.inner.shared.update(|core| core.sizes.is_some())); +} + +#[test] +fn own_ids_skip_ids_still_pending() { + let mut core = Core { next_own: OWN_IDS.start, ..Core::default() }; + core.adapter_reserved.insert(OWN_IDS.start, 1); + core.own.insert(OWN_IDS.start + 1, OwnCommand::IdSizes); + assert_eq!(core.register_own(OwnCommand::IdSizes), OWN_IDS.start + 2); +} + +#[tokio::test] +async fn vm_dispose_closes_link() { + for (set, cmd) in [(1, 6), (1, 10)] { + let mut p = connected(OPEN).await; + stop(&mut p, 2, 9).await; + let exit_code: &[u8] = if cmd == 10 { &[0; 4] } else { &[] }; + let packet = command(40, set, cmd, exit_code); + p.adapter.write_all(&packet).await.unwrap(); + assert_eq!(read_packet(&mut p.jvm).await, packet); + wait_state(&p.link, LinkState::Closed).await; + // the adapter still gets its answer, then the JVM hangs up + let answer = reply(40, 0, &[]); + p.jvm.write_all(&answer).await.unwrap(); + assert_eq!(read_packet(&mut p.adapter).await, answer); + stop(&mut p, 2, 9).await; + assert_eq!(p.link.state(), LinkState::Closed); + drop(p.jvm); + assert_eof(&mut p.adapter, "adapter").await; + } +} + +#[derive(Debug, Clone)] +enum JvmPacket { + Reply { id: u32, error: u16, body: Vec }, + Event { policy: u8, kind: u8, thread: u64 }, + RawEvent(Vec), + OwnReply, +} + +fn adapter_packets() -> impl Strategy>> { + let cmd = prop_oneof![ + Just((1u8, 9u8)), + Just((11, 3)), + Just((9, 6)), + Just((3, 3)), + Just((1, 7)), + (any::(), any::()), + ]; + let packet = (1u32..OWN_IDS.start, cmd, any::(), vec(any::(), 0..48)).prop_map( + |(id, (set, cmd), is_reply, body)| { + let mut p = command(id, set, cmd, &body); + if is_reply { + p[8] = FLAG_REPLY; + } + p + }, + ); + vec(packet, 0..24) +} + +fn jvm_packets() -> impl Strategy, usize)> { + let packet = + prop_oneof![ + (1u32..OWN_IDS.start, any::(), vec(any::(), 0..48)) + .prop_map(|(id, error, body)| JvmPacket::Reply { id, error, body }), + (0u8..3, any::(), any::()).prop_map(|(policy, kind, thread)| JvmPacket::Event { + policy, + kind, + thread + }), + vec(any::(), 0..32).prop_map(JvmPacket::RawEvent), + ]; + (vec(packet, 0..24), any::()) +} + +fn adapter_and_jvm_packets() -> impl Strategy>, Vec)> { + (adapter_packets(), jvm_packets()).prop_map(|(adapter, (mut jvm, at))| { + let at = at % (jvm.len() + 1); + jvm.insert(at, JvmPacket::OwnReply); + (adapter, jvm) + }) +} + +fn jvm_bytes(packet: &JvmPacket, own: u32) -> Vec { + match packet { + JvmPacket::Reply { id, error, body } => reply(*id, *error, body), + JvmPacket::Event { policy, kind, thread } => { + let mut e = event(*policy, *kind, *thread); + e[4..8].copy_from_slice(&0x200u32.to_be_bytes()); + e + } + JvmPacket::RawEvent(body) => command(0x300, 64, 100, body), + JvmPacket::OwnReply => reply(own, 0, &id_sizes_body()), + } +} + +async fn passthrough(adapter_sent: Vec>, jvm_script: Vec) { + let Pair { link, adapter, jvm } = pair(OPEN); + let (mut adapter_rx, mut adapter_tx) = tokio::io::split(adapter); + let (mut jvm_rx, mut jvm_tx) = tokio::io::split(jvm); + let upstream: Vec = adapter_sent.concat(); + let upstream_len = upstream.len(); + let adapter_writer = tokio::spawn(async move { + adapter_tx.write_all(HANDSHAKE).await.unwrap(); + adapter_tx.write_all(&upstream).await.unwrap(); + adapter_tx + }); + let mut buf = [0; 14]; + within("handshake", jvm_rx.read_exact(&mut buf)).await.unwrap(); + let own_packet = read_packet(&mut jvm_rx).await; + let own = Header::parse(own_packet[..HEADER_LEN].try_into().unwrap()).id; + let mut expected_down = HANDSHAKE.to_vec(); + let mut downstream = HANDSHAKE.to_vec(); + for p in &jvm_script { + let bytes = jvm_bytes(p, own); + if !matches!(p, JvmPacket::OwnReply) { + expected_down.extend_from_slice(&bytes); + } + downstream.extend_from_slice(&bytes); + } + let jvm_writer = tokio::spawn(async move { + jvm_tx.write_all(&downstream).await.unwrap(); + jvm_tx + }); + let mut got_up = vec![0; upstream_len]; + within("upstream bytes", jvm_rx.read_exact(&mut got_up)).await.unwrap(); + let mut got_down = vec![0; expected_down.len()]; + within("downstream bytes", adapter_rx.read_exact(&mut got_down)).await.unwrap(); + let _open = (adapter_writer.await.unwrap(), jvm_writer.await.unwrap(), link); + assert_eq!(got_up, adapter_sent.concat()); + assert_eq!(got_down, expected_down); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + #[test] + fn passthrough_is_byte_exact((adapter, jvm) in adapter_and_jvm_packets()) { + let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap(); + rt.block_on(passthrough(adapter, jvm)); + } +} diff --git a/crates/ide-jdwp/src/link/upstream.rs b/crates/ide-jdwp/src/link/upstream.rs new file mode 100644 index 000000000..ddc9bfaf8 --- /dev/null +++ b/crates/ide-jdwp/src/link/upstream.rs @@ -0,0 +1,163 @@ +//! Adapter → JVM. + +use super::{Core, HANDSHAKE, OWN_IDS, OwnCommand, RESERVED_ID, Shared, check_handshake}; +use crate::codec::{HEADER_LEN, Header, MAX_ADAPTER_PACKET}; +use std::io; +use tokio::io::{AsyncRead, AsyncReadExt}; +use tokio::sync::watch; + +const MAX_HELD: usize = 1024; + +pub(super) async fn run( + shared: &Shared, + mut adapter: impl AsyncRead + Unpin, + mut released: watch::Receiver, + hold: bool, +) -> io::Result<()> { + let mut handshake = [0; HANDSHAKE.len()]; + adapter.read_exact(&mut handshake).await?; + check_handshake(&handshake)?; + { + let mut jvm = shared.jvm.lock().await; + let jvm = jvm.as_mut().ok_or_else(closed)?; + jvm.write_all(&handshake).await?; + let id = shared.update(|core| core.register_own(OwnCommand::IdSizes)); + jvm.write_all(&Header::command_bytes(id, 1, 7, 0)).await?; + jvm.flush().await?; + } + let mut open = !hold; + let mut held: Vec> = Vec::new(); + loop { + // Keep the in-flight read, including a partly read packet, across the other branches. + let read = packet(&mut adapter); + tokio::pin!(read); + let packet = loop { + tokio::select! { + packet = &mut read => break packet?, + result = async { released.wait_for(|released| *released).await.map(drop) }, if !open => { + result.map_err(|_| io::Error::new(io::ErrorKind::ConnectionAborted, "link dropped"))?; + open = true; + for packet in held.drain(..) { + forward(shared, &packet).await?; + } + } + () = shared.unfinished.notified() => finish_own(shared).await?, + } + }; + // Everything else must go through: attach itself queries threads and sets requests. + if !open && is_resume(&packet) { + if held.len() >= MAX_HELD { + return Err(io::Error::new(io::ErrorKind::InvalidData, "too many held JDWP resumes")); + } + held.push(packet); + } else { + forward(shared, &packet).await?; + } + } +} + +/// Writes what a cancelled command of the link's own left unwritten. Fails once a failed write +/// took the writer, which ends the relay. +async fn finish_own(shared: &Shared) -> io::Result<()> { + shared.jvm.lock().await.as_mut().ok_or_else(closed)?.finish().await +} + +async fn packet(reader: &mut (impl AsyncRead + Unpin)) -> io::Result> { + let mut header = [0; HEADER_LEN]; + reader.read_exact(&mut header).await?; + let len = Header::parse(&header).length as usize; + if !(HEADER_LEN..=MAX_ADAPTER_PACKET).contains(&len) { + return Err(io::Error::new(io::ErrorKind::InvalidData, format!("invalid JDWP packet length {len}"))); + } + let mut packet = vec![0; len]; + packet[..HEADER_LEN].copy_from_slice(&header); + reader.read_exact(&mut packet[HEADER_LEN..]).await?; + Ok(packet) +} + +fn header(packet: &[u8]) -> Header { + Header::parse(packet[..HEADER_LEN].try_into().expect("packet() returns at least a header")) +} + +fn is_resume(packet: &[u8]) -> bool { + let h = header(packet); + // VirtualMachine.Resume, ThreadReference.Resume + !h.is_reply() && matches!(h.command(), (1, 9) | (11, 3)) +} + +/// ClassType.InvokeMethod, ClassType.NewInstance, InterfaceType.InvokeMethod, +/// ObjectReference.InvokeMethod. +pub(super) fn is_invoke(command: (u8, u8)) -> bool { + matches!(command, (3, 3) | (3, 4) | (5, 1) | (9, 6)) +} + +async fn forward(shared: &Shared, packet: &[u8]) -> io::Result<()> { + let h = header(packet); + if OWN_IDS.contains(&h.id) { + shared.update(|core| { + core.degraded.get_or_insert_with(|| RESERVED_ID.to_string()); + }); + } + let command = (!h.is_reply()).then(|| h.command()); + let (mut jvm, release) = loop { + let answered = shared.own_answered.notified(); + tokio::pin!(answered); + answered.as_mut().enable(); + let jvm = shared.jvm.lock().await; + // Before the write: the reply, or the stop a resume lets happen, may be read before + // this task runs again. + let registered = shared.update(|core| { + if command.is_some() && OWN_IDS.contains(&h.id) { + // A reply carries only its id. The link never invokes, so the JVM answers its + // command without waiting on anything; until then this command waits. + if core.own.contains_key(&h.id) { + return None; + } + *core.adapter_reserved.entry(h.id).or_default() += 1; + } + if command.is_some_and(is_invoke) { + core.invokes.insert(h.id); + } + Some(match command { + Some(resume @ ((1, 9) | (11, 3))) => core.resume(resume, &packet[HEADER_LEN..]), + // VirtualMachine.Suspend, ThreadReference.Suspend + Some(suspend @ ((1, 8) | (11, 2))) => { + core.suspend(h.id, suspend, &packet[HEADER_LEN..]); + false + } + _ => false, + }) + }); + if let Some(release) = registered { + break (jvm, release); + } + drop(jvm); + answered.await; + }; + let jvm = jvm.as_mut().ok_or_else(closed)?; + jvm.write_all(packet).await?; + jvm.flush().await?; + // Still under the writer lock, so no command of the link's own can slip in between the + // write and the state change. + match command { + Some((1, 9) | (11, 3)) if release => { + // Only once the resume is on its way: the ids stay valid for the whole stop. + let disposals = shared.update(Core::disposals); + for packet in &disposals { + jvm.write_all(packet).await?; + } + if !disposals.is_empty() { + jvm.flush().await?; + } + } + // VirtualMachine.Dispose, VirtualMachine.Exit: the session is over, but the adapter + // still gets the JVM's answer. + Some((1, 6) | (1, 10)) => shared.update(|core| core.close()), + _ => {} + } + Ok(()) +} + +fn closed() -> io::Error { + io::Error::new(io::ErrorKind::NotConnected, "JDWP link closed") +} diff --git a/crates/ide-jdwp/src/link/write_tests.rs b/crates/ide-jdwp/src/link/write_tests.rs new file mode 100644 index 000000000..a98c4dcb8 --- /dev/null +++ b/crates/ide-jdwp/src/link/write_tests.rs @@ -0,0 +1,211 @@ +//! A packet of the link's own that a cancelled caller or a failing socket cuts short, and a +//! flush that returns only after the JVM already acted on the packet. + +use super::command_tests::{ + THREAD, entries, header, instances_body, own_instances, result, resume, spawn_command, suspended, tagged, +}; +use super::tests::{ + OPEN, Pair, WAIT, adapter_marker, assert_eof, command, connect, pair_over, read_packet, reply, stop, wait_state, + within, +}; +use super::*; +use crate::codec::{HEADER_LEN, Header}; +use crate::ids::ReplyIds; +use std::pin::Pin; +use std::task::{Context, Poll, Waker, ready}; +use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream, ReadBuf}; + +/// Body bytes of the link's `Instances` the JVM gets before the writer stalls or fails. +const CUT: usize = 3; + +#[derive(Default)] +struct Gate { + /// Bytes still let through; `None`: no limit. + budget: Option, + /// Once the budget is spent: fail writes instead of stalling them. + fail: bool, + /// Bytes still to be written before flushes stall; `None`: flushes never stall. + flush_after: Option, + waker: Option, +} + +#[derive(Clone, Default)] +struct Valve(Arc>); + +impl Valve { + fn limit(&self, bytes: usize, fail: bool) { + let mut gate = self.0.lock().unwrap(); + gate.budget = Some(bytes); + gate.fail = fail; + } + + /// Writes go through, but once `bytes` more are written flushes stay pending until [`Self::open`]. + fn stall_flush_after(&self, bytes: usize) { + self.0.lock().unwrap().flush_after = Some(bytes); + } + + fn open(&self) { + let mut gate = self.0.lock().unwrap(); + gate.budget = None; + gate.fail = false; + gate.flush_after = None; + if let Some(waker) = gate.waker.take() { + waker.wake(); + } + } +} + +/// The link's end of the JVM stream; writes only what the valve lets through. +struct Throttled { + inner: DuplexStream, + valve: Valve, +} + +impl AsyncRead for Throttled { + fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_read(cx, buf) + } +} + +impl AsyncWrite for Throttled { + fn poll_write(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll> { + let this = self.get_mut(); + let mut gate = this.valve.0.lock().unwrap(); + let allowed = match gate.budget { + None => buf.len(), + Some(0) if gate.fail => return Poll::Ready(Err(io::ErrorKind::BrokenPipe.into())), + Some(0) => { + gate.waker = Some(cx.waker().clone()); + return Poll::Pending; + } + Some(n) => n.min(buf.len()), + }; + let written = ready!(Pin::new(&mut this.inner).poll_write(cx, &buf[..allowed])); + if let (Ok(n), Some(budget)) = (&written, gate.budget.as_mut()) { + *budget -= n; + } + if let (Ok(n), Some(left)) = (&written, gate.flush_after.as_mut()) { + *left = left.saturating_sub(*n); + } + Poll::Ready(written) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + let mut gate = this.valve.0.lock().unwrap(); + if gate.flush_after == Some(0) { + gate.waker = Some(cx.waker().clone()); + return Poll::Pending; + } + drop(gate); + Pin::new(&mut this.inner).poll_flush(cx) + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_shutdown(cx) + } +} + +async fn throttled() -> (Pair, Valve) { + let valve = Valve::default(); + let p = pair_over(OPEN, { + let valve = valve.clone(); + move |inner| Throttled { inner, valve } + }); + (suspended(connect(p).await).await, valve) +} + +/// Answers the `SuspendCount` check, then lets through only the header and [`CUT`] body bytes +/// of the link's `Instances`, and reads them. +async fn cut_command(p: &mut Pair, valve: &Valve, fail: bool) -> Header { + let check = read_packet(&mut p.jvm).await; + let h = header(&check); + assert_eq!((h.command(), &check[HEADER_LEN..]), ((11, 12), &THREAD.to_be_bytes()[..])); + valve.limit(HEADER_LEN + CUT, fail); + p.jvm.write_all(&reply(h.id, 0, &1i32.to_be_bytes())).await.unwrap(); + let mut head = [0; HEADER_LEN + CUT]; + within("cut packet", p.jvm.read_exact(&mut head)).await.unwrap(); + let h = header(&head); + assert_eq!((h.command(), h.length as usize), ((2, 16), HEADER_LEN + instances_body().len())); + assert_eq!(&head[HEADER_LEN..], &instances_body()[..CUT]); + h +} + +async fn read_rest(p: &mut Pair) { + let mut rest = vec![0; instances_body().len() - CUT]; + within("rest of the cut packet", p.jvm.read_exact(&mut rest)).await.unwrap(); + assert_eq!(rest, &instances_body()[CUT..]); +} + +/// The cut command's reply is the link's and counted, and the turn comes free with it. +async fn answer_cut_then_run_another(p: &mut Pair, cut: u32) { + p.jvm.write_all(&reply(cut, 0, &tagged(&[0x66]))).await.unwrap(); + let next = spawn_command(&p.link, WAIT); + let id = own_instances(p).await; + p.jvm.write_all(&reply(id, 0, &tagged(&[0x77]))).await.unwrap(); + assert_eq!(result(next).await, Ok(tagged(&[0x77]))); + assert_eq!(entries(&resume(p).await), vec![vec![(0x66, 1), (0x77, 1)]]); +} + +#[tokio::test] +async fn timed_out_write_ends_its_packet_before_the_adapter_writes() { + let (mut p, valve) = throttled().await; + let task = spawn_command(&p.link, Duration::from_millis(300)); + let cut = cut_command(&mut p, &valve, false).await; + // the relay waits for the writer with the adapter's packet when the caller gives up + let marker = command(0x99, 1, 1, &[]); + p.adapter.write_all(&marker).await.unwrap(); + assert_eq!(result(task).await, Err(LinkError::TimedOut)); + valve.open(); + read_rest(&mut p).await; + assert_eq!(read_packet(&mut p.jvm).await, marker); + answer_cut_then_run_another(&mut p, cut.id).await; +} + +#[tokio::test] +async fn cancelled_write_is_finished_without_another_writer() { + let (mut p, valve) = throttled().await; + let task = spawn_command(&p.link, WAIT); + let cut = cut_command(&mut p, &valve, false).await; + task.abort(); + assert!(within("abort", task).await.unwrap_err().is_cancelled()); + valve.open(); + read_rest(&mut p).await; + answer_cut_then_run_another(&mut p, cut.id).await; + // nothing but the link's own task holds the JVM stream + let Pair { link, adapter: _adapter, mut jvm } = p; + drop(link); + assert_eof(&mut jvm, "jvm").await; +} + +#[tokio::test] +async fn failed_write_closes_the_link() { + let (mut p, valve) = throttled().await; + let task = spawn_command(&p.link, WAIT); + cut_command(&mut p, &valve, true).await; + assert_eq!(result(task).await, Err(LinkError::Closed)); + wait_state(&p.link, LinkState::Closed).await; + // writes work again, but no adapter byte may follow the cut packet + valve.open(); + let _ = p.adapter.write_all(&command(0x99, 1, 1, &[])).await; + let mut rest = Vec::new(); + within("jvm eof", p.jvm.read_to_end(&mut rest)).await.unwrap(); + assert_eq!(rest, Vec::::new()); + assert_eof(&mut p.adapter, "adapter").await; + let refused = p.link.command(2, 16, instances_body(), ReplyIds::TaggedObjects, WAIT).await; + assert_eq!(refused, Err(LinkError::Closed)); +} + +#[tokio::test] +async fn a_stop_while_the_resume_is_flushed_is_kept() { + let (mut p, valve) = throttled().await; + let resume = command(50, 1, 9, &[]); + valve.stall_flush_after(resume.len()); + p.adapter.write_all(&resume).await.unwrap(); + assert_eq!(read_packet(&mut p.jvm).await, resume); + // The JVM runs and stops again before the link's flush of the resume returns. + stop(&mut p, 2, THREAD).await; + valve.open(); + adapter_marker(&mut p).await; + assert_eq!(p.link.state(), LinkState::Suspended { generation: 2, thread: Some(THREAD) }); +} diff --git a/crates/ide-jdwp/src/link/writer.rs b/crates/ide-jdwp/src/link/writer.rs new file mode 100644 index 000000000..09af2aa83 --- /dev/null +++ b/crates/ide-jdwp/src/link/writer.rs @@ -0,0 +1,50 @@ +//! The link's end of the JVM socket. + +use std::io; +use tokio::io::{AsyncWrite, AsyncWriteExt}; + +/// Keeps the unwritten rest of a queued packet when a write of it is cancelled part way, so +/// every later write finishes that packet first and the JVM never reads a cut one. +pub(super) struct JvmWriter { + out: Box, + queued: Vec, + written: usize, +} + +impl JvmWriter { + pub(super) fn new(out: impl AsyncWrite + Send + Unpin + 'static) -> JvmWriter { + JvmWriter { out: Box::new(out), queued: Vec::new(), written: 0 } + } + + /// Writes the rest of the queued packet and flushes. Cancel-safe: a dropped call leaves + /// the unwritten bytes queued. + pub(super) async fn finish(&mut self) -> io::Result<()> { + while self.written < self.queued.len() { + let n = self.out.write(&self.queued[self.written..]).await?; + if n == 0 { + return Err(io::ErrorKind::WriteZero.into()); + } + self.written += n; + } + self.queued = Vec::new(); + self.written = 0; + self.out.flush().await + } + + /// Call after [`Self::finish`] succeeded, with no await in between. + pub(super) fn queue(&mut self, packet: Vec) { + debug_assert!(self.queued.is_empty(), "a queued packet is still unwritten"); + self.queued = packet; + self.written = 0; + } + + /// Not cancel-safe: only for the relay task, which stops only when the link ends. + pub(super) async fn write_all(&mut self, bytes: &[u8]) -> io::Result<()> { + self.finish().await?; + self.out.write_all(bytes).await + } + + pub(super) async fn flush(&mut self) -> io::Result<()> { + self.finish().await + } +} diff --git a/crates/ide-jdwp/tests/real_jvm.rs b/crates/ide-jdwp/tests/real_jvm.rs new file mode 100644 index 000000000..55f4692af --- /dev/null +++ b/crates/ide-jdwp/tests/real_jvm.rs @@ -0,0 +1,422 @@ +//! The link against a real JVM, with the test playing the adapter: +//! `OPENIDEA_TEST_JAVA=/bin/java cargo test -p ide-jdwp --test real_jvm -- --ignored`. +//! +//! A JDK 8 `java` cannot run a source file: also set `OPENIDEA_TEST_JAVAC` to the `javac` of a +//! newer JDK, which compiles `Main.java` with `--release 8` first. + +use ide_jdwp::{ + HEADER_LEN, Header, IdSizes, JdwpLink, LinkInspector, LinkOptions, LinkState, Reader, ReplyIds, Writer, object_ids, +}; +use ide_jvm_inspect::{JvmInspector, JvmValue, VmTarget}; +use std::collections::VecDeque; +use std::io::{BufRead, BufReader}; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +const WAIT: Duration = Duration::from_secs(30); +const INVALID_OBJECT: u16 = 20; + +const MAIN: &str = r#" +import java.util.ArrayList; +import java.util.List; + +public class Main { + static final class Item { + final int n; + final String name; + Item(int n) { this.n = n; this.name = "item-" + n; } + } + + static final class Row { + final String label; + Row(int i) { this.label = "row-" + i; } + } + + static final List ITEMS = new ArrayList(); + static final List ROWS = new ArrayList(); + + static void pause() { + try { + Thread.sleep(20); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + public static void main(String[] args) { + for (int i = 0; i < 5; i++) { + ITEMS.add(new Item(i)); + } + for (int i = 0; i < 1000; i++) { + ROWS.add(new Row(i)); + } + System.out.println("READY"); + while (true) { + pause(); + } + } +} +"#; + +/// The debuggee; killed on drop, so a failed assert never leaves it running. +struct Jvm { + child: Child, + lines: mpsc::Receiver, + _dir: tempfile::TempDir, +} + +impl Drop for Jvm { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +impl Jvm { + fn launch(java: &str) -> Jvm { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("Main.java"), MAIN).unwrap(); + let agent = "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=127.0.0.1:0"; + let mut command = Command::new(java); + command.current_dir(dir.path()).arg(agent); + match std::env::var("OPENIDEA_TEST_JAVAC") { + Ok(javac) => { + let status = Command::new(javac) + .current_dir(dir.path()) + .args(["--release", "8", "-d", ".", "Main.java"]) + .status() + .unwrap(); + assert!(status.success(), "javac failed: {status}"); + command.args(["-cp", ".", "Main"]); + } + Err(_) => { + command.arg("Main.java"); + } + } + let mut child = command.stdout(Stdio::piped()).stderr(Stdio::inherit()).spawn().unwrap(); + let stdout = child.stdout.take().unwrap(); + let (tx, lines) = mpsc::channel(); + std::thread::spawn(move || { + for line in BufReader::new(stdout).lines().map_while(Result::ok) { + if tx.send(line).is_err() { + break; + } + } + }); + Jvm { child, lines, _dir: dir } + } + + fn line_starting(&self, prefix: &str) -> String { + loop { + let line = self.lines.recv_timeout(WAIT).unwrap_or_else(|e| panic!("no line {prefix:?}: {e}")); + if line.starts_with(prefix) { + return line; + } + } + } + + fn assert_alive(&mut self) { + assert_eq!(self.child.try_wait().unwrap(), None, "the JVM exited"); + } +} + +/// The debug adapter's side of the link, speaking JDWP through the codec. +struct Adapter { + stream: TcpStream, + next_id: u32, + sizes: Option, + events: VecDeque>, +} + +impl Adapter { + async fn connect(port: u16) -> Adapter { + let mut stream = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + stream.write_all(b"JDWP-Handshake").await.unwrap(); + let mut handshake = [0; 14]; + within("handshake", stream.read_exact(&mut handshake)).await.unwrap(); + assert_eq!(&handshake, b"JDWP-Handshake"); + let mut adapter = Adapter { stream, next_id: 1, sizes: None, events: VecDeque::new() }; + let sizes = adapter.ok(1, 7, Vec::new()).await; + adapter.sizes = Some(IdSizes::parse(&sizes).unwrap()); + adapter + } + + fn sizes(&self) -> IdSizes { + self.sizes.unwrap() + } + + async fn packet(&mut self) -> (Header, Vec) { + let mut header = [0; HEADER_LEN]; + within("packet", self.stream.read_exact(&mut header)).await.unwrap(); + let h = Header::parse(&header); + let mut body = vec![0; h.length as usize - HEADER_LEN]; + within("body", self.stream.read_exact(&mut body)).await.unwrap(); + (h, body) + } + + /// Error code and body of the reply; events that arrive first are kept for later. + async fn request(&mut self, set: u8, cmd: u8, body: Vec) -> (u16, Vec) { + let id = self.next_id; + self.next_id += 1; + let mut packet = Header::command_bytes(id, set, cmd, body.len()).to_vec(); + packet.extend_from_slice(&body); + self.stream.write_all(&packet).await.unwrap(); + loop { + let (h, body) = self.packet().await; + if !h.is_reply() && h.command() == (64, 100) { + self.events.push_back(body); + continue; + } + // The link's own replies must never get here. + assert!(h.is_reply() && h.id == id, "unexpected packet id {:#x} command {:?}", h.id, h.command()); + return (h.error(), body); + } + } + + async fn ok(&mut self, set: u8, cmd: u8, body: Vec) -> Vec { + let (error, reply) = self.request(set, cmd, body).await; + assert_eq!(error, 0, "{set}/{cmd} failed"); + reply + } + + async fn event(&mut self) -> Vec { + if let Some(event) = self.events.pop_front() { + return event; + } + loop { + let (h, body) = self.packet().await; + assert!(!h.is_reply(), "unexpected reply id {:#x}", h.id); + if h.command() == (64, 100) { + return body; + } + } + } + + /// The class's reference type id. + async fn class(&mut self, signature: &str) -> u64 { + let mut w = Writer::new(); + w.string(signature); + let reply = self.ok(1, 2, w.into_vec()).await; + let mut r = Reader::new(&reply); + assert_eq!(r.count().unwrap(), 1, "{signature} loaded {reply:?}"); + r.u8().unwrap(); + r.id(self.sizes().reference_type).unwrap() + } + + async fn method(&mut self, class: u64, name: &str) -> u64 { + let mut w = Writer::new(); + w.id(class, self.sizes().reference_type); + let reply = self.ok(2, 5, w.into_vec()).await; + let mut r = Reader::new(&reply); + for _ in 0..r.count().unwrap() { + let id = r.id(self.sizes().method).unwrap(); + let method = r.string().unwrap(); + r.string().unwrap(); + r.i32().unwrap(); + if method == name { + return id; + } + } + panic!("no method {name}"); + } + + async fn break_at_start(&mut self, class: u64, method: u64) { + let mut w = Writer::new(); + w.u8(2); // BREAKPOINT + w.u8(2); // SUSPEND_ALL + w.i32(1); + w.u8(7); // LocationOnly + w.u8(1); // CLASS + w.id(class, self.sizes().reference_type); + w.id(method, self.sizes().method); + w.i64(0); + self.ok(15, 1, w.into_vec()).await; + } + + async fn reference_type_error(&mut self, object: u64) -> u16 { + let mut w = Writer::new(); + w.id(object, self.sizes().object); + self.request(9, 1, w.into_vec()).await.0 + } +} + +fn instances_body(class: u64, sizes: &IdSizes) -> Vec { + let mut w = Writer::new(); + w.id(class, sizes.reference_type); + w.i32(1000); + w.into_vec() +} + +async fn within(what: &str, f: impl Future) -> T { + tokio::time::timeout(WAIT, f).await.unwrap_or_else(|_| panic!("timed out: {what}")) +} + +async fn wait_suspended(link: &JdwpLink, generation: u64) { + let mut states = link.subscribe(); + let seen = within( + "link suspended", + states.wait_for( + |state| matches!(state, LinkState::Suspended { generation: g, thread: Some(_) } if *g == generation), + ), + ) + .await + .map(|state| state.clone()); + assert!(seen.is_ok(), "link state {:?}", link.state()); +} + +/// A JVM stopped at a breakpoint in `Main.pause` (generation 1), with the test as its adapter. +struct Session { + jvm: Jvm, + link: JdwpLink, + adapter: Adapter, +} + +async fn stopped() -> Session { + let java = std::env::var("OPENIDEA_TEST_JAVA").expect("OPENIDEA_TEST_JAVA"); + let jvm = Jvm::launch(&java); + let listening = jvm.line_starting("Listening for transport dt_socket at address: "); + let port: u16 = listening.rsplit(' ').next().unwrap().trim().parse().unwrap(); + jvm.line_starting("READY"); + + let (link, adapter_port) = + JdwpLink::start("127.0.0.1".into(), port, LinkOptions { hold_resumes: false }).await.unwrap(); + let mut adapter = Adapter::connect(adapter_port).await; + assert_eq!(link.id_sizes(), Some(adapter.sizes()), "the link's own IDSizes"); + + let main = adapter.class("LMain;").await; + let pause = adapter.method(main, "pause").await; + adapter.break_at_start(main, pause).await; + adapter.event().await; + wait_suspended(&link, 1).await; + Session { jvm, link, adapter } +} + +#[tokio::test] +#[ignore = "needs a JDK: OPENIDEA_TEST_JAVA=/bin/java"] +async fn link_releases_exactly_its_own_references() { + let Session { mut jvm, link, mut adapter } = stopped().await; + let sizes = adapter.sizes(); + let item = adapter.class("LMain$Item;").await; + + // The adapter holds its own reference to each Item, as java-debug would. + let own = adapter.ok(2, 16, instances_body(item, &sizes)).await; + let mut adapter_ids = object_ids(ReplyIds::TaggedObjects, &own, &sizes).unwrap(); + let reply = link + .command(2, 16, instances_body(item, &sizes), ReplyIds::TaggedObjects, WAIT) + .await + .expect("the link's Instances"); + let mut link_ids = object_ids(ReplyIds::TaggedObjects, &reply, &sizes).unwrap(); + assert_eq!(link_ids.len(), 5); + adapter_ids.sort_unstable(); + link_ids.sort_unstable(); + assert_eq!(link_ids, adapter_ids, "both sides got the same ids"); + for &id in &link_ids { + assert_eq!(adapter.reference_type_error(id).await, 0); + } + + // The link releases its references once this resume is written. + adapter.ok(1, 9, Vec::new()).await; + tokio::time::sleep(Duration::from_secs(1)).await; + jvm.assert_alive(); + + adapter.event().await; + wait_suspended(&link, 2).await; + for &id in &link_ids { + assert_eq!(adapter.reference_type_error(id).await, 0, "the adapter's reference survived"); + } + // Releasing the adapter's one reference frees each id, so the link left none behind. + let mut w = Writer::new(); + w.i32(link_ids.len() as i32); + for &id in &link_ids { + w.id(id, sizes.object); + w.i32(1); + } + adapter.ok(1, 14, w.into_vec()).await; + for &id in &link_ids { + assert_eq!(adapter.reference_type_error(id).await, INVALID_OBJECT); + } + adapter.ok(1, 9, Vec::new()).await; + tokio::time::sleep(Duration::from_millis(500)).await; + jvm.assert_alive(); +} + +fn object_type(value: &JvmValue) -> &str { + match value { + JvmValue::Object { type_name, .. } => type_name, + other => panic!("not a plain object: {other:?}"), + } +} + +#[tokio::test] +#[ignore = "needs a JDK: OPENIDEA_TEST_JAVA=/bin/java"] +async fn link_inspector_reads_instances_fields_and_referrers() { + let Session { mut jvm, link, mut adapter } = stopped().await; + let inspector = LinkInspector::new(link.clone()); + let target = VmTarget { pid: None, attach_host: None, attach_port: None, generation: 1 }; + + let started = Instant::now(); + let items = inspector.instances(&target, "Main$Item", 1000).await.expect("instances of Main$Item"); + eprintln!("timing: instances(Main$Item, 1000) = {} objects in {:?}", items.len(), started.elapsed()); + assert_eq!(items.len(), 5); + assert!(items.iter().all(|item| object_type(item) == "Main$Item"), "{items:?}"); + + // `Instances` does not promise allocation order, so find the item by its field. + let mut first = None; + for item in &items { + let id = item.object_id().unwrap(); + let fields = inspector.fields(&target, id).await.expect("fields of an item"); + let names: Vec<_> = fields.iter().map(|f| f.name.as_str()).collect(); + assert_eq!(names, ["n", "name"]); + let JvmValue::Primitive { type_name, text: n } = &fields[0].value else { + panic!("n is {:?}", fields[0].value); + }; + assert_eq!(type_name, "int"); + let JvmValue::Str { text, length, .. } = &fields[1].value else { + panic!("name is {:?}", fields[1].value); + }; + let expected = format!("item-{n}"); + assert_eq!(text.as_deref(), Some(expected.as_str())); + assert_eq!(*length as usize, expected.len()); + if n == "0" { + first = Some(id); + } + } + let first = first.expect("the item with n == 0"); + + let started = Instant::now(); + let referrers = inspector.referrers(&target, first, 50).await.expect("referrers of the first item"); + eprintln!("timing: referrers(first item) = {} in {:?}", referrers.len(), started.elapsed()); + assert_eq!(referrers.len(), 1, "{referrers:?}"); + let JvmValue::Array { type_name, .. } = &referrers[0].value else { + panic!("referrer is {:?}", referrers[0].value); + }; + assert_eq!(type_name, "java.lang.Object[]"); + assert_eq!(referrers[0].via, ["[0]"]); + + let started = Instant::now(); + let counts = inspector.class_counts(&target).await.expect("class counts"); + eprintln!("timing: class_counts = {} classes in {:?}", counts.len(), started.elapsed()); + let count = |name: &str| counts.iter().find(|c| c.name == name).map(|c| c.instances); + assert_eq!(count("Main$Item"), Some(5)); + assert_eq!(count("Main$Row"), Some(1000)); + assert!(counts.iter().all(|c| c.instances > 0)); + + // Previews cost a few commands each; record what 1000 of them take. + let started = Instant::now(); + let rows = inspector.instances(&target, "Main$Row", 1000).await.expect("instances of Main$Row"); + eprintln!("timing: instances(Main$Row, 1000) = {} objects in {:?}", rows.len(), started.elapsed()); + assert_eq!(rows.len(), 1000); + let started = Instant::now(); + let strings = inspector.instances(&target, "java.lang.String", 1000).await.expect("instances of String"); + eprintln!("timing: instances(java.lang.String, 1000) = {} strings in {:?}", strings.len(), started.elapsed()); + assert!(strings.iter().all(|s| matches!(s, JvmValue::Str { .. }))); + assert!(strings.len() > 900, "only {} strings", strings.len()); + + // The link counted every id above; releasing them must leave the JVM running. + adapter.ok(1, 9, Vec::new()).await; + tokio::time::sleep(Duration::from_secs(1)).await; + jvm.assert_alive(); +} diff --git a/crates/ide-jvm-attach/Cargo.toml b/crates/ide-jvm-attach/Cargo.toml new file mode 100644 index 000000000..1308a0935 --- /dev/null +++ b/crates/ide-jvm-attach/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ide-jvm-attach" +description = "HotSpot attach-socket client and class-histogram parser for the debugger's JVM Memory view" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +thiserror.workspace = true + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/ide-jvm-attach/src/discover.rs b/crates/ide-jvm-attach/src/discover.rs new file mode 100644 index 000000000..abe3dae42 --- /dev/null +++ b/crates/ide-jvm-attach/src/discover.rs @@ -0,0 +1,184 @@ +use std::time::Duration; +#[cfg(unix)] +use std::{path::Path, sync::mpsc, thread, time::Instant}; + +#[cfg(unix)] +use crate::properties::jdwp_port; +#[cfg(unix)] +use crate::socket::{agent_properties_at, has_listener_at, temp_dir}; + +/// Pid of the local JVM whose JDWP agent listens on `port`, found through the `.java_pid` +/// sockets of JVMs run by the current user. Returns within `budget` even if a JVM never answers; +/// `per_socket` bounds each individual query. Only JVMs whose pid `may_query` accepts are asked; +/// it runs on the calling thread before each query. +pub fn find_jvm_by_jdwp_port( + port: u16, + per_socket: Duration, + budget: Duration, + may_query: impl Fn(u32) -> bool, +) -> Option { + #[cfg(unix)] + { + find_in(&temp_dir(), port, per_socket, budget, may_query) + } + #[cfg(not(unix))] + { + let _ = (port, per_socket, budget, may_query); + None + } +} + +#[cfg(unix)] +fn pid_of_socket_name(name: &str) -> Option { + let digits = name.strip_prefix(".java_pid")?; + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + digits.parse().ok() +} + +#[cfg(unix)] +pub(crate) fn find_in( + dir: &Path, + port: u16, + per_socket: Duration, + budget: Duration, + may_query: impl Fn(u32) -> bool, +) -> Option { + let deadline = Instant::now() + budget; + let (found, results) = mpsc::channel(); + for entry in std::fs::read_dir(dir).ok()?.flatten() { + let Some(pid) = entry.file_name().to_str().and_then(pid_of_socket_name) else { continue }; + let path = entry.path(); + // Same trust checks as a real request: never talk to a socket another user could own. + if !has_listener_at(&path) || !may_query(pid) { + continue; + } + let found = found.clone(); + // Detached on purpose: a thread stuck on a silent JVM ends by its own `per_socket` + // deadline and must not hold up the caller. + let _ = thread::Builder::new().name(format!("jvm-attach-probe-{pid}")).spawn(move || { + if let Ok(props) = agent_properties_at(&path, per_socket) + && jdwp_port(&props) == Some(port) + { + let _ = found.send(pid); + } + }); + } + drop(found); + // Timeout and Disconnected (every probe finished without a match) both mean "not found". + results.recv_timeout(deadline.saturating_duration_since(Instant::now())).ok() +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::os::unix::{fs::PermissionsExt, net::UnixListener}; + use std::{fs, sync::mpsc::Sender}; + + /// Fake JVM: after the request's five NULs, answers with `port` as its JDWP port. + /// With `None` it accepts and then stays silent until the returned sender fires or drops. + fn fake_jvm(dir: &Path, name: &str, port: Option) -> Sender<()> { + let path = dir.join(name); + let listener = UnixListener::bind(&path).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + let (release, hold) = mpsc::channel::<()>(); + thread::spawn(move || { + let Ok((mut conn, _)) = listener.accept() else { return }; + let mut nuls = 0; + let mut byte = [0u8; 1]; + while nuls < 5 { + if conn.read_exact(&mut byte).is_err() { + return; + } + nuls += usize::from(byte[0] == 0); + } + match port { + Some(port) => { + let body = format!("0\n#Thu\nsun.jdwp.listenerAddress=dt_socket\\:{port}\nother=1\n"); + let _ = conn.write_all(body.as_bytes()); + } + None => { + let _ = hold.recv(); + } + } + }); + release + } + + const SECOND: Duration = Duration::from_secs(1); + const ALL: fn(u32) -> bool = |_| true; + + #[test] + fn scan_matches_port_not_first_listener() { + let dir = tempfile::tempdir().unwrap(); + let _a = fake_jvm(dir.path(), ".java_pid111", Some(5005)); + let _b = fake_jvm(dir.path(), ".java_pid222", Some(6006)); + assert_eq!(find_in(dir.path(), 6006, SECOND, 2 * SECOND, ALL), Some(222)); + } + + #[test] + fn scan_returns_none_when_no_port_matches() { + let dir = tempfile::tempdir().unwrap(); + let _a = fake_jvm(dir.path(), ".java_pid111", Some(5005)); + assert_eq!(find_in(dir.path(), 6006, SECOND, 2 * SECOND, ALL), None); + } + + #[test] + fn scan_skips_silent_listener_within_budget() { + let dir = tempfile::tempdir().unwrap(); + let _silent = fake_jvm(dir.path(), ".java_pid111", None); + let _b = fake_jvm(dir.path(), ".java_pid222", Some(6006)); + let started = Instant::now(); + assert_eq!(find_in(dir.path(), 6006, SECOND, 2 * SECOND, ALL), Some(222)); + assert!(started.elapsed() < Duration::from_millis(2500), "{:?}", started.elapsed()); + } + + #[test] + fn scan_gives_up_at_the_budget_even_if_a_query_could_run_longer() { + let dir = tempfile::tempdir().unwrap(); + let _silent = fake_jvm(dir.path(), ".java_pid111", None); + let started = Instant::now(); + assert_eq!(find_in(dir.path(), 6006, Duration::from_secs(30), Duration::from_millis(500), ALL), None); + assert!(started.elapsed() < Duration::from_millis(2500), "{:?}", started.elapsed()); + } + + #[test] + fn scan_ignores_non_matching_names() { + let dir = tempfile::tempdir().unwrap(); + let _a = fake_jvm(dir.path(), ".java_pidabc", Some(6006)); + let _b = fake_jvm(dir.path(), ".java_pid", Some(6006)); + let _c = fake_jvm(dir.path(), "java_pid12", Some(6006)); + let _d = fake_jvm(dir.path(), ".java_pid12x", Some(6006)); + assert_eq!(find_in(dir.path(), 6006, SECOND, 2 * SECOND, ALL), None); + } + + #[test] + fn scan_applies_the_socket_trust_checks() { + let dir = tempfile::tempdir().unwrap(); + let _a = fake_jvm(dir.path(), ".java_pid111", Some(6006)); + fs::set_permissions(dir.path().join(".java_pid111"), fs::Permissions::from_mode(0o666)).unwrap(); + assert_eq!(find_in(dir.path(), 6006, SECOND, 2 * SECOND, ALL), None); + } + + #[test] + fn scan_of_a_missing_directory_finds_nothing() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(find_in(&dir.path().join("gone"), 6006, SECOND, SECOND, ALL), None); + } + + #[test] + fn scan_never_asks_a_jvm_the_caller_rejects() { + let dir = tempfile::tempdir().unwrap(); + let _a = fake_jvm(dir.path(), ".java_pid111", Some(6006)); + let offered = std::sync::Mutex::new(Vec::new()); + let only_222 = |pid| { + offered.lock().unwrap().push(pid); + pid == 222 + }; + // Pid 111 listens on the port, so asking it would have found it. + assert_eq!(find_in(dir.path(), 6006, SECOND, 2 * SECOND, only_222), None); + assert_eq!(*offered.lock().unwrap(), [111]); + } +} diff --git a/crates/ide-jvm-attach/src/histogram.rs b/crates/ide-jvm-attach/src/histogram.rs new file mode 100644 index 000000000..5846754a6 --- /dev/null +++ b/crates/ide-jvm-attach/src/histogram.rs @@ -0,0 +1,154 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HistogramRow { + /// Java source form: `int[]`, `java.lang.String[][]`, `Heap$Live`. + pub name: String, + /// `java.base@17.0.21`; absent on JDK 8 and for classes without a named module. + pub module: Option, + pub instances: u64, + pub bytes: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ClassHistogram { + pub rows: Vec, + /// Lines that started like a row (`:`) but could not be read. + pub skipped_lines: u32, +} + +pub fn parse_class_histogram(text: &str) -> ClassHistogram { + let mut histogram = ClassHistogram::default(); + for line in text.lines() { + let Some(num) = line.split_whitespace().next() else { + continue; + }; + let is_row = num.strip_suffix(':').is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())); + if !is_row { + continue; + } + match parse_row(line) { + Some(row) => { + // G1 pads heap regions with filler objects; they are not user data. + if !row.name.starts_with("jdk.internal.vm.Filler") { + histogram.rows.push(row); + } + } + None => histogram.skipped_lines += 1, + } + } + histogram +} + +fn parse_row(line: &str) -> Option { + let (_, rest) = line.split_once(':')?; + let rest = rest.trim_start(); + let (instances, rest) = rest.split_once(char::is_whitespace)?; + let (bytes, rest) = rest.trim_start().split_once(char::is_whitespace)?; + let rest = rest.trim(); + if rest.is_empty() { + return None; + } + let (jvm_name, module) = match rest.rsplit_once(" (") { + Some((name, module)) if module.ends_with(')') => { + (name.trim_end(), Some(module[..module.len() - 1].to_string())) + } + _ => (rest, None), + }; + Some(HistogramRow { + name: display_class_name(jvm_name), + module, + instances: instances.parse().ok()?, + bytes: bytes.parse().ok()?, + }) +} + +/// Converts a JVM class name (`[Ljava.lang.String;`, `[[I`) to its Java source form. +pub fn display_class_name(jvm_name: &str) -> String { + let dims = jvm_name.bytes().take_while(|&b| b == b'[').count(); + if dims == 0 { + return jvm_name.to_string(); + } + let element = &jvm_name[dims..]; + let base = match element { + "Z" => "boolean", + "B" => "byte", + "C" => "char", + "S" => "short", + "I" => "int", + "J" => "long", + "F" => "float", + "D" => "double", + _ => match element.strip_prefix('L').and_then(|e| e.strip_suffix(';')) { + Some(class) => class, + None => return jvm_name.to_string(), + }, + }; + format!("{base}{}", "[]".repeat(dims)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const JDK17: &str = " num #instances #bytes class name (module)\n-------------------------------------------------------\n 1: 1085 3955024 [I (java.base@17.0.21)\n 2: 1000000 16000000 Heap$Live\n 3: 1218 107296 [Ljava.lang.Object; (java.base@17.0.21)\n 4: 4 3565864 [Ljdk.internal.vm.FillerElement; (java.base@25.0.3)\n 5: 2 64 [[B (java.base@17.0.21)\n 6: 1 16 Heap$$Lambda/0x000000080000c218\nTotal 31639 5354360\n"; + const JDK8: &str = "\n num #instances #bytes class name\n----------------------------------------------\n 1: 425 3572968 [I\n 2: 100000 1600000 Heap$Live\nTotal 100425 5172968\n"; + + #[test] + fn parses_jdk17_rows_with_modules() { + let h = parse_class_histogram(JDK17); + assert_eq!( + h.rows[0], + HistogramRow { + name: "int[]".into(), + module: Some("java.base@17.0.21".into()), + instances: 1085, + bytes: 3955024 + } + ); + assert_eq!( + h.rows[1], + HistogramRow { name: "Heap$Live".into(), module: None, instances: 1_000_000, bytes: 16_000_000 } + ); + assert_eq!(h.rows[2].name, "java.lang.Object[]"); + assert_eq!(h.rows.iter().find(|r| r.name == "byte[][]").unwrap().instances, 2); + assert_eq!(h.rows.last().unwrap().name, "Heap$$Lambda/0x000000080000c218"); + } + + #[test] + fn drops_g1_filler_rows() { + let h = parse_class_histogram(JDK17); + assert!(h.rows.iter().all(|r| !r.name.contains("jdk.internal.vm.Filler"))); + assert_eq!(h.rows.len(), 5); + assert_eq!(h.skipped_lines, 0); + } + + #[test] + fn parses_jdk8_without_module_column() { + let h = parse_class_histogram(JDK8); + assert_eq!(h.rows.len(), 2); + assert_eq!( + h.rows[1], + HistogramRow { name: "Heap$Live".into(), module: None, instances: 100000, bytes: 1600000 } + ); + assert_eq!(h.skipped_lines, 0); + } + + #[test] + fn counts_garbled_lines_instead_of_failing() { + let h = parse_class_histogram(" 1: 12 abc Foo\n 2: 3 48 Bar\n"); + assert_eq!(h.rows.len(), 1); + assert_eq!(h.skipped_lines, 1); + } + + #[test] + fn display_names_for_arrays() { + for (jvm, shown) in [ + ("[Z", "boolean[]"), + ("[J", "long[]"), + ("[[D", "double[][]"), + ("[Ljava.lang.String;", "java.lang.String[]"), + ("java.util.HashMap$Node", "java.util.HashMap$Node"), + ] { + assert_eq!(display_class_name(jvm), shown); + } + } +} diff --git a/crates/ide-jvm-attach/src/jvm.rs b/crates/ide-jvm-attach/src/jvm.rs new file mode 100644 index 000000000..fb7f27cbb --- /dev/null +++ b/crates/ide-jvm-attach/src/jvm.rs @@ -0,0 +1,116 @@ +#[cfg(any(target_os = "linux", target_os = "macos"))] +use std::path::PathBuf; + +/// Placed before the user's JVM arguments, and only added when `wants_listener_flag` is true. +pub const START_LISTENER_FLAG: &str = "-XX:+StartAttachListener"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JavaVersion { + pub major: u32, + /// As the JDK prints it: `1.8.0_512`, `17.0.21`, `25`. + pub text: String, +} + +/// Reads `JAVA_VERSION="…"` from a JDK `release` file. +pub fn parse_release(contents: &str) -> Option { + let value = contents.lines().find_map(|line| line.trim().strip_prefix("JAVA_VERSION="))?; + version_from(value.trim().trim_matches('"')) +} + +/// Reads the `JDK …` line of `VM.version`; JDK 8 prints `JDK 8.0_512`. +pub fn parse_vm_version(output: &str) -> Option { + let value = output.lines().find_map(|line| line.trim().strip_prefix("JDK "))?; + version_from(value.trim()) +} + +fn version_from(text: &str) -> Option { + let mut parts = text.split(|c: char| !c.is_ascii_digit()).filter(|p| !p.is_empty()); + let first: u32 = parts.next()?.parse().ok()?; + // Pre-9 versions are `1..…`. + let major = if first == 1 && text.starts_with("1.") { parts.next()?.parse().ok()? } else { first }; + Some(JavaVersion { major, text: text.to_owned() }) +} + +/// JDK version of the process `pid`, from the `release` file next to its `java` executable. +pub fn java_version_of(pid: u32) -> Option { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + let exe = std::fs::canonicalize(exe_path(pid)?).ok()?; + // `/bin/java`, or `/jre/bin/java` on JDK 8. + let home = exe.parent()?.parent()?; + // Only climb out of `jre/`, so a JDK without its own `release` cannot pick up an + // unrelated file in a shared parent such as `/usr/lib/jvm`. + let outer = if home.file_name().is_some_and(|n| n == "jre") { home.parent() } else { None }; + [Some(home), outer].into_iter().flatten().find_map(|dir| { + let contents = std::fs::read_to_string(dir.join("release")).ok()?; + parse_release(&contents) + }) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = pid; + None + } +} + +#[cfg(target_os = "linux")] +fn exe_path(pid: u32) -> Option { + std::fs::read_link(format!("/proc/{pid}/exe")).ok() +} + +#[cfg(target_os = "macos")] +fn exe_path(pid: u32) -> Option { + use std::{ffi::c_void, os::unix::ffi::OsStringExt}; + + let mut buf = vec![0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize]; + // SAFETY: `buf` is writable for `buf.len()` bytes and proc_pidpath writes at most that many. + let n = unsafe { libc::proc_pidpath(pid as i32, buf.as_mut_ptr() as *mut c_void, buf.len() as u32) }; + if n <= 0 { + return None; + } + buf.truncate(n as usize); + Some(PathBuf::from(std::ffi::OsString::from_vec(buf))) +} + +/// JDK 8 and 11 were measured to hang while suspended (12–16 unmeasured). +pub fn histogram_safe_while_suspended(major: u32) -> bool { + major >= 17 +} + +/// False when the user's JVM arguments already decide the attach listener's fate. +pub fn wants_listener_flag(jvm_args: &str) -> bool { + !(jvm_args.contains("StartAttachListener") || jvm_args.contains("DisableAttachMechanism")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn release_versions() { + assert_eq!(parse_release("IMPLEMENTOR=\"Eclipse Adoptium\"\nJAVA_VERSION=\"17.0.21\"\n").unwrap().major, 17); + assert_eq!(parse_release("JAVA_VERSION=\"1.8.0_512\"").unwrap().major, 8); + assert_eq!(parse_release("JAVA_VERSION=\"25\"").unwrap(), JavaVersion { major: 25, text: "25".into() }); + assert!(parse_release("IMPLEMENTOR=x").is_none()); + } + + #[test] + fn vm_version_lines() { + assert_eq!(parse_vm_version("OpenJDK 64-Bit Server VM version 17.0.21+9\nJDK 17.0.21\n").unwrap().major, 17); + assert_eq!(parse_vm_version("OpenJDK 64-Bit Server VM version 25.512-b09\nJDK 8.0_512\n").unwrap().major, 8); + } + + #[test] + fn suspended_gate() { + assert!(!histogram_safe_while_suspended(11)); + assert!(!histogram_safe_while_suspended(16)); + assert!(histogram_safe_while_suspended(17)); + } + + #[test] + fn listener_flag_respects_user_choice() { + assert!(wants_listener_flag("-Xmx1g")); + assert!(!wants_listener_flag("-XX:-StartAttachListener")); + assert!(!wants_listener_flag("-Xmx1g -XX:+DisableAttachMechanism")); + } +} diff --git a/crates/ide-jvm-attach/src/lib.rs b/crates/ide-jvm-attach/src/lib.rs new file mode 100644 index 000000000..c07d04b60 --- /dev/null +++ b/crates/ide-jvm-attach/src/lib.rs @@ -0,0 +1,14 @@ +mod discover; +mod histogram; +mod jvm; +mod properties; +mod socket; + +pub use discover::find_jvm_by_jdwp_port; +pub use histogram::{ClassHistogram, HistogramRow, display_class_name, parse_class_histogram}; +pub use jvm::{ + JavaVersion, START_LISTENER_FLAG, histogram_safe_while_suspended, java_version_of, parse_release, parse_vm_version, + wants_listener_flag, +}; +pub use properties::{jdwp_port, parse_properties}; +pub use socket::{AttachError, MAX_OUTPUT, agent_properties, has_listener, jcmd, temp_dir}; diff --git a/crates/ide-jvm-attach/src/properties.rs b/crates/ide-jvm-attach/src/properties.rs new file mode 100644 index 000000000..914118602 --- /dev/null +++ b/crates/ide-jvm-attach/src/properties.rs @@ -0,0 +1,163 @@ +use std::collections::HashMap; +use std::str::Chars; + +fn is_blank(c: char) -> bool { + matches!(c, ' ' | '\t' | '\x0c') +} + +/// Parses the `java.util.Properties.store` text format. A malformed `\u` escape is read like +/// any other unknown escape, its characters kept without the backslash, rather than rejected: +/// the JVM, not the user, produced this text. +pub fn parse_properties(text: &str) -> HashMap { + let mut map = HashMap::new(); + let mut lines = text.lines(); + while let Some(first) = lines.next() { + let first = first.trim_start_matches(is_blank); + if first.is_empty() || first.starts_with('#') || first.starts_with('!') { + continue; + } + let mut logical = String::new(); + let mut current = first; + loop { + // An odd number of trailing backslashes continues the entry on the next line. + let slashes = current.bytes().rev().take_while(|b| *b == b'\\').count(); + if slashes % 2 == 0 { + logical.push_str(current); + break; + } + logical.push_str(¤t[..current.len() - 1]); + match lines.next() { + Some(next) => current = next.trim_start_matches(is_blank), + None => break, + } + } + let (key, value) = split_entry(&logical); + map.insert(unescape(key), unescape(value)); + } + map +} + +/// The value of `sun.jdwp.listenerAddress` is `:`; the port follows the last colon. +pub fn jdwp_port(props: &HashMap) -> Option { + let address = props.get("sun.jdwp.listenerAddress")?; + let (_, port) = address.rsplit_once(':')?; + port.trim().parse::().ok().filter(|port| *port != 0) +} + +fn split_entry(line: &str) -> (&str, &str) { + let mut escaped = false; + let mut key_end = line.len(); + for (i, c) in line.char_indices() { + if escaped { + escaped = false; + } else if c == '\\' { + escaped = true; + } else if c == '=' || c == ':' || is_blank(c) { + key_end = i; + break; + } + } + let mut rest = line[key_end..].trim_start_matches(is_blank); + if let Some(stripped) = rest.strip_prefix(['=', ':']) { + rest = stripped.trim_start_matches(is_blank); + } + (&line[..key_end], rest) +} + +fn unescape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c != '\\' { + out.push(c); + continue; + } + match chars.next() { + Some('t') => out.push('\t'), + Some('n') => out.push('\n'), + Some('r') => out.push('\r'), + Some('f') => out.push('\x0c'), + Some('u') => push_unicode_escape(&mut chars, &mut out), + Some(other) => out.push(other), + None => {} + } + } + out +} + +fn hex4(chars: &mut Chars) -> Option { + let mut value = 0u16; + for _ in 0..4 { + value = value * 16 + chars.next()?.to_digit(16)? as u16; + } + Some(value) +} + +/// `store` writes supplementary characters as two `\uXXXX` surrogate escapes. +fn push_unicode_escape(chars: &mut Chars, out: &mut String) { + // Parsed on a copy, so the digits of a malformed escape are still read as text. + let mut digits = chars.clone(); + let Some(unit) = hex4(&mut digits) else { + out.push('u'); + return; + }; + *chars = digits; + let mut units = vec![unit]; + if (0xD800..0xDC00).contains(&unit) { + let mut ahead = chars.clone(); + if ahead.next() == Some('\\') + && ahead.next() == Some('u') + && let Some(low) = hex4(&mut ahead) + && (0xDC00..0xE000).contains(&low) + { + units.push(low); + *chars = ahead; + } + } + out.extend(char::decode_utf16(units).map(|r| r.unwrap_or(char::REPLACEMENT_CHARACTER))); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_store_format_with_escapes() { + let props = parse_properties("#c\nsun.jdwp.listenerAddress=dt_socket\\:64390\nk\\=x=a\\\\b\nu=\\u0041"); + assert_eq!(props["sun.jdwp.listenerAddress"], "dt_socket:64390"); + assert_eq!(props["k=x"], "a\\b"); + assert_eq!(props["u"], "A"); + assert_eq!(props.len(), 3); + } + + #[test] + fn handles_whitespace_separators_continuations_and_surrogates() { + let props = parse_properties("! bang\n a : 1\nb 2\nc=x\\\n y\nd=\\ud83d\\ude00\ne=\\t\\n\\r\\f\nf=\n"); + assert_eq!(props["a"], "1"); + assert_eq!(props["b"], "2"); + assert_eq!(props["c"], "xy"); + assert_eq!(props["d"], "\u{1F600}"); + assert_eq!(props["e"], "\t\n\r\x0c"); + assert_eq!(props["f"], ""); + } + + #[test] + fn malformed_unicode_escape_keeps_its_characters() { + let props = parse_properties("a=\\u00zz\nb=\\u12\nc=x\\u"); + assert_eq!(props["a"], "u00zz"); + assert_eq!(props["b"], "u12"); + assert_eq!(props["c"], "xu"); + } + + #[test] + fn jdwp_port_forms() { + let with = |value: &str| HashMap::from([("sun.jdwp.listenerAddress".to_string(), value.to_string())]); + assert_eq!(jdwp_port(&with("dt_socket:64390")), Some(64390)); + assert_eq!(jdwp_port(&with("127.0.0.1:5005")), Some(5005)); + assert_eq!(jdwp_port(&HashMap::new()), None); + assert_eq!(jdwp_port(&with("dt_socket:abc")), None); + assert_eq!(jdwp_port(&with(":0")), None); + assert_eq!(jdwp_port(&with("dt_socket")), None); + assert_eq!(jdwp_port(&with("dt_socket:70000")), None); + } +} diff --git a/crates/ide-jvm-attach/src/socket.rs b/crates/ide-jvm-attach/src/socket.rs new file mode 100644 index 000000000..a43847430 --- /dev/null +++ b/crates/ide-jvm-attach/src/socket.rs @@ -0,0 +1,543 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; + +#[cfg(unix)] +use crate::properties::parse_properties; +#[cfg(unix)] +use std::{ + io::{self, Read, Write}, + os::{ + fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}, + unix::{ + ffi::OsStrExt, + fs::{FileTypeExt, MetadataExt}, + net::UnixStream, + }, + }, + path::Path, + time::Instant, +}; + +/// Upper bound on the reply read from the JVM, in bytes. +pub const MAX_OUTPUT: usize = 64 * 1024 * 1024; + +#[derive(Debug, thiserror::Error)] +pub enum AttachError { + #[error("attach is not supported on this platform")] + Unsupported, + #[error("the JVM has no attach listener")] + NoListener, + #[error("the attach socket is not private to the current user")] + ForeignListener, + #[error("the attach listener refused the connection")] + Refused, + #[error("the JVM did not answer in time")] + Timeout, + #[error("the JVM reply exceeds the size limit")] + TooLarge, + #[error("the JVM rejected the command ({code}): {message}")] + Command { code: i32, message: String }, + #[error("unexpected reply from the JVM: {0}")] + Protocol(String), + #[error(transparent)] + Io(#[from] std::io::Error), +} + +/// Directory holding the JVM's `.java_pid` sockets. On macOS this is the per-user +/// directory from `confstr`, which differs from `$TMPDIR` in some launch contexts. +pub fn temp_dir() -> PathBuf { + #[cfg(target_os = "macos")] + { + let mut buf = vec![0u8; libc::PATH_MAX as usize]; + // SAFETY: `buf` is writable for `buf.len()` bytes; confstr NUL-terminates within it. + let n = unsafe { libc::confstr(libc::_CS_DARWIN_USER_TEMP_DIR, buf.as_mut_ptr().cast(), buf.len()) }; + if n > 0 && n <= buf.len() { + let dir = String::from_utf8_lossy(&buf[..n - 1]); + let dir = dir.trim_end_matches('/'); + if !dir.is_empty() { + return PathBuf::from(dir); + } + } + } + #[cfg(unix)] + { + PathBuf::from("/tmp") + } + #[cfg(not(unix))] + { + std::env::temp_dir() + } +} + +/// True when `.java_pid` exists and passes the same ownership check `jcmd` applies. +pub fn has_listener(pid: u32) -> bool { + #[cfg(unix)] + { + has_listener_at(&socket_path(pid)) + } + #[cfg(not(unix))] + { + let _ = pid; + false + } +} + +/// Runs one diagnostic command. `command_line` is sent as a single argument, so +/// `"GC.class_histogram -all"` is one request, not a command plus an option. +pub fn jcmd(pid: u32, command_line: &str, timeout: Duration) -> Result { + #[cfg(unix)] + { + jcmd_at(&socket_path(pid), command_line, timeout) + } + #[cfg(not(unix))] + { + let _ = (pid, command_line, timeout); + Err(AttachError::Unsupported) + } +} + +#[cfg(unix)] +fn socket_path(pid: u32) -> PathBuf { + temp_dir().join(format!(".java_pid{pid}")) +} + +/// Same trust rule as the JDK's `VirtualMachineImpl`: only talk to a socket we own and +/// nobody else can reach, otherwise another user could answer in the JVM's place. +#[cfg(unix)] +fn check_socket(path: &Path) -> Result<(), AttachError> { + // No symlink following: in a sticky, world-writable /tmp anyone can plant a symlink to a + // socket we own, pass this check, then re-point it before connect. + let meta = match std::fs::symlink_metadata(path) { + Ok(meta) => meta, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(AttachError::NoListener), + Err(e) => return Err(e.into()), + }; + // SAFETY: geteuid has no preconditions and cannot fail. + let owned_by_us = meta.uid() == unsafe { libc::geteuid() }; + if meta.file_type().is_socket() && owned_by_us && meta.mode() & 0o077 == 0 { + Ok(()) + } else { + Err(AttachError::ForeignListener) + } +} + +/// JVM agent properties (`sun.jdwp.listenerAddress`, ...) via the `agentProperties` operation. +pub fn agent_properties(pid: u32, timeout: Duration) -> Result, AttachError> { + #[cfg(unix)] + { + agent_properties_at(&socket_path(pid), timeout) + } + #[cfg(not(unix))] + { + let _ = (pid, timeout); + Err(AttachError::Unsupported) + } +} + +#[cfg(unix)] +pub(crate) fn has_listener_at(path: &Path) -> bool { + check_socket(path).is_ok() +} + +#[cfg(unix)] +pub(crate) fn jcmd_at(path: &Path, command_line: &str, timeout: Duration) -> Result { + request_at(path, "jcmd", [command_line, "", ""], timeout) +} + +#[cfg(unix)] +pub(crate) fn agent_properties_at(path: &Path, timeout: Duration) -> Result, AttachError> { + request_at(path, "agentProperties", ["", "", ""], timeout).map(|body| parse_properties(&body)) +} + +/// One attach-protocol v1 request: `1\0\0\0\0\0`, always three argument slots. +#[cfg(unix)] +fn request_at(path: &Path, operation: &str, args: [&str; 3], timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + check_socket(path)?; + let mut stream = connect_before(path, deadline)?; + // Defence in depth: the path check can race with a replacement of the socket file. + if !peer_is_us(&stream)? { + return Err(AttachError::ForeignListener); + } + // A suspended VM may never drain the socket; bound the write by the same budget. + let remaining = deadline.saturating_duration_since(Instant::now()); + stream.set_write_timeout(Some(remaining.max(Duration::from_millis(1))))?; + let request = format!("1\0{operation}\0{}\0{}\0{}\0", args[0], args[1], args[2]); + stream.write_all(request.as_bytes()).map_err(map_timeout)?; + read_reply(&mut stream, deadline, MAX_OUTPUT) +} + +#[cfg(unix)] +fn map_connect_error(e: io::Error) -> AttachError { + match e.kind() { + io::ErrorKind::ConnectionRefused => AttachError::Refused, + io::ErrorKind::NotFound => AttachError::NoListener, + _ => AttachError::Io(e), + } +} + +#[cfg(unix)] +fn set_nonblocking(fd: RawFd, on: bool) -> io::Result<()> { + // SAFETY: fcntl on a descriptor we own. + unsafe { + let flags = libc::fcntl(fd, libc::F_GETFL); + if flags < 0 { + return Err(io::Error::last_os_error()); + } + let flags = if on { flags | libc::O_NONBLOCK } else { flags & !libc::O_NONBLOCK }; + if libc::fcntl(fd, libc::F_SETFL, flags) < 0 { + return Err(io::Error::last_os_error()); + } + } + Ok(()) +} + +#[cfg(unix)] +/// Waits until `fd` is writable or the deadline passes. +fn wait_writable(fd: RawFd, deadline: Instant) -> Result<(), AttachError> { + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(AttachError::Timeout); + } + let millis = remaining.as_millis().saturating_add(1).min(i32::MAX as u128) as i32; + let mut pfd = libc::pollfd { fd, events: libc::POLLOUT, revents: 0 }; + // SAFETY: one valid pollfd. + let rc = unsafe { libc::poll(&mut pfd, 1, millis) }; + if rc > 0 { + return Ok(()); + } + if rc < 0 { + let err = io::Error::last_os_error(); + if err.kind() != io::ErrorKind::Interrupted { + return Err(err.into()); + } + } + } +} + +#[cfg(unix)] +/// `UnixStream::connect` has no timeout, and a listener with a full accept backlog (a suspended +/// VM that never accepted earlier timed-out requests) would block it forever. +fn connect_before(path: &Path, deadline: Instant) -> Result { + // SAFETY: plain socket(2); the result is wrapped in an OwnedFd immediately. + let raw = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0) }; + if raw < 0 { + return Err(io::Error::last_os_error().into()); + } + // SAFETY: `raw` is a fresh descriptor that nothing else owns. + let fd = unsafe { OwnedFd::from_raw_fd(raw) }; + // SAFETY: fcntl on a descriptor we own. + if unsafe { libc::fcntl(raw, libc::F_SETFD, libc::FD_CLOEXEC) } < 0 { + return Err(io::Error::last_os_error().into()); + } + set_nonblocking(raw, true)?; + + // SAFETY: sockaddr_un is plain data; all-zero is a valid value. + let mut addr: libc::sockaddr_un = unsafe { std::mem::zeroed() }; + addr.sun_family = libc::AF_UNIX as libc::sa_family_t; + let bytes = path.as_os_str().as_bytes(); + if bytes.len() >= addr.sun_path.len() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "attach socket path is too long").into()); + } + for (slot, byte) in addr.sun_path.iter_mut().zip(bytes) { + *slot = *byte as libc::c_char; + } + let len = std::mem::size_of::() as libc::socklen_t; + #[cfg(target_os = "macos")] + { + addr.sun_len = len as u8; + } + + loop { + // SAFETY: `addr` is a fully initialised sockaddr_un of length `len`. + let rc = unsafe { libc::connect(raw, (&raw const addr).cast::(), len) }; + if rc == 0 { + break; + } + let err = io::Error::last_os_error(); + match err.raw_os_error() { + Some(libc::EINTR) => {} + Some(libc::EINPROGRESS) => { + wait_writable(raw, deadline)?; + let mut so_error: libc::c_int = 0; + let mut size = std::mem::size_of::() as libc::socklen_t; + // SAFETY: `so_error` and `size` match what SO_ERROR writes. + let rc = unsafe { + libc::getsockopt(raw, libc::SOL_SOCKET, libc::SO_ERROR, (&raw mut so_error).cast(), &mut size) + }; + if rc < 0 { + return Err(io::Error::last_os_error().into()); + } + if so_error != 0 { + return Err(map_connect_error(io::Error::from_raw_os_error(so_error))); + } + break; + } + // Linux reports a full accept backlog as EAGAIN on non-blocking sockets. + Some(libc::EAGAIN) => { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(AttachError::Timeout); + } + std::thread::sleep(remaining.min(Duration::from_millis(10))); + } + _ => return Err(map_connect_error(err)), + } + } + set_nonblocking(raw, false)?; + Ok(UnixStream::from(fd)) +} + +#[cfg(unix)] +/// True when the process on the other end of `stream` runs as our effective user. +fn peer_is_us(stream: &UnixStream) -> io::Result { + let fd = stream.as_raw_fd(); + #[cfg(any(target_os = "linux", target_os = "android"))] + let peer_uid = { + let mut cred = libc::ucred { pid: 0, uid: 0, gid: 0 }; + let mut size = std::mem::size_of::() as libc::socklen_t; + // SAFETY: `cred` and `size` match what SO_PEERCRED writes. + let rc = + unsafe { libc::getsockopt(fd, libc::SOL_SOCKET, libc::SO_PEERCRED, (&raw mut cred).cast(), &mut size) }; + if rc < 0 { + return Err(io::Error::last_os_error()); + } + cred.uid + }; + #[cfg(not(any(target_os = "linux", target_os = "android")))] + let peer_uid = { + let (mut uid, mut gid): (libc::uid_t, libc::gid_t) = (0, 0); + // SAFETY: both out-pointers are valid for the call. + if unsafe { libc::getpeereid(fd, &mut uid, &mut gid) } < 0 { + return Err(io::Error::last_os_error()); + } + uid + }; + // SAFETY: geteuid has no preconditions and cannot fail. + Ok(peer_uid == unsafe { libc::geteuid() }) +} + +#[cfg(unix)] +fn map_timeout(e: io::Error) -> AttachError { + match e.kind() { + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut => AttachError::Timeout, + _ => AttachError::Io(e), + } +} + +/// Reads until the JVM closes the connection. The first line is the result code, the rest is the body. +#[cfg(unix)] +fn read_reply(stream: &mut UnixStream, deadline: Instant, max: usize) -> Result { + let mut reply = Vec::new(); + let mut chunk = [0u8; 64 * 1024]; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(AttachError::Timeout); + } + // macOS rejects SO_RCVTIMEO with EINVAL once the peer has closed; the read then + // returns buffered data or EOF immediately, so the missing timeout is harmless. + match stream.set_read_timeout(Some(remaining)) { + Err(e) if e.kind() != io::ErrorKind::InvalidInput => return Err(e.into()), + _ => {} + } + match stream.read(&mut chunk) { + Ok(0) => break, + Ok(n) => { + if reply.len() + n > max { + return Err(AttachError::TooLarge); + } + reply.extend_from_slice(&chunk[..n]); + } + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(e) => return Err(map_timeout(e)), + } + } + + let newline = reply + .iter() + .position(|b| *b == b'\n') + .ok_or_else(|| AttachError::Protocol("reply has no result code line".into()))?; + let code_text = String::from_utf8_lossy(&reply[..newline]); + let code: i32 = + code_text.trim().parse().map_err(|_| AttachError::Protocol(format!("invalid result code {code_text:?}")))?; + let body = String::from_utf8_lossy(&reply[newline + 1..]).into_owned(); + if code == 0 { Ok(body) } else { Err(AttachError::Command { code, message: body.trim().to_string() }) } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::os::unix::{fs::PermissionsExt, net::UnixListener}; + use std::{fs, sync::mpsc, thread}; + + fn bind_private(dir: &Path) -> (PathBuf, UnixListener) { + let path = dir.join(".java_pid1"); + let listener = UnixListener::bind(&path).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + (path, listener) + } + + /// Accepts one connection, returns the request bytes (5 NUL-terminated strings), sends `reply`. + fn serve_once(listener: UnixListener, reply: &'static [u8]) -> thread::JoinHandle> { + thread::spawn(move || { + let (mut conn, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut byte = [0u8; 1]; + while request.iter().filter(|b| **b == 0).count() < 5 { + conn.read_exact(&mut byte).unwrap(); + request.push(byte[0]); + } + conn.write_all(reply).unwrap(); + request + }) + } + + #[test] + fn sends_v1_jcmd_request_and_returns_body() { + let dir = tempfile::tempdir().unwrap(); + let (path, listener) = bind_private(dir.path()); + let server = serve_once(listener, b"0\nhello\n"); + let out = jcmd_at(&path, "GC.class_histogram -all", Duration::from_secs(2)).unwrap(); + assert_eq!(out, "hello\n"); + assert_eq!(server.join().unwrap(), b"1\0jcmd\0GC.class_histogram -all\0\0\0"); + } + + #[test] + fn sends_v1_agent_properties_request_and_parses_body() { + let dir = tempfile::tempdir().unwrap(); + let (path, listener) = bind_private(dir.path()); + let server = serve_once(listener, b"0\n#Mon\nsun.jdwp.listenerAddress=dt_socket\\:64390\n"); + let props = agent_properties_at(&path, Duration::from_secs(2)).unwrap(); + assert_eq!(props.get("sun.jdwp.listenerAddress").map(String::as_str), Some("dt_socket:64390")); + assert_eq!(server.join().unwrap(), b"1\0agentProperties\0\0\0\0"); + } + + #[test] + fn nonzero_code_is_command_error() { + let dir = tempfile::tempdir().unwrap(); + let (path, listener) = bind_private(dir.path()); + let _server = serve_once(listener, b"-1\njava.lang.IllegalArgumentException: Unknown diagnostic command"); + match jcmd_at(&path, "Nope", Duration::from_secs(2)) { + Err(AttachError::Command { code, message }) => { + assert_eq!(code, -1); + assert!(message.contains("Unknown diagnostic command"), "{message}"); + } + other => panic!("unexpected {other:?}"), + } + } + + #[test] + fn jcmd_times_out_when_listener_never_answers() { + let dir = tempfile::tempdir().unwrap(); + let (path, listener) = bind_private(dir.path()); + let (release, hold) = mpsc::channel::<()>(); + let server = thread::spawn(move || { + let (_conn, _) = listener.accept().unwrap(); + let _ = hold.recv(); + }); + let started = Instant::now(); + let result = jcmd_at(&path, "Thread.print", Duration::from_millis(200)); + assert!(matches!(result, Err(AttachError::Timeout)), "{result:?}"); + assert!(started.elapsed() < Duration::from_secs(1)); + release.send(()).unwrap(); + server.join().unwrap(); + } + + #[test] + fn stale_socket_maps_to_refused() { + let dir = tempfile::tempdir().unwrap(); + let (path, listener) = bind_private(dir.path()); + drop(listener); + assert!(path.exists()); + let result = jcmd_at(&path, "Thread.print", Duration::from_secs(1)); + assert!(matches!(result, Err(AttachError::Refused)), "{result:?}"); + } + + #[test] + fn rejects_socket_with_group_or_other_permissions() { + let dir = tempfile::tempdir().unwrap(); + let (path, _listener) = bind_private(dir.path()); + fs::set_permissions(&path, fs::Permissions::from_mode(0o666)).unwrap(); + assert!(!has_listener_at(&path)); + let result = jcmd_at(&path, "Thread.print", Duration::from_secs(1)); + assert!(matches!(result, Err(AttachError::ForeignListener)), "{result:?}"); + } + + #[test] + fn output_over_limit_is_too_large() { + let (mut reader, mut writer) = UnixStream::pair().unwrap(); + writer.write_all(b"0\n").unwrap(); + writer.write_all(&[b'x'; 100]).unwrap(); + drop(writer); + let deadline = Instant::now() + Duration::from_secs(1); + let result = read_reply(&mut reader, deadline, 10); + assert!(matches!(result, Err(AttachError::TooLarge)), "{result:?}"); + } + + #[test] + fn symlink_to_our_own_private_socket_is_rejected() { + let dir = tempfile::tempdir().unwrap(); + let (target, _listener) = bind_private(dir.path()); + let link = dir.path().join(".java_pid2"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + assert!(has_listener_at(&target)); + assert!(!has_listener_at(&link)); + let result = jcmd_at(&link, "Thread.print", Duration::from_secs(1)); + assert!(matches!(result, Err(AttachError::ForeignListener)), "{result:?}"); + } + + #[test] + fn peer_uid_check_passes_for_our_own_listener() { + let dir = tempfile::tempdir().unwrap(); + let (path, listener) = bind_private(dir.path()); + let stream = connect_before(&path, Instant::now() + Duration::from_secs(1)).unwrap(); + let _accepted = listener.accept().unwrap(); + assert!(peer_is_us(&stream).unwrap()); + } + + #[test] + fn connect_before_maps_missing_socket_to_no_listener() { + let dir = tempfile::tempdir().unwrap(); + let deadline = Instant::now() + Duration::from_secs(1); + let missing = connect_before(&dir.path().join("nope"), deadline); + assert!(matches!(missing, Err(AttachError::NoListener)), "{missing:?}"); + } + + /// Linux blocks or returns EAGAIN on a full accept backlog, macOS refuses; either way the + /// connect must give up within its deadline instead of hanging. + #[test] + fn connect_before_gives_up_on_a_full_backlog() { + let dir = tempfile::tempdir().unwrap(); + let (path, _listener) = bind_private(dir.path()); + let mut held = Vec::new(); + let started = Instant::now(); + for _ in 0..4096 { + match connect_before(&path, Instant::now() + Duration::from_millis(200)) { + Ok(stream) => held.push(stream), + Err(e) => { + assert!(matches!(e, AttachError::Timeout | AttachError::Refused), "{e:?}"); + break; + } + } + } + assert!(started.elapsed() < Duration::from_secs(5)); + } + + #[test] + fn temp_dir_is_an_existing_dir_without_trailing_slash() { + let dir = temp_dir(); + assert!(dir.is_dir(), "{dir:?}"); + assert!(!dir.to_string_lossy().ends_with('/')); + } + + #[test] + fn missing_socket_is_no_listener() { + let dir = tempfile::tempdir().unwrap(); + let result = jcmd_at(&dir.path().join(".java_pid9"), "x", Duration::from_secs(1)); + assert!(matches!(result, Err(AttachError::NoListener)), "{result:?}"); + assert!(!has_listener_at(&dir.path().join(".java_pid9"))); + } +} diff --git a/crates/ide-jvm-attach/tests/real_jvm.rs b/crates/ide-jvm-attach/tests/real_jvm.rs new file mode 100644 index 000000000..49a63d2a9 --- /dev/null +++ b/crates/ide-jvm-attach/tests/real_jvm.rs @@ -0,0 +1,165 @@ +#![cfg(unix)] + +use std::io::{BufRead, BufReader, Read}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use ide_jvm_attach::{ + AttachError, agent_properties, find_jvm_by_jdwp_port, has_listener, java_version_of, jcmd, jdwp_port, + parse_class_histogram, parse_release, +}; + +const MAIN_JAVA: &str = r#" +import java.util.ArrayList; +import java.util.List; + +public class Main { + static class Item {} + + public static void main(String[] args) throws Exception { + List items = new ArrayList<>(); + for (int i = 0; i < 1000; i++) items.add(new Item()); + System.out.println("READY"); + System.out.flush(); + while (true) Thread.sleep(1000); + } +} +"#; + +/// Kills the JVM even when an assertion fails. +struct JvmGuard(Child); + +impl Drop for JvmGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +/// Major version `java -version` reports, via the same parser as the `release` file. +fn reported_major(java: &PathBuf) -> u32 { + let out = Command::new(java).arg("-version").output().expect("run java -version"); + let stderr = String::from_utf8_lossy(&out.stderr); + let quoted = stderr.split('"').nth(1).expect("quoted version in java -version output"); + parse_release(&format!("JAVA_VERSION=\"{quoted}\"")).expect("parse reported version").major +} + +#[test] +#[ignore = "needs a real JDK >= 11: set OPENIDEA_TEST_JAVA to its bin/java"] +fn attaches_to_a_real_jvm() { + let Some(java) = std::env::var_os("OPENIDEA_TEST_JAVA").map(PathBuf::from) else { + eprintln!("OPENIDEA_TEST_JAVA is not set; skipping"); + return; + }; + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("Main.java"); + std::fs::write(&source, MAIN_JAVA).unwrap(); + + let child = Command::new(&java) + .arg("-XX:+StartAttachListener") + .arg(&source) + .current_dir(dir.path()) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn java"); + let mut guard = JvmGuard(child); + let pid = guard.0.id(); + + let stdout = guard.0.stdout.take().unwrap(); + let ready = BufReader::new(stdout).lines().map_while(Result::ok).any(|line| line.trim() == "READY"); + assert!(ready, "the JVM exited before printing READY"); + + let deadline = Instant::now() + Duration::from_secs(5); + while !has_listener(pid) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(50)); + } + assert!(has_listener(pid), "no attach listener for pid {pid}"); + + let version = java_version_of(pid).expect("JDK version of the running JVM"); + assert_eq!(version.major, reported_major(&java)); + + let text = jcmd(pid, "GC.class_histogram -all", Duration::from_secs(10)).unwrap(); + let histogram = parse_class_histogram(&text); + let item = histogram.rows.iter().find(|row| row.name == "Main$Item").expect("Main$Item row"); + assert_eq!(item.instances, 1000); + + assert!(matches!(jcmd(pid, "No.such", Duration::from_secs(10)), Err(AttachError::Command { .. }))); +} + +/// Lines of the child's stdout and stderr, interleaved. +fn output_lines(child: &mut Child) -> mpsc::Receiver { + let (lines, received) = mpsc::channel::(); + let stdout = child.stdout.take().unwrap(); + let stderr = child.stderr.take().unwrap(); + for stream in [Box::new(stdout) as Box, Box::new(stderr)] { + let lines = lines.clone(); + std::thread::spawn(move || { + for line in BufReader::new(stream).lines().map_while(Result::ok) { + if lines.send(line).is_err() { + break; + } + } + }); + } + received +} + +/// First value `pick` returns within 30 s. +fn wait_for_line(lines: &mpsc::Receiver, mut pick: impl FnMut(&str) -> Option) -> Option { + let deadline = Instant::now() + Duration::from_secs(30); + while let Ok(line) = lines.recv_timeout(deadline.saturating_duration_since(Instant::now())) { + if let Some(value) = pick(&line) { + return Some(value); + } + } + None +} + +/// Port from JDWP's "Listening for transport dt_socket at address: N" line, which JDKs print on +/// stdout or stderr depending on the version. +fn jdwp_port_in(line: &str) -> Option { + let (_, port) = line.split_once("Listening for transport dt_socket at address:")?; + Some(port.trim().parse().expect("numeric JDWP port")) +} + +fn read_jdwp_port(child: &mut Child) -> u16 { + wait_for_line(&output_lines(child), jdwp_port_in).expect("the JVM never reported its JDWP port") +} + +#[test] +#[ignore = "needs a real JDK: set OPENIDEA_TEST_JAVA to its bin/java"] +fn finds_a_real_jvm_by_its_jdwp_port() { + let Some(java) = std::env::var_os("OPENIDEA_TEST_JAVA").map(PathBuf::from) else { + eprintln!("OPENIDEA_TEST_JAVA is not set; skipping"); + return; + }; + let child = Command::new(&java) + .arg("-XX:+StartAttachListener") + .arg("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:0") + .arg("-version") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn java"); + let mut guard = JvmGuard(child); + let pid = guard.0.id(); + let port = read_jdwp_port(&mut guard.0); + + // The listener thread may start a moment after the JDWP banner. + let deadline = Instant::now() + Duration::from_secs(10); + let mut found = None; + while found.is_none() && Instant::now() < deadline { + // Only the JVM this test started: other JVMs of the developer are left alone. + found = find_jvm_by_jdwp_port(port, Duration::from_secs(1), Duration::from_secs(2), |p| p == pid); + if found.is_none() { + std::thread::sleep(Duration::from_millis(100)); + } + } + assert_eq!(found, Some(pid), "port {port}"); + + let props = agent_properties(pid, Duration::from_secs(2)).unwrap(); + assert_eq!(jdwp_port(&props), Some(port)); + assert_eq!(find_jvm_by_jdwp_port(port ^ 1, Duration::from_secs(1), Duration::from_secs(2), |p| p == pid), None); +} diff --git a/crates/ide-jvm-inspect/Cargo.toml b/crates/ide-jvm-inspect/Cargo.toml new file mode 100644 index 000000000..7d7630dff --- /dev/null +++ b/crates/ide-jvm-inspect/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ide-jvm-inspect" +description = "Shared types and the JvmInspector trait for inspecting live JVM instances while a debug session is suspended" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +futures.workspace = true +serde.workspace = true +specta.workspace = true +thiserror.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/crates/ide-jvm-inspect/src/lib.rs b/crates/ide-jvm-inspect/src/lib.rs new file mode 100644 index 000000000..71a403160 --- /dev/null +++ b/crates/ide-jvm-inspect/src/lib.rs @@ -0,0 +1,156 @@ +//! Types and the [`JvmInspector`] trait for reading live JVM objects while a debug session is +//! suspended. Implementations live elsewhere (jdtls bundle for Java, JDWP link for Kotlin). + +mod names; + +pub use names::{is_boxed, name_to_signature, signature_to_name, string_length}; + +use futures::future::BoxFuture; +use serde::{Deserialize, Serialize}; +use specta::Type; + +/// JDWP object id; only valid within the stop (`VmTarget::generation`) it was read in. +pub type ObjectId = u64; + +pub const MAX_INSTANCES: u32 = 1000; +pub const DEFAULT_REFERRERS: u32 = 50; +pub const MAX_REFERRERS: u32 = 200; +/// Leading array elements expanded when listing an array's fields. +pub const ARRAY_PREVIEW: u32 = 100; +/// `String` text is read only up to this many characters. +pub const STRING_PREVIEW_CHARS: u32 = 1000; +/// Referrer arrays larger than this are not searched for the index that points at the object. +pub const REFERRER_ARRAY_SCAN: u32 = 10_000; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum JvmValue { + Null, + Primitive { + type_name: String, + text: String, + }, + /// `text` is `None` when `length` exceeds [`STRING_PREVIEW_CHARS`]. + #[serde(rename = "string")] + Str { + id: ObjectId, + text: Option, + length: u32, + }, + Boxed { + id: ObjectId, + type_name: String, + text: String, + }, + Object { + id: ObjectId, + type_name: String, + }, + Array { + id: ObjectId, + type_name: String, + length: u32, + }, +} + +impl JvmValue { + pub fn object_id(&self) -> Option { + match self { + Self::Null | Self::Primitive { .. } => None, + Self::Str { id, .. } | Self::Boxed { id, .. } | Self::Object { id, .. } | Self::Array { id, .. } => { + Some(*id) + } + } + } +} + +/// For arrays `name` is `"[i]"`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct JvmField { + pub name: String, + pub value: JvmValue, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct JvmReferrer { + pub value: JvmValue, + /// Field names, or `"[i]"` for array slots, through which `value` points at the object. + pub via: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ClassCount { + /// Display form, as in the class histogram (`int[]`, `com.x.Foo$Bar`). + pub name: String, + pub instances: u64, +} + +/// Identifies one JVM and one stop. Not serialized: the IPC layer builds its JSON by hand. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VmTarget { + pub pid: Option, + pub attach_host: Option, + pub attach_port: Option, + /// Session `stop_seq`; objects are valid only for the generation they were read in. + pub generation: u64, +} + +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum InspectError { + #[error("the debuggee is not suspended")] + NotSuspended, + #[error("the object belongs to an earlier stop")] + Stale, + #[error("the object was garbage collected")] + Collected, + #[error("the class is not loaded")] + ClassNotLoaded, + #[error("no matching JVM found")] + VmNotFound, + #[error("not supported by this debug adapter: {0}")] + Unsupported(String), + #[error("the request timed out")] + TimedOut, + #[error("the connection was closed")] + Closed, + #[error("{0}")] + Failed(String), +} + +pub type InspectFuture<'a, T> = BoxFuture<'a, Result>; + +pub trait JvmInspector: Send + Sync { + fn class_counts<'a>(&'a self, target: &'a VmTarget) -> InspectFuture<'a, Vec>; + fn instances<'a>( + &'a self, + target: &'a VmTarget, + class_name: &'a str, + limit: u32, + ) -> InspectFuture<'a, Vec>; + fn fields<'a>(&'a self, target: &'a VmTarget, object: ObjectId) -> InspectFuture<'a, Vec>; + fn referrers<'a>( + &'a self, + target: &'a VmTarget, + object: ObjectId, + limit: u32, + ) -> InspectFuture<'a, Vec>; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn value_json_shape() { + let v = JvmValue::Str { id: 7, text: None, length: 50_000_000 }; + assert_eq!( + serde_json::to_value(&v).unwrap(), + serde_json::json!({"kind": "string", "id": 7, "text": null, "length": 50000000}) + ); + assert_eq!(JvmValue::Null.object_id(), None); + assert_eq!(JvmValue::Array { id: 3, type_name: "int[]".into(), length: 2 }.object_id(), Some(3)); + } +} diff --git a/crates/ide-jvm-inspect/src/names.rs b/crates/ide-jvm-inspect/src/names.rs new file mode 100644 index 000000000..42ca278e8 --- /dev/null +++ b/crates/ide-jvm-inspect/src/names.rs @@ -0,0 +1,109 @@ +//! Conversions between JVM type signatures and the display names used by the class histogram. + +const PRIMITIVES: [(char, &str); 9] = [ + ('B', "byte"), + ('C', "char"), + ('D', "double"), + ('F', "float"), + ('I', "int"), + ('J', "long"), + ('S', "short"), + ('Z', "boolean"), + ('V', "void"), +]; + +const BOXED: [&str; 8] = [ + "java.lang.Integer", + "java.lang.Long", + "java.lang.Short", + "java.lang.Byte", + "java.lang.Character", + "java.lang.Boolean", + "java.lang.Float", + "java.lang.Double", +]; + +/// `None` for anything that is not a well-formed field descriptor. +pub fn signature_to_name(signature: &str) -> Option { + let base = signature.trim_start_matches('['); + let dims = signature.len() - base.len(); + let mut chars = base.chars(); + let first = chars.next()?; + let name = if first == 'L' { + let class = base.strip_prefix('L')?.strip_suffix(';')?; + if class.is_empty() || class.contains(';') { + return None; + } + class.replace('/', ".") + } else { + if chars.next().is_some() || (first == 'V' && dims > 0) { + return None; + } + PRIMITIVES.iter().find(|(c, _)| *c == first)?.1.to_string() + }; + Some(name + &"[]".repeat(dims)) +} + +/// Inverse of [`signature_to_name`]. +pub fn name_to_signature(name: &str) -> Option { + let mut base = name; + let mut dims = 0; + while let Some(rest) = base.strip_suffix("[]") { + base = rest; + dims += 1; + } + if base.is_empty() || base.contains(['[', ']', ';', '/']) { + return None; + } + let element = match PRIMITIVES.iter().find(|(_, n)| *n == base) { + Some((_, _)) if base == "void" && dims > 0 => return None, + Some((c, _)) => c.to_string(), + None => format!("L{};", base.replace('.', "/")), + }; + Some("[".repeat(dims) + &element) +} + +pub fn is_boxed(type_name: &str) -> bool { + BOXED.contains(&type_name) +} + +/// `coder` is `String.coder` (0 = Latin-1, 1 = UTF-16); JDK 8 strings have none and store one +/// `char` per element. +pub fn string_length(value_array_length: u32, coder: Option) -> u32 { + match coder { + Some(c) => value_array_length >> c.min(31), + None => value_array_length, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn signatures_to_display_names() { + for (sig, name) in [ + ("Lcom/x/Foo$Bar;", "com.x.Foo$Bar"), + ("[I", "int[]"), + ("[[Ljava/lang/String;", "java.lang.String[][]"), + ("Z", "boolean"), + ] { + assert_eq!(signature_to_name(sig).as_deref(), Some(name)); + assert_eq!(name_to_signature(name).as_deref(), Some(sig)); + } + assert_eq!(signature_to_name("Lbroken"), None); + } + + #[test] + fn string_length_follows_coder() { + assert_eq!(string_length(10, Some(1)), 5); + assert_eq!(string_length(10, Some(0)), 10); + assert_eq!(string_length(7, None), 7); + } + + #[test] + fn boxed_types() { + assert!(is_boxed("java.lang.Integer")); + assert!(!is_boxed("java.lang.String")); + } +} diff --git a/crates/ide-lsp/Cargo.toml b/crates/ide-lsp/Cargo.toml index 876c8130c..37610236d 100644 --- a/crates/ide-lsp/Cargo.toml +++ b/crates/ide-lsp/Cargo.toml @@ -12,6 +12,7 @@ dunce.workspace = true ide-text.workspace = true ide-language.workspace = true ide-jsonrpc.workspace = true +ide-jvm-inspect.workspace = true lsp-types.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index b00be106d..b01507548 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -21,6 +21,7 @@ const SERVER: &str = "jdtls"; pub(crate) mod configuration; mod formatter; mod hierarchy; +pub(crate) mod jvm_inspect; pub(crate) mod reserved_names; pub use formatter::configure_formatter; /// First start imports the Maven / Gradle model, which can take minutes. @@ -1078,7 +1079,15 @@ impl LspManager { { tracing::debug!("vscode.java.updateDebugSettings: {e}"); } - parse_port(&v).ok_or_else(|| IdeError::new(ErrorKind::Protocol, format!("unexpected debug server port: {v}"))) + let port = parse_port(&v) + .ok_or_else(|| IdeError::new(ErrorKind::Protocol, format!("unexpected debug server port: {v}")))?; + self.debug_servers.lock().insert(port, path.to_path_buf()); + Ok(port) + } + + /// The module path whose jdtls started the debug server on `port`. + pub fn java_debug_server_module(&self, port: u16) -> Option { + self.debug_servers.lock().get(&port).cloned() } } diff --git a/crates/ide-lsp/src/jdtls/jvm_inspect.rs b/crates/ide-lsp/src/jdtls/jvm_inspect.rs new file mode 100644 index 000000000..6a6b67586 --- /dev/null +++ b/crates/ide-lsp/src/jdtls/jvm_inspect.rs @@ -0,0 +1,136 @@ +//! [`JvmInspector`] over the `openidea.jvm.*` commands of the JVM inspector bundle that runs inside +//! jdtls next to java-debug (`java/jvm-inspect`). + +use super::missing_bundle; +use crate::LspManager; +use ide_core::IdeError; +use ide_jvm_inspect::{ + ClassCount, InspectError, InspectFuture, JvmField, JvmInspector, JvmReferrer, JvmValue, ObjectId, VmTarget, +}; +use serde::de::DeserializeOwned; +use serde_json::{Value, json}; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use tokio::time::{Instant, timeout_at}; + +const CLASSES_TIMEOUT: Duration = Duration::from_secs(90); +const REFERRERS_TIMEOUT: Duration = Duration::from_secs(90); +const INSTANCES_TIMEOUT: Duration = Duration::from_secs(30); +const FIELDS_TIMEOUT: Duration = Duration::from_secs(15); + +pub struct JdtJvmInspector { + lsp: Arc, + /// A path inside the module whose jdtls runs the debug session. + server: PathBuf, + /// The bundle serializes commands per VM and waits without bound on the JDT LS thread pool, + /// so a second command in flight can only queue behind the first one. + turn: Arc>, +} + +impl JdtJvmInspector { + pub fn new(lsp: Arc, server: PathBuf) -> Self { + Self { lsp, server, turn: Arc::new(tokio::sync::Mutex::new(())) } + } + + /// Runs `openidea.jvm.`; `limit` covers waiting for the turn as well as the command. + async fn call( + &self, + op: &str, + target: &VmTarget, + args: Vec, + limit: Duration, + ) -> Result { + let deadline = Instant::now() + limit; + let turn = timeout_at(deadline, self.turn.clone().lock_owned()).await.map_err(|_| InspectError::TimedOut)?; + let lsp = self.lsp.clone(); + let server = self.server.clone(); + let command = format!("openidea.jvm.{op}"); + let arguments: Vec = std::iter::once(target_json(target)).chain(args).collect(); + // Spawned so that a caller that gave up keeps the turn until jdtls answers or its own + // timeout fires: the bundle is still working on the abandoned command until then. + let task = tokio::spawn(async move { + let _turn = turn; + lsp.jdtls_command(&server, &command, arguments).await + }); + let reply = match timeout_at(deadline, task).await { + Err(_) => return Err(InspectError::TimedOut), + Ok(Err(joined)) => return Err(InspectError::Failed(joined.to_string())), + Ok(Ok(reply)) => reply.map_err(command_error)?, + }; + decode(reply) + } +} + +/// `VmTarget` is not `Serialize`: the field names here are the bundle's. +fn target_json(target: &VmTarget) -> Value { + json!({ + "pid": target.pid, + "host": target.attach_host, + "port": target.attach_port, + "generation": target.generation, + }) +} + +fn command_error(e: IdeError) -> InspectError { + if missing_bundle(&e) { + InspectError::Unsupported("the JVM inspector bundle is not loaded".to_owned()) + } else { + InspectError::Failed(e.message) + } +} + +/// `{"ok": payload}` or `{"error": code, "detail": text}`. +fn decode(mut reply: Value) -> Result { + if let Some(code) = reply.get("error").and_then(Value::as_str) { + let detail = reply.get("detail").and_then(Value::as_str); + return Err(match code { + "unsupported" => { + InspectError::Unsupported(detail.unwrap_or("the JVM inspector cannot read this JVM").to_owned()) + } + "vmNotFound" => InspectError::VmNotFound, + "stale" => InspectError::Stale, + "collected" => InspectError::Collected, + "classNotLoaded" => InspectError::ClassNotLoaded, + "notSuspended" => InspectError::NotSuspended, + "timedOut" => InspectError::TimedOut, + other => InspectError::Failed(match detail { + Some(detail) => format!("{other}: {detail}"), + None => other.to_owned(), + }), + }); + } + let payload = reply + .get_mut("ok") + .map(Value::take) + .ok_or_else(|| InspectError::Failed(format!("unexpected reply from the JVM inspector: {reply}")))?; + serde_json::from_value(payload).map_err(|e| InspectError::Failed(format!("malformed JVM inspector reply: {e}"))) +} + +impl JvmInspector for JdtJvmInspector { + fn class_counts<'a>(&'a self, target: &'a VmTarget) -> InspectFuture<'a, Vec> { + Box::pin(self.call("classes", target, Vec::new(), CLASSES_TIMEOUT)) + } + + fn instances<'a>( + &'a self, + target: &'a VmTarget, + class_name: &'a str, + limit: u32, + ) -> InspectFuture<'a, Vec> { + Box::pin(self.call("instances", target, vec![json!(class_name), json!(limit)], INSTANCES_TIMEOUT)) + } + + fn fields<'a>(&'a self, target: &'a VmTarget, object: ObjectId) -> InspectFuture<'a, Vec> { + Box::pin(self.call("fields", target, vec![json!(object)], FIELDS_TIMEOUT)) + } + + fn referrers<'a>( + &'a self, + target: &'a VmTarget, + object: ObjectId, + limit: u32, + ) -> InspectFuture<'a, Vec> { + Box::pin(self.call("referrers", target, vec![json!(object), json!(limit)], REFERRERS_TIMEOUT)) + } +} diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 54db6dd98..04724d3ad 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -36,6 +36,7 @@ mod virtual_doc; pub use injections::{InjectedRange, InjectionProvider, MAX_INJECTED_DOCUMENT_BYTES}; pub use inspect::{FileInspection, InspectHost, InspectOptions}; +pub use jdtls::jvm_inspect::JdtJvmInspector; pub use launch::{LaunchError, Launcher, ProcessLauncher}; pub use scope::{CandidateFiles, CandidateFn, CandidateQuery, MAX_CANDIDATES}; pub use uri::{is_virtual_uri, path_to_uri_string, uri_target, uri_to_path}; @@ -212,6 +213,8 @@ pub struct LspManager { shutting_down: AtomicBool, execution_allowed: AtomicBool, java_usages_warned: AtomicBool, + /// java-debug DAP port → the module path whose jdtls serves it. + debug_servers: Mutex>, configuration_cache: Arc>, /// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure. shown_messages: Mutex>, @@ -420,6 +423,7 @@ impl LspManager { shutting_down: AtomicBool::new(false), execution_allowed: AtomicBool::new(true), java_usages_warned: AtomicBool::new(false), + debug_servers: Mutex::new(HashMap::new()), configuration_cache: Arc::new(Mutex::new(Default::default())), shown_messages: Mutex::new(HashSet::new()), virtual_origins: Mutex::new(HashMap::new()), diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 40d499ef3..3347e8bcc 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -2710,6 +2710,160 @@ async fn alternative_java_server_uses_lsp_without_jdt_commands() { assert!(h.launcher.state("kmp-lsp").requested("workspace/executeCommand").is_empty()); } +async fn jvm_inspect_harness() -> (Harness, Arc, ide_lsp::JdtJvmInspector) { + let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]); + let (_, path) = h.open("Sample.java", "class Sample {}"); + h.wait_status("jdtls", ServerStatus::Ready).await; + let inspector = ide_lsp::JdtJvmInspector::new(h.mgr.clone(), path); + let state = h.launcher.state("jdtls"); + (h, state, inspector) +} + +fn attach_target(generation: u64) -> ide_jvm_inspect::VmTarget { + ide_jvm_inspect::VmTarget { pid: None, attach_host: Some("localhost".into()), attach_port: Some(5005), generation } +} + +#[tokio::test(flavor = "multi_thread")] +async fn jvm_inspect_instances_round_trip() { + use ide_jvm_inspect::{JvmInspector, JvmValue}; + let (_h, s, inspector) = jvm_inspect_harness().await; + s.respond("workspace/executeCommand", json!({"ok": [{"kind": "object", "id": 7, "typeName": "Foo"}]})); + let found = inspector.instances(&attach_target(3), "com.acme.Foo", 25).await.unwrap(); + assert_eq!(found, vec![JvmValue::Object { id: 7, type_name: "Foo".into() }]); + let asked = s.requested("workspace/executeCommand"); + assert_eq!(asked[0]["command"], "openidea.jvm.instances"); + assert_eq!( + asked[0]["arguments"], + json!([{"pid": null, "host": "localhost", "port": 5005, "generation": 3}, "com.acme.Foo", 25]) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn jvm_inspect_commands_carry_their_arguments_and_decode_their_replies() { + use ide_jvm_inspect::{ClassCount, JvmField, JvmInspector, JvmReferrer, JvmValue, VmTarget}; + let (_h, s, inspector) = jvm_inspect_harness().await; + let launch = VmTarget { pid: Some(4242), attach_host: None, attach_port: None, generation: 9 }; + let target = json!({"pid": 4242, "host": null, "port": null, "generation": 9}); + + s.respond("workspace/executeCommand", json!({"ok": [{"name": "int[]", "instances": 12}]})); + let counts = inspector.class_counts(&launch).await.unwrap(); + assert_eq!(counts, vec![ClassCount { name: "int[]".into(), instances: 12 }]); + + // gson leaves out null members: a long string has no `text`. + s.respond( + "workspace/executeCommand", + json!({"ok": [{"name": "[0]", "value": {"kind": "string", "id": 9, "length": 50000000}}]}), + ); + let fields = inspector.fields(&launch, 5).await.unwrap(); + assert_eq!( + fields, + vec![JvmField { name: "[0]".into(), value: JvmValue::Str { id: 9, text: None, length: 50_000_000 } }] + ); + + s.respond( + "workspace/executeCommand", + json!({"ok": [{"value": {"kind": "array", "id": 3, "typeName": "Object[]", "length": 2}, "via": ["[1]"]}]}), + ); + let referrers = inspector.referrers(&launch, 5, 50).await.unwrap(); + assert_eq!( + referrers, + vec![JvmReferrer { + value: JvmValue::Array { id: 3, type_name: "Object[]".into(), length: 2 }, + via: vec!["[1]".into()] + }] + ); + + let asked = s.requested("workspace/executeCommand"); + let seen: Vec<_> = asked.iter().map(|p| (p["command"].clone(), p["arguments"].clone())).collect(); + assert_eq!( + seen, + vec![ + (json!("openidea.jvm.classes"), json!([target])), + (json!("openidea.jvm.fields"), json!([target, 5])), + (json!("openidea.jvm.referrers"), json!([target, 5, 50])), + ] + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn jvm_inspect_error_maps_to_inspect_error() { + use ide_jvm_inspect::{InspectError, JvmInspector}; + let (_h, s, inspector) = jvm_inspect_harness().await; + let target = attach_target(1); + let cases = [ + (json!({"error": "stale"}), InspectError::Stale), + (json!({"error": "collected", "detail": "gone"}), InspectError::Collected), + (json!({"error": "classNotLoaded"}), InspectError::ClassNotLoaded), + (json!({"error": "notSuspended"}), InspectError::NotSuspended), + (json!({"error": "vmNotFound"}), InspectError::VmNotFound), + (json!({"error": "timedOut"}), InspectError::TimedOut), + (json!({"error": "unsupported", "detail": "x"}), InspectError::Unsupported("x".into())), + ]; + for (reply, expected) in cases { + s.respond("workspace/executeCommand", reply.clone()); + assert_eq!(inspector.fields(&target, 1).await.unwrap_err(), expected, "{reply}"); + } + s.respond("workspace/executeCommand", json!({"error": "surprise", "detail": "d"})); + assert!( + matches!(inspector.fields(&target, 1).await.unwrap_err(), InspectError::Failed(m) if m.contains("surprise")) + ); + s.respond("workspace/executeCommand", json!("neither")); + assert!(matches!(inspector.fields(&target, 1).await.unwrap_err(), InspectError::Failed(_))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn jvm_inspect_missing_bundle_is_unsupported() { + use ide_jvm_inspect::{InspectError, JvmInspector}; + let (_h, s, inspector) = jvm_inspect_harness().await; + s.errors.lock().insert( + "workspace/executeCommand".into(), + (-32603, "No delegateCommandHandler for openidea.jvm.classes".into()), + ); + assert_eq!( + inspector.class_counts(&attach_target(1)).await.unwrap_err(), + InspectError::Unsupported("the JVM inspector bundle is not loaded".into()) + ); +} + +/// The bundle waits without bound on the JDT LS thread pool, so the client never has two commands +/// in flight. +#[tokio::test(flavor = "multi_thread")] +async fn jvm_inspect_sends_one_command_at_a_time() { + use ide_jvm_inspect::JvmInspector; + let (_h, s, inspector) = jvm_inspect_harness().await; + let in_flight = Arc::new(AtomicUsize::new(0)); + let most = Arc::new(AtomicUsize::new(0)); + *s.responder.lock() = Some(Arc::new({ + let (in_flight, most) = (in_flight.clone(), most.clone()); + move |_, method, _| { + (method == "workspace/executeCommand").then(|| { + most.fetch_max(in_flight.fetch_add(1, Ordering::SeqCst) + 1, Ordering::SeqCst); + std::thread::sleep(Duration::from_millis(80)); + in_flight.fetch_sub(1, Ordering::SeqCst); + json!({"ok": []}) + }) + } + })); + let target = attach_target(1); + let (a, b, c) = + tokio::join!(inspector.fields(&target, 1), inspector.fields(&target, 2), inspector.fields(&target, 3)); + assert!(a.is_ok() && b.is_ok() && c.is_ok()); + assert_eq!(s.requested("workspace/executeCommand").len(), 3); + assert_eq!(most.load(Ordering::SeqCst), 1); +} + +#[tokio::test(flavor = "multi_thread")] +async fn debug_server_port_maps_to_module() { + let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]); + let (_, path) = h.open("Sample.java", "class Sample {}"); + h.wait_status("jdtls", ServerStatus::Ready).await; + h.launcher.state("jdtls").respond("workspace/executeCommand", json!(4711)); + assert_eq!(h.mgr.java_debug_server_module(4711), None); + assert_eq!(h.mgr.java_debug_server(&path).await.unwrap(), 4711); + assert_eq!(h.mgr.java_debug_server_module(4711), Some(path)); + assert_eq!(h.mgr.java_debug_server_module(4712), None); +} + #[tokio::test(flavor = "multi_thread")] async fn fallback_servers_only_run_when_the_preferred_one_is_missing() { // Preferred installed: the alternative is never started. diff --git a/crates/ide-run/src/tomcat.rs b/crates/ide-run/src/tomcat.rs index 75360f96c..2e07f223f 100644 --- a/crates/ide-run/src/tomcat.rs +++ b/crates/ide-run/src/tomcat.rs @@ -1002,6 +1002,12 @@ pub fn script_command(home: &Path, windows: bool, verb: &str) -> (String, Vec bool { + !windows && !opts.contains("StartAttachListener") && !opts.contains("DisableAttachMechanism") +} + fn parse_optional_port( params: &std::collections::BTreeMap, key: &str, @@ -1124,6 +1130,9 @@ pub fn prepare(root: &Path, cache: &Path, config: &mut RunConfig) -> IdeResult<( let mut opts = config.env.get("CATALINA_OPTS").cloned().unwrap_or_default(); opts.push_str(&format!(" {vm} -Dcom.sun.management.jmxremote.port={jmx} -Dcom.sun.management.jmxremote.rmi.port={jmx} -Dcom.sun.management.jmxremote.host=127.0.0.1 -Djava.rmi.server.hostname=127.0.0.1 -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false")); if debug { + if wants_attach_listener(&opts, cfg!(windows)) { + opts.push_str(" -XX:+StartAttachListener"); + } let jdwp_port = parse_optional_port(&config.extras.params, "debugPort", "Debug port")?.unwrap_or(0); opts.push_str(&format!(" -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=127.0.0.1:{jdwp_port}")); } @@ -1594,6 +1603,70 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); check_prepared_paths(tmp.path()); } + /// `CATALINA_OPTS` of a prepared Tomcat configuration (`debug` or run) with the given user options. + fn prepared_opts(debug: bool, vm_options: &str, catalina_opts: Option<&str>) -> String { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let home = root.join("home"); + fs::create_dir_all(home.join("bin")).unwrap(); + fs::create_dir_all(home.join("conf")).unwrap(); + fs::create_dir_all(root.join("WebRoot")).unwrap(); + fs::write(home.join("bin/catalina.sh"), "").unwrap(); + fs::write(home.join("bin/catalina.bat"), "").unwrap(); + archive( + &home.join("lib/catalina.jar"), + &[("org/apache/catalina/util/ServerInfo.properties", "server.info=Apache Tomcat/10.1")], + ); + let server = ""; + fs::write(home.join("conf/server.xml"), server).unwrap(); + let mut c = config(); + for (k, v) in [ + ("tomcatHome", "home"), + ("deployment", "directory"), + ("deploymentPath", "WebRoot"), + ("contextPath", "/app"), + ("tomcatDebug", if debug { "true" } else { "false" }), + ("vmOptions", vm_options), + ] { + c.extras.params.insert(k.into(), v.into()); + } + if let Some(opts) = catalina_opts { + c.env.insert("CATALINA_OPTS".into(), opts.into()); + } + prepare(root, &root.join("cache"), &mut c).unwrap(); + c.env["CATALINA_OPTS"].clone() + } + #[test] + fn starts_the_attach_listener_right_before_the_debug_agent() { + let opts = prepared_opts(true, "-Xmx256m", None); + assert_eq!( + opts.contains(" -XX:+StartAttachListener -agentlib:jdwp=transport=dt_socket"), + !cfg!(windows), + "{opts}" + ); + assert!(!prepared_opts(false, "-Xmx256m", None).contains("StartAttachListener")); + } + #[test] + fn no_attach_listener_for_windows() { + assert!(wants_attach_listener(" -Xmx256m", false)); + assert!(!wants_attach_listener(" -Xmx256m", true)); + assert!(!wants_attach_listener(" -XX:+DisableAttachMechanism", false)); + } + #[test] + fn keeps_user_attach_opt_out() { + for vm in ["-XX:+DisableAttachMechanism", "-XX:+StartAttachListener", "-XX:-StartAttachListener"] { + let opts = prepared_opts(true, vm, None); + assert_eq!( + opts.matches("StartAttachListener").count(), + vm.matches("StartAttachListener").count(), + "{opts}" + ); + assert!(opts.contains("-agentlib:jdwp="), "{opts}"); + } + let opts = prepared_opts(true, "", Some("-XX:+DisableAttachMechanism")); + assert!(!opts.contains("StartAttachListener"), "{opts}"); + assert!(opts.contains("-agentlib:jdwp="), "{opts}"); + } #[cfg(windows)] #[test] fn prepares_plain_windows_paths_from_verbatim_root() { @@ -1657,6 +1730,7 @@ mod tests { assert_eq!(c.env["CATALINA_HOME"], dunce::canonicalize(&home).unwrap().to_string_lossy()); assert_eq!(base, dunce::canonicalize(base_dir(&cache, &c.id)).unwrap()); assert!(c.env["CATALINA_OPTS"].contains("suspend=n,address=127.0.0.1:0")); + assert_eq!(c.env["CATALINA_OPTS"].contains(" -XX:+StartAttachListener -agentlib:jdwp="), !cfg!(windows)); assert!(c.env["CATALINA_OPTS"].contains("-Xmx256m")); assert!(!c.env.get("JAVA_OPTS").is_some_and(|s| s.contains("jdwp"))); assert_eq!(c.env["JRE_HOME"], "/jdk with spaces"); diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index de294f3d8..d7008cf5e 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -1538,41 +1538,61 @@ mod kotlin_bridge_bundle_tests { /// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure. pub fn usages_bundle(data_dir: &Path) -> Option { - let extension = data_dir.join("java-usages-1.12.0.jar"); - let bytes = include_bytes!("../../../java/java-usages/openidea-java-usages.jar"); + install_bundle( + data_dir, + "java-usages", + "1.12.0", + include_bytes!("../../../java/java-usages/openidea-java-usages.jar"), + ) +} + +/// Installs the app-owned JDT LS bundle that inspects java-debug's JVMs. +pub fn jvm_inspect_bundle(data_dir: &Path) -> Option { + install_bundle( + data_dir, + "jvm-inspect", + "1.0.0", + include_bytes!("../../../java/jvm-inspect/openidea-jvm-inspect.jar"), + ) +} + +/// `version` must match the jar's `Bundle-Version`: jdtls (OSGi) caches bundles by it. +fn install_bundle(data_dir: &Path, stem: &str, version: &str, bytes: &'static [u8]) -> Option { + let extension = data_dir.join(format!("{stem}-{version}.jar")); if std::fs::read(&extension).is_ok_and(|old| old == bytes) { - clean_usages_bundles(data_dir, &extension); + clean_bundles(data_dir, stem, &extension); return Some(extension); } // Write then rename: another window must never read a partially written jar. let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?.as_nanos(); - let temporary = data_dir.join(format!("java-usages-{}-{nonce}.tmp", std::process::id())); + let temporary = data_dir.join(format!("{stem}-{}-{nonce}.tmp", std::process::id())); let result = std::fs::create_dir_all(data_dir) .and_then(|_| std::fs::write(&temporary, bytes)) .and_then(|_| std::fs::rename(&temporary, &extension)); match result { Ok(()) => { - clean_usages_bundles(data_dir, &extension); + clean_bundles(data_dir, stem, &extension); Some(extension) } Err(e) => { let _ = std::fs::remove_file(&temporary); if std::fs::read(&extension).is_ok_and(|old| old == bytes) { - clean_usages_bundles(data_dir, &extension); + clean_bundles(data_dir, stem, &extension); return Some(extension); } - tracing::warn!("Java usages extension unavailable; using standard references: {e}"); + tracing::warn!("JDT LS extension {stem} unavailable: {e}"); None } } } -fn clean_usages_bundles(data_dir: &Path, current: &Path) { +fn clean_bundles(data_dir: &Path, stem: &str, current: &Path) { let Ok(entries) = std::fs::read_dir(data_dir) else { return }; + let prefix = format!("{stem}-"); for entry in entries.flatten() { let path = entry.path(); let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue }; - let Some(suffix) = name.strip_prefix("java-usages-") else { continue }; + let Some(suffix) = name.strip_prefix(prefix.as_str()) else { continue }; let numeric = |s: &str| !s.is_empty() && s.bytes().all(|c| c.is_ascii_digit()); let old_jar = suffix.strip_suffix(".jar").is_some_and(|v| { let parts: Vec<_> = v.split('.').collect(); @@ -1620,6 +1640,19 @@ mod usages_bundle_tests { } } + #[test] + fn jvm_inspect_bundle_installs_and_cleans_old_versions() { + let dir = tempfile::tempdir().unwrap(); + for name in ["jvm-inspect-0.9.0.jar", "java-usages-1.7.0.jar"] { + std::fs::write(dir.path().join(name), b"old").unwrap(); + } + let jar = super::jvm_inspect_bundle(dir.path()).unwrap(); + assert_eq!(jar, dir.path().join("jvm-inspect-1.0.0.jar")); + assert_eq!(std::fs::read(&jar).unwrap(), include_bytes!("../../../java/jvm-inspect/openidea-jvm-inspect.jar")); + assert!(!dir.path().join("jvm-inspect-0.9.0.jar").exists()); + assert_eq!(std::fs::read(dir.path().join("java-usages-1.7.0.jar")).unwrap(), b"old"); + } + #[test] fn installs_the_exact_bundle_and_reuses_it() { let dir = tempfile::tempdir().unwrap(); diff --git a/java/jvm-inspect/MANIFEST.MF b/java/jvm-inspect/MANIFEST.MF new file mode 100644 index 000000000..ae1c5e38e --- /dev/null +++ b/java/jvm-inspect/MANIFEST.MF @@ -0,0 +1,9 @@ +Manifest-Version: 1.0 +Bundle-ManifestVersion: 2 +Bundle-Name: OpenIDEA JVM inspect +Bundle-SymbolicName: ai.openidea.jvminspect;singleton:=true +Bundle-Version: 1.0.0 +Bundle-RequiredExecutionEnvironment: JavaSE-21 +Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.core.runtime, + org.eclipse.equinox.common,org.eclipse.jdt.debug + diff --git a/java/jvm-inspect/README.md b/java/jvm-inspect/README.md new file mode 100644 index 000000000..d69b798c9 --- /dev/null +++ b/java/jvm-inspect/README.md @@ -0,0 +1,101 @@ +# JVM inspection inside java-debug + +An OSGi bundle loaded into JDT LS next to microsoft java-debug. While a Java debug session is +suspended, it lists the instances of a class, the fields of an object and the objects that refer to +it, and counts the instances of every loaded class. It works on java-debug's own JDI connection to +the debuggee, so no second debugger connects to the JVM. + +## Commands + +Every command but `openidea.jvm.vms` takes `target` first: +`{"pid": n|null, "host": s|null, "port": n|null, "generation": n}`. A launch session is found by +`pid` (the process java-debug started); an attach session by the remote port of java-debug's socket, +then by `host` when several VMs share that port. Two JVMs running the same program have the same +thread ids and names, so threads are never used to tell sessions apart. + +| Command | Arguments after `target` | `ok` | +| ------------------------ | ------------------------ | ---------------------------------------------------------------------------------- | +| `openidea.jvm.vms` | none (no `target`) | `[{pid, remoteHost, remotePort, localPort, version, canGetInstanceInfo, timeout}]` | +| `openidea.jvm.classes` | none | `[{name, instances}]`, classes with no instances left out | +| `openidea.jvm.instances` | `className`, `limit` | `[JvmValue]`, at most 1000 | +| `openidea.jvm.fields` | `objectId` | `[{name, value}]`; the first 100 elements, as `"[i]"`, of an array | +| `openidea.jvm.referrers` | `objectId`, `limit` | `[{value, via}]`, at most 200; `via` lists the fields or `"[i]"` slots | + +A reply is `{"ok": payload}` or `{"error": code, "detail": text}`, `code` being one of +`unsupported`, `vmNotFound`, `stale`, `collected`, `classNotLoaded`, `notSuspended`, `timedOut`. +Anything else (a malformed argument, a JDI error the bundle does not expect) is an ordinary +`executeCommand` error. Values have the JSON shape of `ide_jvm_inspect::JvmValue`. JDT LS writes +replies with Gson, which leaves out `null` members: an attached VM has no `pid`, and a long string +no `text`. + +- Data is read only while some thread of the VM is suspended (`notSuspended` otherwise), one command + per VM at a time. +- An `objectId` is valid only in the `generation` (the session's stop) it was returned in; any other + is `stale`. Each VM keeps a table of the objects the bundle handed out, emptied when the generation + changes or when it grows past 100 000 entries. +- Previews follow the JDWP link's rules: arrays give only their length, boxed types the primitive in + their `value` field, a `String` the length of its backing array (by `coder`) and its text only up + to 1000 characters. Nothing is invoked: no `toString`, no `invokeMethod`. +- A referrer array longer than 10 000 elements is not searched for the slot (`via` is empty). +- An object collected before its preview is left out of `instances` and `referrers`. + +`classes` and `referrers` can take seconds on a large heap. Eclipse JDI fails a request after +`getRequestTimeout()` (3000 ms by default), and the timeout is per VM: java-debug's own requests +waiting behind the command time out too. These two commands raise it to at least 60 000 ms and, +when done, put back the old value unless something else changed it meanwhile (java-debug applies its +timeout setting to every VM when it changes). + +## Internals it depends on + +java-debug is not pinned: the app installs the latest vscode-java-debug. The bundle reaches the VMs +through these non-API members, by reflection: + +- `com.microsoft.java.debug.plugin.internal.JdtVirtualMachineManagerProvider#getVirtualMachineManager()` +- `AdvancedVirtualMachineManager.connectedVMs` (a synchronized list) +- `org.eclipse.jdi.internal.VirtualMachineImpl#packetReceiveManager()` +- `org.eclipse.jdi.internal.connect.PacketManager.fConnection` +- `org.eclipse.jdi.internal.connect.SocketConnection.fSocket` + +and, as API of `org.eclipse.jdt.debug`, `org.eclipse.jdi.VirtualMachine#getRequestTimeout` / +`#setRequestTimeout` and `org.eclipse.jdi.TimeoutException`. When any of these is missing or +changes shape, every command answers `unsupported`; nothing throws into java-debug. + +Checked with JDT LS 1.63.0.202610191322 (`org.eclipse.jdt.debug` 3.27.0) and java-debug plugin +0.53.2 (vscode-java-debug 0.60.0). + +## Rebuild + +Build dependency: [JDT LS 1.63.0 build 202610191322](https://download.eclipse.org/jdtls/milestones/1.63.0/jdt-language-server-1.63.0-202610191322.tar.gz), +the same as `java/java-usages`. Check the extracted distribution from its root +(`sha256sum -c /path/to/build-dependencies.sha256`), then with Java 25 on `PATH`: + +```sh +python3 build.py /path/to/jdtls # writes openidea-jvm-inspect.jar +python3 build.py /path/to/jdtls --test # compiles tests/SelfTest.java with the sources and runs it +``` + +The build uses the distribution's ECJ and plugin jars plus the `jdimodel.jar` nested in +`plugins/org.eclipse.jdt.debug_*.jar`, sorted inputs and fixed zip timestamps; bytecode targets +Java 21. Build twice and compare: two builds of the checked-in jar gave + +``` +ab53d0e6b9e492b5c3be1179c793c879db94ba44a421bdbd4f2f33319e03464a openidea-jvm-inspect.jar +``` + +Change `Bundle-Version` whenever the jar changes: OSGi caches bundles by version. + +## Real JDT LS check + +`tests/inspect.py` starts JDT LS with java-debug and this jar, debugs a small `Heap` program (a launch +and two attaches to the same program) and prints `OK`/`FAIL`/`SKIP` per check: + +```sh +PATH=/path/to/jdk25/bin:$PATH \ +OPENIDEA_TEST_JAVA=/path/to/jdk11/bin/java:/path/to/jdk21/bin/java \ +python3 -I tests/inspect.py /path/to/jdtls /path/to/com.microsoft.java.debug.plugin-0.53.2.jar --heap 10000000 +``` + +`OPENIDEA_TEST_JAVA` lists debuggee JDKs (`:`-separated; `;` on Windows). The attaches use the +first; `classes` is checked on the JDK 11 one. With `--heap N`, a JDK 21 JVM holds N objects and a +DAP `evaluate` sent 0.5 s after `classes` starts must succeed, with the timeout back to 3000 after. +It stops every process it started. diff --git a/java/jvm-inspect/build-dependencies.sha256 b/java/jvm-inspect/build-dependencies.sha256 new file mode 100644 index 000000000..bb0540f5b --- /dev/null +++ b/java/jvm-inspect/build-dependencies.sha256 @@ -0,0 +1,110 @@ +beebede8db065fe1b72909ecc66bb49acda618901635d86c25fce14a5915e37e plugins/ch.qos.logback.classic_1.6.3.jar +a6967a28c8b086dee75a694d2f6fb8830c71153539866d7d8af065c9b6df91e0 plugins/ch.qos.logback.core_1.6.3.jar +2cbd119bf1961c28788310963dc80ba65f58cdeec1dd139c8bdb1240faa2c36f plugins/com.google.gson_2.14.0.jar +cbfc3906b19b8f55dd7cfd6dfe0aa4532e834250d7f080bd8d211a3e246b59cb plugins/com.google.guava.failureaccess_1.0.3.jar +796d8e28ac64e83a47c4c5935a8fecc4682650a04bbdead738ef0f5a3a0e6c46 plugins/com.google.guava_33.7.1.jre.jar +3b3864f5b449e9c3c24b16861524b622b086563f44e0cd8384c8efc5a6052f82 plugins/com.sun.jna.platform_5.19.1.jar +467b2990d4f406e7d41f7a989f89df257767ca11d566f844560e2ab8d09f6433 plugins/com.sun.jna_5.19.1.v20260612-1000.jar +85fb03fc054cdf4efca8efd9b6712bbb418e1ab98241c4539c8585bbc23e1b8a plugins/jakarta.annotation-api_1.3.5.jar +a852b0971c589491e24334bd77e92760c048d2c102fc4c68e582e1cd53130f84 plugins/jakarta.inject.jakarta.inject-api_1.0.5.jar +2506fb25a748d4a5e5ec549d8bb063889d0f213e9cfbd6fd6fcbdc93b4f35bbb plugins/jakarta.servlet-api_6.2.0.M2.jar +cbf2aa09c14441d2ddea7096f60b738b3aee7f08fbf98f5f91e2ca48b08fff4a plugins/org.apache.ant_1.10.18.v20260906-1000.jar +8f7f8605d68e15bf32db61ec94eac6fdafc51b1bdbe1e0e0802b57d23f387792 plugins/org.apache.commons.cli_1.11.0.jar +78a5d732fbd715e2d10bd7150d2f8030bae57267f8aacc5c88f642cb6c2e5d3f plugins/org.apache.commons.commons-codec_1.22.1.jar +69e5c9fa35da7a51a5fd2099dfe56a2d8d32cf233e2f6d770e796146440263f4 plugins/org.apache.commons.lang3_3.20.0.jar +c5bd887c8fd5ec4cd563249e7552bb1a1a1ca57e05a1f1ef8425a7b7b7bd2fbe plugins/org.apache.felix.scr_2.2.18.jar +c375b6d5b71efe179e5763a6d83fcf10e303c0bf82031310a46769727ac90da0 plugins/org.commonmark.ext-gfm-tables_0.30.1.jar +9b0d20064975a17e876fcb4b599c6b3667f46a7739ed61e2f06c96f44b50629f plugins/org.commonmark_0.30.1.jar +a6a16f8f2b6977a10f951ab4ff46b155e4e20dd8c2fcd2cb835b74d90f30b456 plugins/org.eclipse.ant.core_3.7.1100.v20260831-0701.jar +61c479b6544db7eeebb5ba796051c092032d03a5136295baabd72a233baef29c plugins/org.eclipse.buildship.compat_3.1.13.v20260923-0232-s.jar +213baab808816e0661acd61a5382a8f120923288212defbd189b4ee6540ecce1 plugins/org.eclipse.buildship.core_3.1.13.v20260923-0232-s.jar +e75b07863128a076989d15e815d63a49cab772a338f1f0444d007bef3fa94601 plugins/org.eclipse.compare.core_3.8.900.v20260731-0809.jar +01425989976dccd56d348b207e11239b3d8bf572f7b7ef12dbd29eb39c40205e plugins/org.eclipse.core.commands_3.13.100.v20260902-1129.jar +a63b488fb9d5382fbb3b8bae0951f1da5fd3a20211aa6814c7e8c1e21d2ffc78 plugins/org.eclipse.core.contenttype_3.9.1000.v20260921-1824.jar +06a18236ae181659e1e5499d912d68de527720686f1f51dab632d3e298e32b78 plugins/org.eclipse.core.expressions_3.9.700.v20260710-0925.jar +422a33041ea01954f48ff3969e2c6d96e64831b3e372202a4d2a914282e3ffd9 plugins/org.eclipse.core.filebuffers_3.9.0.v20261010-1449.jar +d0f5e3fb7cd17bb8daebc684311198138e380e089292477f12d2dfbb4c8a30a8 plugins/org.eclipse.core.filesystem_1.12.0.v20260927-0940.jar +941b3c5feeba4ec0a949a8a94ff10283b2505813fa736c9e6345f146ae1b7e8a plugins/org.eclipse.core.jobs_3.15.900.v20260604-0622.jar +6e7a8db7d87a44bb35924bb345ddf0d9372413e3fee2edd842b50ed630ec254f plugins/org.eclipse.core.net_1.5.900.v20260507-0401.jar +e22d47e2e68713a74bfacd134dc01b1aa904a1b429769241483e058de166acea plugins/org.eclipse.core.resources_3.25.0.v20261010-1447.jar +0947f29f5c5b7d234e3b735de2d00d33ae6e3ee428a61bb818f9c0fe2b809735 plugins/org.eclipse.core.runtime_3.35.100.v20260910-1515.jar +553f09a59f2662fa4a3bbc94e84bd83221c1c6fb532d81b57010162fb47460a6 plugins/org.eclipse.core.variables_3.6.900.v20260602-0702.jar +a4e87f5e883e6c864783dc8148e9f915c3e7a9e3e0e26acf6b82210d1134ed79 plugins/org.eclipse.debug.core_3.24.100.v20261009-1915.jar +fd353398835107608f554779a6e91891725efb94e0f57559d0ab3cea4b6156c7 plugins/org.eclipse.equinox.app_1.7.600.v20251211-1038.jar +28749ec64dac9d0e20e00becc3d5f9daccb5637455c9c546ab07be324011ef84 plugins/org.eclipse.equinox.common_3.21.100.v20260829-1451.jar +9beeb1098a3da89a470c547aa840dea0d240d144138a5ba7b9ceedb84ed301ab plugins/org.eclipse.equinox.frameworkadmin.equinox_1.3.500.v20260928-0506.jar +f2b956930b8383d708f1fe9df92938bffbe2e129082d4fd0b3d5f59c20a5c9f0 plugins/org.eclipse.equinox.frameworkadmin_2.3.600.v20260306-0554.jar +8e889f92718fb8c612dd358265da2c94738bbc08b46473b81cad36f1177a8235 plugins/org.eclipse.equinox.launcher.cocoa.macosx.aarch64_1.2.1700.v20260913-1737.jar +347674eb3d3d4000c184573618241141297fb9586c8f65ebdbacace59b8c81a2 plugins/org.eclipse.equinox.launcher.cocoa.macosx.x86_64_1.2.1700.v20260913-1737.jar +57e8a4b46906751e214dfa8fab1e02462c40ad4e51f2226d9ef70af5f0941ccb plugins/org.eclipse.equinox.launcher.gtk.linux.aarch64_1.2.1700.v20260913-1737.jar +d0e7418232627c53288e566728d24d1578650514152992b87f82a5d982429606 plugins/org.eclipse.equinox.launcher.gtk.linux.x86_64_1.2.1700.v20260913-1737.jar +aa8c31a082b25b87f75008548f6544379174c98091934fb8399e3f917a0445f1 plugins/org.eclipse.equinox.launcher.win32.win32.x86_64_1.3.200.v20260913-1737.jar +9b3b1da60813ac5fa09dd5c198f1884be7076d0a5daec54faa3f351a787e34b7 plugins/org.eclipse.equinox.launcher_1.8.0.v20260804-1928.jar +fd04e81a28ab32b6bb073286eb293c44b760176fcf1e35ef15614d62df820f86 plugins/org.eclipse.equinox.preferences_3.12.100.v20251111-0704.jar +9b149d3367d33fc1f9a19d3f87c425ecf9fc235ee748b3cf200ec387169b042b plugins/org.eclipse.equinox.registry_3.12.700.v20260830-1519.jar +60388d163f70329c39a9cf4e32389ec4fba8dc80ae5518d97cd0be2ebb971510 plugins/org.eclipse.equinox.security.linux_1.2.0.v20260914-1851.jar +a8408d4bcdc70e019d2d89ed8b1b0413eb74ea73e74fa3d064b7401643539797 plugins/org.eclipse.equinox.security.macosx_1.102.500.v20250521-0414.jar +cf9668c1d1e481059366abc2719468aa52e1276475cf9c32d4d10f57b34d7182 plugins/org.eclipse.equinox.security.win32_1.3.0.v20240419-2334.jar +91b74719f9c1a130193407808fe5daab3b92431efbf30b8f886f1ff5e97f468f plugins/org.eclipse.equinox.security_1.4.700.v20250622-1644.jar +1a24a3c0fb01ad71f97432d5c2b364d9868b5a5ebd99a1308aacb1a2c03c47da plugins/org.eclipse.equinox.simpleconfigurator.manipulator_2.3.700.v20260306-0556.jar +d250938d3715529a9822fcdf46ad2f5de39720558d7195b5c1a8713af00bcd13 plugins/org.eclipse.equinox.simpleconfigurator_1.5.700.v20251111-1031.jar +db9a4e856bb4728a2719df544b0f2ccef52b54ebf4e643a417fdb471f85703c1 plugins/org.eclipse.equinox.spi_1.0.0.v20261009-1945.jar +7615ee7abdd14695957a52864c57869018dc10ddaac80d3cd24b6a97e05d0d9f plugins/org.eclipse.jdt.apt.core_3.8.1000.v20261012-1009.jar +ea365808514a22c2e5a1a3fe0b9c07b88a371094d5d5716d31906a8818d56b2a plugins/org.eclipse.jdt.apt.pluggable.core_1.4.800.v20260930-1106.jar +f667bb3666d9875a2309fa2447a19e6cdd960281473bd7125a22c5a435246d21 plugins/org.eclipse.jdt.core.compiler.batch_3.46.200.v20261008-1115.jar +a6d0f8fabd64c248c37c463789a1a13f801252b68b0a4f4808c207677b5cae17 plugins/org.eclipse.jdt.core.javac_1.0.0.z20260430-1959.jar +8ea98fcbecbd89f311849e448f4c4274a2aba5e15cfcdcad29951330c40e0ee8 plugins/org.eclipse.jdt.core.manipulation_1.26.0.v20261009-1526.jar +70b4bb168aab096ee7f33da7b72f470f4172fe3e18e80289dd89cf344937ed22 plugins/org.eclipse.jdt.core_3.48.0.v20261007-1246.jar +12113df750bb22f7ef0dd3f044f7c334feecd55ddb56b9340529899c55f5893b plugins/org.eclipse.jdt.debug_3.27.0.v20261012-1250.jar +f140fad97dc5c452af0cb0f87ed3c6c83ff518f1dda49a43b8b9bec07f17d0fd plugins/org.eclipse.jdt.junit.core_3.16.0.v20261008-2002.jar +4ea3fe4a12a0e1e8b10b7ce3f577a0ff4f5115c6a5e40364c53b8b329a8505b0 plugins/org.eclipse.jdt.junit.runtime_3.8.200.v20260911-1643.jar +8b928263817f5dd0a9210a8842682903f779dc83253fea501591e150e5303095 plugins/org.eclipse.jdt.launching_3.25.0.v20261010-0421.jar +6e5e5e39d82e9ac04925e8b3f432ef6fe81284441e99952d2f720779c9e2831b plugins/org.eclipse.jdt.ls.core_1.63.0.202610191322.jar +666cc544f8a63e41d2dc8e6f3626071214027e4f698dea5bf36137f85477bba4 plugins/org.eclipse.jdt.ls.filesystem_1.63.0.202610191322.jar +792f7df214d4ab5e4915ac1805922d44de61ebb880f1fc4881ecd4ecfdca6aca plugins/org.eclipse.jdt.ls.logback.appender_1.63.0.202610191322.jar +9647feb0524bf763c878e12ab878a684102b81cccb3f77feecbec709d54f9bbb plugins/org.eclipse.lsp4j.jsonrpc_1.0.0.v20260209-1721.jar +ccd78893facc6bfcc359d56cba05d3d5b85eb41e4c40d4b4215ca45db5f416d9 plugins/org.eclipse.lsp4j_1.0.0.v20260209-1721.jar +a5d6bdc3e6f5017f19375697c7a5929fbc83f5734f35d5ed4540c7b2c843d4bb plugins/org.eclipse.ltk.core.refactoring_3.17.0.v20261010-1449.jar +69d1b41401686fd99337e1ddd8aeec309255ae464393ca19a8025c85bfae6601 plugins/org.eclipse.m2e.apt.core_2.3.200.20260815-0704.jar +34872f7712dffca0b39804708c0c1b58f2f2576e27cb7176b92a1ea437d384e6 plugins/org.eclipse.m2e.core_2.8.0.20260815-0704.jar +8c3fa36fe9f62ace897b0196df8e6bba6a50610c186f6689ada5f386a8b223b1 plugins/org.eclipse.m2e.jdt_2.5.300.20260813-1359.jar +5c5b419a101210127f21fa7d37bd2605b90f96efe9d3e91ea31099fb243dec8e plugins/org.eclipse.m2e.maven.runtime_3.9.1601.20260722-2149.jar +53376fa2d7bfd2d5166806e74af9576ee3f8f1c1ac916e2cb4d2bb0c3e72c02f plugins/org.eclipse.m2e.workspace.cli_0.4.0.jar +20e09b8f88845480b6ad0b89ac001679b6713592cd0ee4bd78cdb326172eee62 plugins/org.eclipse.osgi.compatibility.state_1.3.100.v20260909-1123.jar +fe66fd0b071bebc7e1a122be571ed8ecde373d2966fa6b1e161a899b3c7a054d plugins/org.eclipse.osgi_3.25.0.v20261008-2211.jar +2714e0c7548a0c23ab99d4e84b68d3e67419bbd6b295efda15d881e7cb173a1c plugins/org.eclipse.search.core_3.17.0.v20261010-1449.jar +0ca3b552c867619285b8ae36c7defdd6c0f013e692643d433fd111796da55f19 plugins/org.eclipse.text_3.14.900.v20261012-1310.jar +c6c2b8593cd39c279e74e7055cbd146d39dc6f16011961b4928b92728d32d00d plugins/org.eclipse.xtext.xbase.lib_2.44.0.v20260824-1228.jar +ea7a53f8b7966039c6f7d54f462cbbaaad38a5e313a4e7d9125fa39640988725 plugins/org.gradle.toolingapi_9.7.1.v20260923-0232-s.jar +5d66b6a4a680755cb6ed7cb104fa7835ef644667586ff0737adeb977c39ecdbc plugins/org.hamcrest_3.0.0.jar +e9d8c856856680427f096d156f02a414289985c930998fcb519ce5be39b42003 plugins/org.jsoup_1.23.2.jar +3efac657139b0b8024748e937e3e1dba71fbdf69fb526c60f504a3fb5490381b plugins/org.junit_4.13.2.v20240929-1000.jar +ed825d10ab1399c8c0cb669e688cf0c8c82629b4c8399b58352b68e92ca10fcb plugins/org.objectweb.asm_9.10.1.jar +0653852deab5c6f6e9c84e3aa8f72e72b9ce7dfaf7a6431f3d80f6f9d7a433ad plugins/org.osgi.service.component_1.5.1.202212101352.jar +43c7c870710e363405d422da653cce0d798a4537f76e4930f79bceadd3a55345 plugins/org.osgi.service.prefs_1.1.2.202109301733.jar +208819c7c71690c15a6bb8b187474e7f9d0147946b680182a62b9f222ae014ec plugins/org.osgi.util.function_1.2.0.202109301733.jar +7053c57e7d7d88fec6b90979a3af125e1d2bb847268a328a2f1ed65ad0a4c185 plugins/org.osgi.util.promise_1.3.0.202212101352.jar +7e1446499b359675d8aabe6af2de86b85e72ae8a2a932c0b40d0e5ba57097439 plugins/slf4j.api_2.0.20.jar +3970dab20b4deb8380ed9656392f289f3f3c2ee882d8c3942f1b5b486284620d plugins/wrapped.com.jetbrains.intellij.java.java-decompiler-engine_253.29346.240.jar +65d87386b7e926c0b6fb330a8d2eb6a987f5949634f7574731c0a3c2329d5629 plugins/wrapped.com.vladsch.flexmark.flexmark-ext-emoji_0.64.8.jar +7a350f43f858da8f4a230552607ced119aeaf97484ca11ce4cd792e1e41e5bf7 plugins/wrapped.com.vladsch.flexmark.flexmark-ext-gfm-strikethrough_0.64.8.jar +7a424dae7df0ee86200a08e163d9d1d8f63cd7a1a0c980b84ccbec029571fb4e plugins/wrapped.com.vladsch.flexmark.flexmark-ext-ins_0.64.8.jar +75a2e7168145f799a039832708aa0bc277a954c4a367f58711ccd6b9b7e7d04e plugins/wrapped.com.vladsch.flexmark.flexmark-ext-superscript_0.64.8.jar +dbe6355ea9e04731d534d3d4151870f83684c7a44c87d541a2c53ef5ee2c4fa4 plugins/wrapped.com.vladsch.flexmark.flexmark-ext-tables_0.64.8.jar +91ae2f77e832d20607fad5b7f407d438ca3f16ac032b4f14956e4e7eb3a60931 plugins/wrapped.com.vladsch.flexmark.flexmark-ext-wikilink_0.64.8.jar +0b650a4b47d29ffe0a08802c5d58e2270f733ce5443420c0e8904cfa1605964f plugins/wrapped.com.vladsch.flexmark.flexmark-html2md-converter_0.64.8.jar +74bab2d767f48f930471c9a72a0c3b89e9073549ad3754e6b7f83f09585463bc plugins/wrapped.com.vladsch.flexmark.flexmark-jira-converter_0.64.8.jar +1c7d205afeef618760a5fa907d579835df625ee258cbebd49813f14c2254a535 plugins/wrapped.com.vladsch.flexmark.flexmark-util-ast_0.64.8.jar +9179310212700753a4ecdc7aa057a04591b9cfdb089787a248e8269c34740310 plugins/wrapped.com.vladsch.flexmark.flexmark-util-builder_0.64.8.jar +796b9acec9af0fa290725a14be8a3fc1d6fe7a9cb518a13f67124b5172a3485b plugins/wrapped.com.vladsch.flexmark.flexmark-util-collection_0.64.8.jar +20458c20bc72808e1718ff8f1652272bbb32f62ddaecb2e9a0ddc84d77fd7283 plugins/wrapped.com.vladsch.flexmark.flexmark-util-data_0.64.8.jar +b59fbc922e3604442ff6aae1c6b83220245ba4c3f89d38870bc28c982b69d01d plugins/wrapped.com.vladsch.flexmark.flexmark-util-dependency_0.64.8.jar +b6073ee177aa2476d64787f9ec813fdb881943d402c195df0631ba672bfb1311 plugins/wrapped.com.vladsch.flexmark.flexmark-util-format_0.64.8.jar +c892e6cdbba715c55462decbe1c83cec432548cf2877cfd0715975c44dfab55c plugins/wrapped.com.vladsch.flexmark.flexmark-util-html_0.64.8.jar +1726c33ee95f77cfadc58c8ad27949ed72653f4045fbc399aff62827b44c7fcd plugins/wrapped.com.vladsch.flexmark.flexmark-util-misc_0.64.8.jar +cd0f9212530aa73f67de94d080018b2c3a3a55d41050d3abb4be8afb69989be0 plugins/wrapped.com.vladsch.flexmark.flexmark-util-options_0.64.8.jar +340167af15da00e3ae5fb89d6b44751abfd648951bb6d2e05c48287d9f9e70c9 plugins/wrapped.com.vladsch.flexmark.flexmark-util-sequence_0.64.8.jar +a8fad4a006411d020624c7539e6145e97920067844acddc10b019a178a680948 plugins/wrapped.com.vladsch.flexmark.flexmark-util-visitor_0.64.8.jar +792c13bc5536704fc0bfba9120e5bac712843a7ae6da6104b1392b382feaf63d plugins/wrapped.com.vladsch.flexmark.flexmark-util_0.64.8.jar +a2ec63056bcfe8c4babc62f13f5f49b502fccccf7c011b5d4ba7dd93f4d09393 plugins/wrapped.com.vladsch.flexmark.flexmark_0.64.8.jar +482b6aade7b0320e28db530a22f1382b3d30addf26a5ffd55a347f5c32f6dfc2 plugins/wrapped.org.jetbrains.annotations_24.0.1.jar diff --git a/java/jvm-inspect/build.py b/java/jvm-inspect/build.py new file mode 100644 index 000000000..133da8568 --- /dev/null +++ b/java/jvm-inspect/build.py @@ -0,0 +1,39 @@ +"""Reproducible bundle build: python3 build.py /path/to/jdtls [--test] (JDT LS 1.63.0, Java 25; target Java 21). + +--test compiles tests/SelfTest.java with the sources and runs it; it does not write the jar.""" +import os +import pathlib +import subprocess +import sys +import tempfile +import zipfile + +def run(command): + code = subprocess.run(command).returncode + if code: + sys.exit(f'{command[-1]} failed with exit status {code}') + + +root = pathlib.Path(__file__).resolve().parent +test = '--test' in sys.argv[1:] +plugins = pathlib.Path(next(a for a in sys.argv[1:] if a != '--test')).resolve() / 'plugins' +with tempfile.TemporaryDirectory() as tmp: + tmp = pathlib.Path(tmp) + # org.eclipse.jdt.debug ships the JDI model as a nested jar, which javac cannot read in place. + with zipfile.ZipFile(next(plugins.glob('org.eclipse.jdt.debug_*.jar'))) as debug: + (tmp / 'jdimodel.jar').write_bytes(debug.read('jdimodel.jar')) + classpath = [str(p) for p in sorted(plugins.glob('*.jar'))] + [str(tmp / 'jdimodel.jar')] + out = tmp / 'classes' + sources = sorted((root / 'src').rglob('*.java')) + ([root / 'tests' / 'SelfTest.java'] if test else []) + run(['java', '-cp', str(plugins / '*'), + 'org.eclipse.jdt.internal.compiler.batch.Main', '-21', '-proc:none', + '-classpath', os.pathsep.join(classpath), + '-d', str(out), *[str(p) for p in sources]]) + if test: + run(['java', '-cp', os.pathsep.join([str(out), *classpath]), 'ai.openidea.jvminspect.SelfTest']) + sys.exit(0) + with zipfile.ZipFile(root / 'openidea-jvm-inspect.jar', 'w', zipfile.ZIP_DEFLATED) as jar: + files = {'META-INF/MANIFEST.MF': root / 'MANIFEST.MF', 'plugin.xml': root / 'plugin.xml'} + files.update({p.relative_to(out).as_posix(): p for p in out.rglob('*.class')}) + for name, path in sorted(files.items()): + jar.writestr(zipfile.ZipInfo(name, (2026, 1, 1, 0, 0, 0)), path.read_bytes()) diff --git a/java/jvm-inspect/openidea-jvm-inspect.jar b/java/jvm-inspect/openidea-jvm-inspect.jar new file mode 100644 index 000000000..c25c37921 Binary files /dev/null and b/java/jvm-inspect/openidea-jvm-inspect.jar differ diff --git a/java/jvm-inspect/plugin.xml b/java/jvm-inspect/plugin.xml new file mode 100644 index 000000000..b1939bb4c --- /dev/null +++ b/java/jvm-inspect/plugin.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/java/jvm-inspect/src/ai/openidea/jvminspect/ConnectedVms.java b/java/jvm-inspect/src/ai/openidea/jvminspect/ConnectedVms.java new file mode 100644 index 000000000..a0ec74e7a --- /dev/null +++ b/java/jvm-inspect/src/ai/openidea/jvminspect/ConnectedVms.java @@ -0,0 +1,72 @@ +package ai.openidea.jvminspect; + +import com.sun.jdi.VirtualMachine; +import java.lang.reflect.Field; +import java.net.Socket; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +/** + * java-debug's live VMs, read through its internals. Nothing here is API: every step that no + * longer resolves is {@link Unsupported}, never a crash. + */ +final class ConnectedVms { + static final String PROVIDER = "com.microsoft.java.debug.plugin.internal.JdtVirtualMachineManagerProvider"; + + static final class Unsupported extends Exception { + private static final long serialVersionUID = 1L; + + Unsupported(String detail) { + super(detail); + } + } + + private ConnectedVms() {} + + /** + * @param loader loads a class of the java-debug bundle by name; {@code null} when it is not there + * @return a copy of {@code AdvancedVirtualMachineManager.connectedVMs} + */ + static List read(Function> loader) throws Unsupported { + Class provider = loader.apply(PROVIDER); + if (provider == null) throw new Unsupported(PROVIDER + " not found"); + try { + // The provider keeps the manager in a static field, so a new provider returns java-debug's one. + Object manager = provider.getMethod("getVirtualMachineManager").invoke(provider.getDeclaredConstructor().newInstance()); + List vms = (List) field(manager.getClass(), "connectedVMs").get(manager); + List out = new ArrayList<>(); + // a Collections.synchronizedList that java-debug changes as sessions start and end + synchronized (vms) { + for (Object vm : vms) out.add((VirtualMachine) vm); + } + return out; + } catch (ReflectiveOperationException | RuntimeException e) { + throw new Unsupported(e.toString()); + } + } + + /** {@code VirtualMachineImpl.packetReceiveManager()} → {@code PacketManager.fConnection} → {@code SocketConnection.fSocket}. */ + static Socket socket(VirtualMachine vm) throws Unsupported { + try { + Object receiver = vm.getClass().getMethod("packetReceiveManager").invoke(vm); + Object connection = field(receiver.getClass(), "fConnection").get(receiver); + return (Socket) field(connection.getClass(), "fSocket").get(connection); + } catch (ReflectiveOperationException | RuntimeException e) { + throw new Unsupported(e.toString()); + } + } + + private static Field field(Class type, String name) throws NoSuchFieldException { + for (Class k = type; k != null; k = k.getSuperclass()) { + try { + Field f = k.getDeclaredField(name); + f.setAccessible(true); + return f; + } catch (NoSuchFieldException e) { + // declared further up + } + } + throw new NoSuchFieldException(type.getName() + "." + name); + } +} diff --git a/java/jvm-inspect/src/ai/openidea/jvminspect/Handles.java b/java/jvm-inspect/src/ai/openidea/jvminspect/Handles.java new file mode 100644 index 000000000..a93c524f2 --- /dev/null +++ b/java/jvm-inspect/src/ai/openidea/jvminspect/Handles.java @@ -0,0 +1,42 @@ +package ai.openidea.jvminspect; + +import java.util.HashMap; +import java.util.Map; + +/** + * The objects of one VM that the bundle has handed out, by JDWP id. An id is valid only within the + * stop ({@code generation}) it was returned in. + */ +final class Handles { + static final int MAX = 100_000; + + static final class Stale extends Exception { + private static final long serialVersionUID = 1L; + + Stale(long id) { + super("object " + id + " is not from this stop"); + } + } + + private final Map objects = new HashMap<>(); + private Long generation; + + /** + * Starts a command. A new generation, or a table over {@link #MAX}, starts an empty table; the + * limit is checked here so that one command's own objects never go stale while it runs. + */ + void begin(long generation) { + if (this.generation == null || this.generation != generation || objects.size() > MAX) objects.clear(); + this.generation = generation; + } + + void put(long id, T object) { + objects.put(id, object); + } + + T get(long id) throws Stale { + T object = objects.get(id); + if (object == null) throw new Stale(id); + return object; + } +} diff --git a/java/jvm-inspect/src/ai/openidea/jvminspect/InspectCommand.java b/java/jvm-inspect/src/ai/openidea/jvminspect/InspectCommand.java new file mode 100644 index 000000000..8143be1f2 --- /dev/null +++ b/java/jvm-inspect/src/ai/openidea/jvminspect/InspectCommand.java @@ -0,0 +1,231 @@ +package ai.openidea.jvminspect; + +import ai.openidea.jvminspect.VmSelector.Target; +import ai.openidea.jvminspect.VmSelector.VmInfo; +import com.sun.jdi.ArrayReference; +import com.sun.jdi.ObjectCollectedException; +import com.sun.jdi.ObjectReference; +import com.sun.jdi.ReferenceType; +import com.sun.jdi.ThreadReference; +import com.sun.jdi.VMDisconnectedException; +import com.sun.jdi.VirtualMachine; +import java.net.Socket; +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.Platform; +import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler; +import org.osgi.framework.Bundle; + +/** + * Lists the instances, fields and referrers of objects in a VM java-debug is connected to. Replies + * are {@code {"ok": payload}} or {@code {"error": code, "detail": text}}; the codes are matched by + * the Rust client. + */ +public final class InspectCommand implements IDelegateCommandHandler { + private static final String JAVA_DEBUG = "com.microsoft.java.debug.plugin"; + /** For the commands that can take seconds on a large heap. */ + private static final int SLOW_REQUEST_MS = 60_000; + private static final int MAX_INSTANCES = 1000; + private static final int MAX_REFERRERS = 200; + private static final int ARRAY_PREVIEW = 100; + private static final int REFERRER_ARRAY_SCAN = 10_000; + /** One per connected VM; also the lock that runs one command per VM at a time. */ + private static final Map> HANDLES = new IdentityHashMap<>(); + + private static final class Failure extends Exception { + private static final long serialVersionUID = 1L; + final String code; + + Failure(String code, String detail) { + super(detail); + this.code = code; + } + } + + @Override + public Object executeCommand(String command, List arguments, IProgressMonitor monitor) { + try { + List vms = describe(ConnectedVms.read(InspectCommand::loadJavaDebug)); + if (command.equals("openidea.jvm.vms")) return ok(summaries(vms)); + Target target = Target.of(arguments.get(0)); + VirtualMachine vm = VmSelector.select(vms, target) + .orElseThrow(() -> new Failure("vmNotFound", "no single VM matches " + target)).vm(); + Handles handles = handles(vm); + synchronized (handles) { + if (!vm.canGetInstanceInfo()) throw new Failure("unsupported", "the VM cannot list instances"); + if (vm.allThreads().stream().noneMatch(ThreadReference::isSuspended)) { + throw new Failure("notSuspended", "no thread is suspended"); + } + handles.begin(target.generation()); + return ok(switch (command) { + case "openidea.jvm.classes" -> classes(vm); + case "openidea.jvm.instances" -> instances(vm, handles, (String) arguments.get(1), number(arguments.get(2)).intValue()); + case "openidea.jvm.fields" -> fields(handles, handles.get(number(arguments.get(1)).longValue())); + case "openidea.jvm.referrers" -> referrers(vm, handles, handles.get(number(arguments.get(1)).longValue()), + number(arguments.get(2)).intValue()); + default -> throw new IllegalArgumentException("unknown command " + command); + }); + } + } catch (Failure e) { + return error(e.code, e.getMessage()); + } catch (ConnectedVms.Unsupported e) { + return error("unsupported", e.getMessage()); + } catch (Handles.Stale e) { + return error("stale", e.getMessage()); + } catch (ObjectCollectedException e) { + return error("collected", String.valueOf(e.getMessage())); + } catch (org.eclipse.jdi.TimeoutException e) { + return error("timedOut", String.valueOf(e.getMessage())); + } catch (VMDisconnectedException e) { + return error("vmNotFound", "the VM disconnected"); + } + } + + private static Class loadJavaDebug(String name) { + Bundle bundle = Platform.getBundle(JAVA_DEBUG); + if (bundle == null) return null; + try { + return bundle.loadClass(name); + } catch (ClassNotFoundException e) { + return null; + } + } + + private static List describe(List vms) throws ConnectedVms.Unsupported { + List out = new ArrayList<>(); + for (VirtualMachine vm : vms) { + Socket socket = ConnectedVms.socket(vm); + out.add(new VmInfo(vm, pid(vm), socket.getInetAddress().getHostAddress(), socket.getPort(), socket.getLocalPort())); + } + Set live = Collections.newSetFromMap(new IdentityHashMap<>()); + live.addAll(vms); + synchronized (HANDLES) { + HANDLES.keySet().retainAll(live); + } + return out; + } + + /** Null for an attached VM, which java-debug did not start. */ + private static Long pid(VirtualMachine vm) { + try { + Process process = vm.process(); + return process == null ? null : process.pid(); + } catch (UnsupportedOperationException e) { + return null; + } + } + + private static List> summaries(List vms) throws ConnectedVms.Unsupported { + List> out = new ArrayList<>(); + for (VmInfo v : vms) { + out.add(Values.json("pid", v.pid(), "remoteHost", v.remoteHost(), "remotePort", v.remotePort(), "localPort", v.localPort(), + "version", v.vm().version(), "canGetInstanceInfo", v.vm().canGetInstanceInfo(), + "timeout", RequestTimeout.Knob.of(v.vm()).get())); + } + return out; + } + + private static Handles handles(VirtualMachine vm) { + synchronized (HANDLES) { + return HANDLES.computeIfAbsent(vm, k -> new Handles<>()); + } + } + + /** Every loaded class with at least one instance, from one {@code InstanceCounts} for all of them. */ + private static List> classes(VirtualMachine vm) throws ConnectedVms.Unsupported { + try (RequestTimeout.Raised raised = RequestTimeout.raise(RequestTimeout.Knob.of(vm), SLOW_REQUEST_MS)) { + List all = vm.allClasses(); + long[] counts = vm.instanceCounts(all); + List> out = new ArrayList<>(); + for (int i = 0; i < counts.length; i++) { + if (counts[i] > 0) out.add(Values.json("name", all.get(i).name(), "instances", counts[i])); + } + return out; + } + } + + /** One that was collected before its preview is left out. */ + private static List> instances(VirtualMachine vm, Handles handles, String className, int limit) + throws Failure { + List types = vm.classesByName(className); + if (types.isEmpty()) throw new Failure("classNotLoaded", className); + int wanted = Math.min(limit, MAX_INSTANCES); + List objects = new ArrayList<>(); + for (ReferenceType type : types) { + int remaining = wanted - objects.size(); + // JDI reads a maximum of 0 as "all of them". + if (remaining <= 0) break; + List found = type.instances(remaining); + objects.addAll(found.subList(0, Math.min(remaining, found.size()))); + } + List> out = new ArrayList<>(objects.size()); + for (ObjectReference object : objects) { + try { + out.add(preview(handles, object)); + } catch (ObjectCollectedException e) { + // gone between the listing and its preview + } + } + return out; + } + + /** The fields, or {@code "[i]"} for the first {@link #ARRAY_PREVIEW} elements of an array. */ + private static List> fields(Handles handles, ObjectReference object) { + List slots = object instanceof ArrayReference array + ? Values.elements(array, Math.min(array.length(), ARRAY_PREVIEW)) + : Values.instanceFields(object); + List> out = new ArrayList<>(slots.size()); + for (Values.Slot slot : slots) out.add(Values.json("name", slot.name(), "value", preview(handles, slot.value()))); + return out; + } + + /** With the field names or array slots that point at {@code target}; a collected referrer is left out. */ + private static List> referrers(VirtualMachine vm, Handles handles, ObjectReference target, int limit) + throws ConnectedVms.Unsupported { + int wanted = Math.min(limit, MAX_REFERRERS); + // JDI reads a maximum of 0 as "all of them". + if (wanted <= 0) return List.of(); + try (RequestTimeout.Raised raised = RequestTimeout.raise(RequestTimeout.Knob.of(vm), SLOW_REQUEST_MS)) { + List found = target.referringObjects(wanted); + List> out = new ArrayList<>(); + for (ObjectReference referrer : found.subList(0, Math.min(wanted, found.size()))) { + try { + Map value = preview(handles, referrer); + List slots = referrer instanceof ArrayReference array + ? (array.length() <= REFERRER_ARRAY_SCAN ? Values.elements(array, array.length()) : List.of()) + : Values.instanceFields(referrer); + List via = slots.stream().filter(s -> target.equals(s.value())).map(Values.Slot::name).toList(); + out.add(Values.json("value", value, "via", via)); + } catch (ObjectCollectedException e) { + // gone before it could be read + } + } + return out; + } + } + + private static Map preview(Handles handles, com.sun.jdi.Value value) { + return Values.preview(value, object -> { + long id = object.uniqueID(); + handles.put(id, object); + return id; + }); + } + + private static Number number(Object argument) { + return (Number) argument; + } + + private static Map ok(Object payload) { + return Values.json("ok", payload); + } + + private static Map error(String code, String detail) { + return Values.json("error", code, "detail", detail); + } +} diff --git a/java/jvm-inspect/src/ai/openidea/jvminspect/RequestTimeout.java b/java/jvm-inspect/src/ai/openidea/jvminspect/RequestTimeout.java new file mode 100644 index 000000000..e390253a9 --- /dev/null +++ b/java/jvm-inspect/src/ai/openidea/jvminspect/RequestTimeout.java @@ -0,0 +1,53 @@ +package ai.openidea.jvminspect; + +import com.sun.jdi.VirtualMachine; + +/** + * Raises Eclipse JDI's reply timeout for a slow command. The timeout is per VM, so java-debug's own + * requests that wait behind the command get it too, and do not fail either. + */ +final class RequestTimeout { + interface Knob { + int get(); + + void set(int ms); + + static Knob of(VirtualMachine vm) throws ConnectedVms.Unsupported { + if (!(vm instanceof org.eclipse.jdi.VirtualMachine eclipse)) { + throw new ConnectedVms.Unsupported(vm.getClass().getName() + " is not an Eclipse JDI VM"); + } + return new Knob() { + @Override + public int get() { + return eclipse.getRequestTimeout(); + } + + @Override + public void set(int ms) { + eclipse.setRequestTimeout(ms); + } + }; + } + } + + interface Raised extends AutoCloseable { + @Override + void close(); + } + + private RequestTimeout() {} + + /** + * Sets {@code max(current, ms)}. Closing restores the old value only if the timeout is still the + * raised one: java-debug sets it on every VM when its timeout setting changes. + */ + static Raised raise(Knob knob, int ms) { + int old = knob.get(); + int raised = Math.max(old, ms); + if (raised == old) return () -> {}; + knob.set(raised); + return () -> { + if (knob.get() == raised) knob.set(old); + }; + } +} diff --git a/java/jvm-inspect/src/ai/openidea/jvminspect/Values.java b/java/jvm-inspect/src/ai/openidea/jvminspect/Values.java new file mode 100644 index 000000000..eee8dced5 --- /dev/null +++ b/java/jvm-inspect/src/ai/openidea/jvminspect/Values.java @@ -0,0 +1,139 @@ +package ai.openidea.jvminspect; + +import com.sun.jdi.ArrayReference; +import com.sun.jdi.BooleanValue; +import com.sun.jdi.ByteValue; +import com.sun.jdi.CharValue; +import com.sun.jdi.ClassType; +import com.sun.jdi.DoubleValue; +import com.sun.jdi.Field; +import com.sun.jdi.FloatValue; +import com.sun.jdi.IntegerValue; +import com.sun.jdi.LongValue; +import com.sun.jdi.ObjectReference; +import com.sun.jdi.PrimitiveValue; +import com.sun.jdi.ReferenceType; +import com.sun.jdi.ShortValue; +import com.sun.jdi.StringReference; +import com.sun.jdi.Value; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; +import java.util.function.ToLongFunction; + +/** + * One-line previews in the JSON shape of {@code ide_jvm_inspect::JvmValue}, by the rules of the + * JDWP link's preview: they only read, never invoke ({@code toString} or otherwise). + */ +final class Values { + static final int STRING_PREVIEW_CHARS = 1000; + private static final String STRING = "java.lang.String"; + private static final Set BOXED = Set.of("java.lang.Integer", "java.lang.Long", "java.lang.Short", + "java.lang.Byte", "java.lang.Character", "java.lang.Boolean", "java.lang.Float", "java.lang.Double"); + + /** {@code text} is null when {@code length} exceeds {@link #STRING_PREVIEW_CHARS}. */ + record StringPreview(int length, String text) {} + + /** A field by name, or an array slot as {@code "[i]"}; {@code value} is null for a null reference. */ + record Slot(String name, Value value) {} + + private Values() {} + + /** + * @param arrayLength length of {@code String.value} + * @param coder {@code String.coder} (0 = Latin-1, 1 = UTF-16); null on JDK 8, one char per element + * @param text read only when the string is short + */ + static StringPreview stringPreview(int arrayLength, Integer coder, Supplier text) { + int length = coder == null ? arrayLength : arrayLength >>> Math.min(coder & 0xff, 31); + return new StringPreview(length, length <= STRING_PREVIEW_CHARS ? text.get() : null); + } + + /** @param handle registers an object the preview hands out and returns its id */ + static Map preview(Value value, ToLongFunction handle) { + if (value == null) return json("kind", "null"); + if (value instanceof PrimitiveValue p) return json("kind", "primitive", "typeName", p.type().name(), "text", text(p)); + ObjectReference object = (ObjectReference) value; + long id = handle.applyAsLong(object); + String type = object.referenceType().name(); + if (object instanceof ArrayReference array) return json("kind", "array", "id", id, "typeName", type, "length", array.length()); + if (type.equals(STRING)) { + StringPreview s = string(object); + return json("kind", "string", "id", id, "text", s.text(), "length", s.length()); + } + if (BOXED.contains(type)) return json("kind", "boxed", "id", id, "typeName", type, "text", boxedText(object)); + return json("kind", "object", "id", id, "typeName", type); + } + + /** Every instance field, the object's own class first, then each superclass; static fields are skipped. */ + static List instanceFields(ObjectReference object) { + List fields = new ArrayList<>(); + for (ReferenceType t = object.referenceType(); t != null; t = t instanceof ClassType c ? c.superclass() : null) { + for (Field f : t.fields()) { + if (!f.isStatic()) fields.add(f); + } + } + if (fields.isEmpty()) return List.of(); + Map values = object.getValues(fields); + return fields.stream().map(f -> new Slot(f.name(), values.get(f))).toList(); + } + + /** The first {@code count} elements, named {@code "[i]"}. */ + static List elements(ArrayReference array, int count) { + if (count == 0) return List.of(); + List values = array.getValues(0, count); + List out = new ArrayList<>(values.size()); + for (int i = 0; i < values.size(); i++) out.add(new Slot("[" + i + "]", values.get(i))); + return out; + } + + static Map json(Object... keysAndValues) { + Map m = new LinkedHashMap<>(); + for (int i = 0; i < keysAndValues.length; i += 2) m.put((String) keysAndValues[i], keysAndValues[i + 1]); + return m; + } + + /** Reads the length of the backing array, and the text only when it is short. */ + private static StringPreview string(ObjectReference object) { + ClassType type = (ClassType) object.referenceType(); + Field value = instanceField(type, "value"); + if (value == null) throw new IllegalStateException(STRING + " has no value field"); + Field coder = instanceField(type, "coder"); + Map read = object.getValues(coder == null ? List.of(value) : List.of(value, coder)); + int arrayLength = read.get(value) instanceof ArrayReference array ? array.length() : 0; + Integer c = coder == null ? null : (int) ((ByteValue) read.get(coder)).value(); + return stringPreview(arrayLength, c, () -> ((StringReference) object).value()); + } + + private static String boxedText(ObjectReference object) { + Field value = instanceField((ClassType) object.referenceType(), "value"); + if (value == null) throw new IllegalStateException("a boxed type has no value field"); + return text((PrimitiveValue) object.getValue(value)); + } + + /** Declared in {@code type} itself, not inherited. */ + private static Field instanceField(ReferenceType type, String name) { + for (Field f : type.fields()) { + if (f.name().equals(name) && !f.isStatic()) return f; + } + return null; + } + + /** As Java prints it; a lone surrogate becomes U+FFFD like the JDWP link's preview. */ + private static String text(PrimitiveValue p) { + return switch (p) { + case BooleanValue v -> Boolean.toString(v.value()); + case ByteValue v -> Byte.toString(v.value()); + case CharValue v -> Character.isSurrogate(v.value()) ? "�" :String.valueOf(v.value()); + case ShortValue v -> Short.toString(v.value()); + case IntegerValue v -> Integer.toString(v.value()); + case LongValue v -> Long.toString(v.value()); + case FloatValue v -> Float.toString(v.value()); + case DoubleValue v -> Double.toString(v.value()); + default -> p.toString(); + }; + } +} diff --git a/java/jvm-inspect/src/ai/openidea/jvminspect/VmSelector.java b/java/jvm-inspect/src/ai/openidea/jvminspect/VmSelector.java new file mode 100644 index 000000000..bb9deb28c --- /dev/null +++ b/java/jvm-inspect/src/ai/openidea/jvminspect/VmSelector.java @@ -0,0 +1,67 @@ +package ai.openidea.jvminspect; + +import com.sun.jdi.VirtualMachine; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Finds the VM of one debug session. Thread lists cannot tell two attach sessions apart: two JVMs + * running the same program have the same thread ids and names. + */ +final class VmSelector { + /** The JSON {@code {"pid", "host", "port", "generation"}} the client sends first; pid for launch, host and port for attach. */ + record Target(Long pid, String host, Integer port, long generation) { + static Target of(Object json) { + Map m = (Map) json; + Number pid = (Number) m.get("pid"); + Number port = (Number) m.get("port"); + return new Target(pid == null ? null : pid.longValue(), (String) m.get("host"), + port == null ? null : port.intValue(), ((Number) m.get("generation")).longValue()); + } + } + + /** {@code vm} is null in tests. */ + record VmInfo(VirtualMachine vm, Long pid, String remoteHost, Integer remotePort, Integer localPort) {} + + private VmSelector() {} + + /** Empty unless exactly one VM matches. */ + static Optional select(List vms, Target target) { + List found; + if (target.pid() != null) { + found = vms.stream().filter(v -> target.pid().equals(v.pid())).toList(); + } else if (target.port() != null) { + found = vms.stream().filter(v -> target.port().equals(v.remotePort())).toList(); + if (found.size() > 1 && target.host() != null) { + Set addresses = resolve(target.host()); + if (addresses != null) found = found.stream().filter(v -> isOneOf(v.remoteHost(), addresses)).toList(); + } + } else { + found = List.of(); + } + return found.size() == 1 ? Optional.of(found.get(0)) : Optional.empty(); + } + + /** {@code null} when the name does not resolve, so the port alone decides. */ + private static Set resolve(String host) { + try { + return Set.of(InetAddress.getAllByName(host)); + } catch (UnknownHostException | SecurityException e) { + return null; + } + } + + /** The socket's remote address is an IP literal, which parses without a lookup. */ + private static boolean isOneOf(String address, Set addresses) { + if (address == null) return false; + try { + return addresses.contains(InetAddress.getByName(address)); + } catch (UnknownHostException e) { + return false; + } + } +} diff --git a/java/jvm-inspect/tests/SelfTest.java b/java/jvm-inspect/tests/SelfTest.java new file mode 100644 index 000000000..2218f9ec7 --- /dev/null +++ b/java/jvm-inspect/tests/SelfTest.java @@ -0,0 +1,164 @@ +package ai.openidea.jvminspect; + +import ai.openidea.jvminspect.VmSelector.Target; +import ai.openidea.jvminspect.VmSelector.VmInfo; +import java.util.List; + +/** The JDI-free logic of the bundle; run with {@code build.py --test}. */ +public final class SelfTest { + public static void main(String[] args) throws Exception { + selector_matches_launch_pid(); + selector_matches_attach_port_not_threads(); + selector_filters_same_port_by_host(); + selector_reports_vm_not_found(); + connected_vms_missing_field_is_unsupported(); + request_timeout_raised_and_restored(); + handles_cleared_on_new_generation(); + handles_cleared_when_full(); + string_preview_rule(); + } + + /** Stands in for java-debug's provider: its manager has no {@code connectedVMs}. */ + public static final class FakeProvider { + public Object getVirtualMachineManager() { + return new Object(); + } + } + + static void selector_matches_launch_pid() { + List vms = List.of(vm(10L, "127.0.0.1", 50001), vm(11L, "127.0.0.1", 50002)); + check(VmSelector.select(vms, launch(11)).orElseThrow() == vms.get(1), "pid 11 selects the second VM"); + pass("selector_matches_launch_pid"); + } + + static void selector_matches_attach_port_not_threads() { + // G1: two JVMs running the same program have identical thread lists; only the port tells them apart. + List vms = List.of(vm(null, "127.0.0.1", 59246), vm(null, "127.0.0.1", 59247)); + check(VmSelector.select(vms, attach("127.0.0.1", 59247)).orElseThrow() == vms.get(1), "port 59247 selects the second VM"); + pass("selector_matches_attach_port_not_threads"); + } + + static void selector_filters_same_port_by_host() { + List vms = List.of(vm(null, "127.0.0.1", 5005), vm(null, "10.0.0.5", 5005)); + check(VmSelector.select(vms, attach("localhost", 5005)).orElseThrow() == vms.get(0), "localhost selects 127.0.0.1"); + pass("selector_filters_same_port_by_host"); + } + + static void selector_reports_vm_not_found() { + List distinct = List.of(vm(10L, "127.0.0.1", 5005), vm(null, "127.0.0.1", 5006)); + check(VmSelector.select(distinct, launch(12)).isEmpty(), "unknown pid"); + check(VmSelector.select(distinct, attach("127.0.0.1", 5007)).isEmpty(), "unknown port"); + check(VmSelector.select(distinct, new Target(null, null, null, 1)).isEmpty(), "no pid and no port"); + List twins = List.of(vm(null, "127.0.0.1", 5005), vm(null, "127.0.0.1", 5005)); + check(VmSelector.select(twins, attach("localhost", 5005)).isEmpty(), "same port and host is ambiguous"); + pass("selector_reports_vm_not_found"); + } + + static void connected_vms_missing_field_is_unsupported() { + try { + ConnectedVms.read(name -> FakeProvider.class); + throw new AssertionError("a manager without connectedVMs must be unsupported"); + } catch (ConnectedVms.Unsupported e) { + check(e.getMessage().contains("connectedVMs"), "detail names the field: " + e.getMessage()); + } + try { + ConnectedVms.read(name -> null); + throw new AssertionError("a missing java-debug must be unsupported"); + } catch (ConnectedVms.Unsupported e) { + check(e.getMessage().contains(ConnectedVms.PROVIDER), "detail names the class: " + e.getMessage()); + } + pass("connected_vms_missing_field_is_unsupported"); + } + + static void request_timeout_raised_and_restored() { + int[] value = {3000}; + RequestTimeout.Knob knob = new RequestTimeout.Knob() { + @Override + public int get() { + return value[0]; + } + + @Override + public void set(int ms) { + value[0] = ms; + } + }; + try (RequestTimeout.Raised raised = RequestTimeout.raise(knob, 60_000)) { + check(value[0] == 60_000, "raised to 60000, was " + value[0]); + } + check(value[0] == 3000, "restored to 3000, was " + value[0]); + try (RequestTimeout.Raised raised = RequestTimeout.raise(knob, 60_000)) { + value[0] = 5000; // java-debug applied a changed setting meanwhile + } + check(value[0] == 5000, "a value changed meanwhile is kept, was " + value[0]); + value[0] = 90_000; + try (RequestTimeout.Raised raised = RequestTimeout.raise(knob, 60_000)) { + check(value[0] == 90_000, "a larger timeout is not lowered, was " + value[0]); + } + check(value[0] == 90_000, "a larger timeout stays, was " + value[0]); + pass("request_timeout_raised_and_restored"); + } + + static void handles_cleared_on_new_generation() throws Handles.Stale { + Handles handles = new Handles<>(); + handles.begin(1); + handles.put(7, "seven"); + check("seven".equals(handles.get(7)), "id 7 is known in generation 1"); + handles.begin(2); + expectStale(handles, 7, "id 7 from generation 1 in generation 2"); + pass("handles_cleared_on_new_generation"); + } + + static void handles_cleared_when_full() throws Handles.Stale { + Handles handles = new Handles<>(); + handles.begin(1); + for (long id = 0; id <= Handles.MAX; id++) handles.put(id, "o"); + check("o".equals(handles.get(0)), "a command's own objects stay known while it runs"); + handles.begin(1); + expectStale(handles, 0, "a table over the limit is cleared at the next command"); + pass("handles_cleared_when_full"); + } + + static void string_preview_rule() { + Values.StringPreview shortText = Values.stringPreview(2000, 1, () -> "text"); + check(shortText.length() == 1000 && "text".equals(shortText.text()), "2000 UTF-16 bytes: 1000 chars, read: " + shortText); + Values.StringPreview longText = Values.stringPreview(2002, 1, () -> { + throw new AssertionError("the text of a long string must not be read"); + }); + check(longText.length() == 1001 && longText.text() == null, "2002 UTF-16 bytes: 1001 chars, not read: " + longText); + Values.StringPreview latin1 = Values.stringPreview(1000, 0, () -> "latin"); + check(latin1.length() == 1000 && "latin".equals(latin1.text()), "Latin-1: one byte per char: " + latin1); + Values.StringPreview jdk8 = Values.stringPreview(5, null, () -> "chars"); + check(jdk8.length() == 5 && "chars".equals(jdk8.text()), "no coder (JDK 8): one char per element: " + jdk8); + pass("string_preview_rule"); + } + + private static VmInfo vm(Long pid, String host, Integer port) { + return new VmInfo(null, pid, host, port, null); + } + + private static Target launch(long pid) { + return new Target(pid, null, null, 1); + } + + private static Target attach(String host, int port) { + return new Target(null, host, port, 1); + } + + private static void expectStale(Handles handles, long id, String what) { + try { + handles.get(id); + throw new AssertionError(what + ": expected stale"); + } catch (Handles.Stale expected) { + // the id belongs to an earlier table + } + } + + private static void check(boolean ok, String what) { + if (!ok) throw new AssertionError(what); + } + + private static void pass(String name) { + System.out.println("PASS " + name); + } +} diff --git a/java/jvm-inspect/tests/inspect.py b/java/jvm-inspect/tests/inspect.py new file mode 100644 index 000000000..4ee84dcd0 --- /dev/null +++ b/java/jvm-inspect/tests/inspect.py @@ -0,0 +1,446 @@ +"""Drive the bundle inside a real JDT LS with java-debug. + +Usage: OPENIDEA_TEST_JAVA=/bin/java[:/bin/java...] \ + python3 inspect.py /path/to/jdtls /path/to/com.microsoft.java.debug.plugin-.jar [--heap N] + +JDT LS runs on the `java` on PATH (JDK 25 for 1.63.0). OPENIDEA_TEST_JAVA lists the debuggee JDKs: +the attach checks use the first; `classes` runs on the JDK 11 one; `--heap N` counts N objects on the +JDK 21 one while a java-debug request waits behind the count. Prints OK/FAIL/SKIP per check. +""" +import json +import os +import pathlib +import platform +import re +import shutil +import signal +import socket +import subprocess +import sys +import tempfile +import threading +import time + +COMMANDS = ['openidea.jvm.vms', 'openidea.jvm.classes', 'openidea.jvm.instances', + 'openidea.jvm.fields', 'openidea.jvm.referrers'] +BREAKPOINT_LINE = 6 +HEAP = '''public class Heap { + static final class Live { final int v; Live(int v) { this.v = v; } } + static Object[] keep; + static volatile int tick; + static void pause() { + tick++; + } + public static void main(String[] a) throws Exception { + int n = Integer.parseInt(a[0]); + keep = new Object[n]; + for (int i = 0; i < n; i++) keep[i] = new Live(i); + System.out.println("READY " + n); + while (true) { pause(); Thread.sleep(100); } + } +} +''' + +args = [a for a in sys.argv[1:]] +heap = None +if '--heap' in args: + i = args.index('--heap') + heap = int(args[i + 1]) + del args[i:i + 2] +home, java_debug = (pathlib.Path(a).resolve() for a in args[:2]) +bundle = pathlib.Path(__file__).resolve().parents[1] / 'openidea-jvm-inspect.jar' +javas = [p for p in os.environ.get('OPENIDEA_TEST_JAVA', '').split(os.pathsep) if p] +if not javas: + sys.exit('set OPENIDEA_TEST_JAVA to one or more /bin/java, separated by ' + os.pathsep) +failures = [] +T0 = time.time() + + +def report(status, name, detail=''): + print(f'[{time.time() - T0:6.1f}s] {status} {name}' + (f': {detail}' if detail else ''), flush=True) + if status == 'FAIL': + failures.append(name) + + +def check(name, ok, detail=''): + report('OK' if ok else 'FAIL', name, '' if ok else detail) + return ok + + +def major(java): + out = subprocess.run([java, '-version'], capture_output=True, text=True).stderr + version = re.search(r'version "([^"]+)"', out).group(1) + parts = version.split('.') + return int(parts[1]) if parts[0] == '1' else int(re.match(r'\d+', parts[0]).group()) + + +class Rpc: + """Content-Length framed JSON; responses are matched by id so two threads can wait at once.""" + + def __init__(self, read, write): + self.read, self.write = read, write + self.lock = threading.Lock() + self.cv = threading.Condition() + self.next_id = 0 + self.responses = {} + self.events = [] + threading.Thread(target=self.pump, daemon=True).start() + + def pump(self): + while True: + header = b'' + while not header.endswith(b'\r\n\r\n'): + c = self.read(1) + if not c: + return + header += c + length = int(re.search(rb'Content-Length: (\d+)', header).group(1)) + body = b'' + while len(body) < length: + chunk = self.read(length - len(body)) + if not chunk: + return + body += chunk + self.receive(json.loads(body)) + + def send(self, message): + data = json.dumps(message).encode() + with self.lock: + self.write(b'Content-Length: %d\r\n\r\n' % len(data) + data) + + def allocate(self): + with self.lock: + self.next_id += 1 + return self.next_id + + def store(self, key, message): + with self.cv: + self.responses[key] = message + self.cv.notify_all() + + def wait_for(self, key, timeout, what): + end = time.time() + timeout + with self.cv: + while key not in self.responses: + if not self.cv.wait(max(0.01, end - time.time())) and time.time() > end: + raise TimeoutError(what) + return self.responses.pop(key) + + def wait_event(self, predicate, timeout, what): + end = time.time() + timeout + with self.cv: + while True: + for event in self.events: + if predicate(event): + self.events.remove(event) + return event + if not self.cv.wait(max(0.01, end - time.time())) and time.time() > end: + raise TimeoutError(what) + + +class Lsp(Rpc): + def receive(self, m): + if 'method' in m and 'id' in m: + self.send({'jsonrpc': '2.0', 'id': m['id'], 'result': None}) + elif 'method' in m: + with self.cv: + self.events.append(m) + self.cv.notify_all() + else: + self.store(m['id'], m) + + def request(self, method, params, timeout=120): + rid = self.allocate() + self.send({'jsonrpc': '2.0', 'id': rid, 'method': method, 'params': params}) + return self.wait_for(rid, timeout, method) + + def notify(self, method, params): + self.send({'jsonrpc': '2.0', 'method': method, 'params': params}) + + def command(self, command, *arguments, timeout=120): + r = self.request('workspace/executeCommand', {'command': command, 'arguments': list(arguments)}, timeout) + if 'error' in r: + raise RuntimeError(f'{command}: {r["error"]}') + return r['result'] + + def inspect(self, name, *arguments, timeout=180): + return self.command('openidea.jvm.' + name, *arguments, timeout=timeout) + + +class Dap(Rpc): + def receive(self, m): + if m['type'] == 'response': + self.store(m['request_seq'], m) + elif m['type'] == 'event': + with self.cv: + self.events.append(m) + self.cv.notify_all() + elif m['type'] == 'request': # runInTerminal and the like: refuse + self.send({'seq': 0, 'type': 'response', 'request_seq': m['seq'], 'command': m['command'], 'success': False}) + + def request(self, command, arguments=None, timeout=60): + rid = self.allocate() + self.send({'seq': rid, 'type': 'request', 'command': command, 'arguments': arguments or {}}) + return self.wait_for(rid, timeout, command) + + def start(self, command, arguments): + rid = self.allocate() + self.send({'seq': rid, 'type': 'request', 'command': command, 'arguments': arguments}) + + +# resolved: on macOS the temp dir is under the /var symlink, and JDT LS matches canonical URIs +work = pathlib.Path(tempfile.mkdtemp(prefix='openidea-jvm-inspect-')).resolve() +project = work / 'project' +(project / 'src').mkdir(parents=True) +(project / 'src' / 'Heap.java').write_text(HEAP) +source = str(project / 'src' / 'Heap.java') +classes = work / 'classes' +subprocess.run(['javac', '-g', '--release', '8', '-Xlint:-options', '-d', str(classes), source], check=True) +config = work / 'config' +config.mkdir() +system, machine = platform.system(), platform.machine().lower() +config_name = {'Windows': 'config_win', 'Darwin': 'config_mac'}.get(system, 'config_linux') +names = ([config_name + '_arm'] if machine in ('arm64', 'aarch64') else []) + [config_name] +(config / 'config.ini').write_bytes(next(home / n / 'config.ini' for n in names if (home / n / 'config.ini').is_file()).read_bytes()) + +jdtls = subprocess.Popen(['java', '-Declipse.application=org.eclipse.jdt.ls.core.id1', '-Dosgi.bundles.defaultStartLevel=4', + '-Declipse.product=org.eclipse.jdt.ls.core.product', '-Xmx1G', '--add-modules=ALL-SYSTEM', + '--add-opens', 'java.base/java.util=ALL-UNNAMED', '--add-opens', 'java.base/java.lang=ALL-UNNAMED', + '-jar', str(next((home / 'plugins').glob('org.eclipse.equinox.launcher_*.jar'))), + '-configuration', str(config), '-data', str(work / 'data')], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=(work / 'jdtls.err').open('w')) + + +def write_jdtls(data): + jdtls.stdin.write(data) + jdtls.stdin.flush() + + +lsp = Lsp(jdtls.stdout.read, write_jdtls) +jvms = [] # debuggees started here +launched = [] # pids of debuggees java-debug started +project_name = None +modules, classpaths = [], [] + + +def free_port(): + s = socket.socket() + s.bind(('127.0.0.1', 0)) + port = s.getsockname()[1] + s.close() + return port + + +def start_jvm(java, n, xmx='256m'): + port = free_port() + p = subprocess.Popen([java, f'-Xmx{xmx}', f'-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=127.0.0.1:{port}', + '-cp', str(classes), 'Heap', str(n)], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + jvms.append(p) + for line in p.stdout: + if line.startswith('READY'): + break + threading.Thread(target=lambda: [None for _ in p.stdout], daemon=True).start() + return p, port + + +def session(): + port = lsp.command('vscode.java.startDebugSession', timeout=60) + sock = socket.create_connection(('127.0.0.1', port)) + reader = sock.makefile('rb', buffering=0) + d = Dap(reader.read, sock.sendall) + d.request('initialize', {'clientID': 'openidea-jvm-inspect', 'adapterID': 'java', 'linesStartAt1': True, + 'columnsStartAt1': True, 'pathFormat': 'path'}) + return d + + +def stop_at_breakpoint(d, command, arguments): + d.start(command, arguments) + d.wait_event(lambda e: e['event'] == 'initialized', 60, 'initialized') + d.request('setBreakpoints', {'source': {'path': source}, 'breakpoints': [{'line': BREAKPOINT_LINE}]}) + d.request('configurationDone') + stopped = d.wait_event(lambda e: e['event'] == 'stopped', 120, 'stopped') + # Later resumes must not stop again. + d.request('setBreakpoints', {'source': {'path': source}, 'breakpoints': []}) + return stopped['body']['threadId'] + + +def attach(port): + d = session() + thread = stop_at_breakpoint(d, 'attach', {'hostName': '127.0.0.1', 'port': port, 'timeout': 30000, 'projectName': project_name}) + return d, thread + + +def launch(n): + d = session() + thread = stop_at_breakpoint(d, 'launch', {'mainClass': 'Heap', 'projectName': project_name, 'modulePaths': modules, + 'classPaths': classpaths, 'args': str(n), 'cwd': str(project), + 'console': 'internalConsole', 'shortenCommandLine': 'none'}) + pid = d.request('processId')['body']['processId'] + launched.append(pid) + return d, thread, pid + + +def end(d): + try: + d.request('disconnect', {'terminateDebuggee': True}, 20) + except Exception as e: # the JVM is killed below anyway + report('NOTE', 'disconnect failed', repr(e)) + + +def by_port(port, generation=1): + return {'pid': None, 'host': 'localhost', 'port': port, 'generation': generation} + + +def ok(reply): + if 'ok' not in reply: + raise AssertionError(f'expected ok, got {reply}') + return reply['ok'] + + +def live(target, limit=1000): + return ok(lsp.inspect('instances', target, 'Heap$Live', limit)) + + +def check_selection_and_objects(): + first = javas[0] + (_, p1), (_, p2) = start_jvm(first, 11), start_jvm(first, 13) + a1, _ = attach(p1) + a2, a2_thread = attach(p2) + l, _, pid = launch(1500) + vms = ok(lsp.inspect('vms')) + print(' vms:', json.dumps(vms), flush=True) + check('vms lists the launch by pid and both attaches by port', + sorted(v.get('remotePort') for v in vms if v.get('pid') is None) == sorted([p1, p2]) + and [v.get('pid') for v in vms].count(pid) == 1, vms) + launch_target = {'pid': pid, 'host': None, 'port': None, 'generation': 1} + n = len(live(launch_target, 2000)) + check('launch selected by pid: instances of Heap$Live = min(1500, 1000)', n == 1000, n) + got = (len(live(by_port(p1))), len(live(by_port(p2)))) + check('two attaches to the same program each get their VM by port: 11 and 13 instances', got == (11, 13), got) + instance = live(by_port(p1), 1)[0] + fields = ok(lsp.inspect('fields', by_port(p1), instance['id'])) + v = next((f['value'] for f in fields if f['name'] == 'v'), None) + check('fields of a Heap$Live instance include v (int)', v is not None and v['kind'] == 'primitive' and v['typeName'] == 'int', fields) + referrers = ok(lsp.inspect('referrers', by_port(p1), instance['id'], 50)) + expected = [{'value': {'kind': 'array', 'typeName': 'java.lang.Object[]', 'length': 11}, 'via': [f'[{v["text"]}]']}] + shape = [{'value': {k: r['value'].get(k) for k in ('kind', 'typeName', 'length')}, 'via': r['via']} for r in referrers] + check('referrers of an instance: the array holding it, via its index', shape == expected, referrers) + error = lsp.inspect('fields', by_port(p1, 2), instance['id']) + check('an id from an earlier stop is stale', error.get('error') == 'stale', error) + error = lsp.inspect('instances', by_port(free_port()), 'Heap$Live', 1) + check('a port no session uses is vmNotFound', error.get('error') == 'vmNotFound', error) + error = lsp.inspect('instances', by_port(p1), 'NoSuchClass', 1) + check('a class that is not loaded is classNotLoaded', error.get('error') == 'classNotLoaded', error) + a2.request('continue', {'threadId': a2_thread}) + for _ in range(50): + error = lsp.inspect('instances', by_port(p2), 'Heap$Live', 1) + if error.get('error') == 'notSuspended': + break + time.sleep(0.1) + check('a running VM is notSuspended', error.get('error') == 'notSuspended', error) + eleven = next((j for j in javas if major(j) == 11), None) + if eleven is None: + report('SKIP', 'classes on JDK 11', 'no JDK 11 in OPENIDEA_TEST_JAVA') + else: + if eleven == first: + port, extra = p1, None + else: + _, port = start_jvm(eleven, 17) + extra, _ = attach(port) + counts = ok(lsp.inspect('classes', by_port(port))) + n = {c['name']: c['instances'] for c in counts}.get('Heap$Live') + expected_n = 11 if extra is None else 17 + check(f'classes on JDK 11 while stopped: Heap$Live = {expected_n}, no zero rows', + n == expected_n and all(c['instances'] > 0 for c in counts), n) + if extra: + end(extra) + for d in (a1, a2, l): + end(d) + time.sleep(1) + check('vms is empty after the sessions end', ok(lsp.inspect('vms')) == []) + + +def check_large_heap(n): + java = next((j for j in javas if major(j) == 21), None) + if java is None: + report('SKIP', f'--heap {n}', 'no JDK 21 in OPENIDEA_TEST_JAVA') + return + _, port = start_jvm(java, n, '2g') + d, thread = attach(port) + frame = d.request('stackTrace', {'threadId': thread})['body']['stackFrames'][0]['id'] + box = {} + counting = threading.Thread(target=lambda: box.update(r=lsp.inspect('classes', by_port(port), timeout=300))) + started = time.time() + counting.start() + time.sleep(0.5) + t0 = time.time() + r = d.request('evaluate', {'expression': 'Heap.keep.length', 'frameId': frame, 'context': 'repl'}, 120) + waited = time.time() - t0 + counting.join() + counted = time.time() - started + check(f'a DAP evaluate sent 0.5 s into classes succeeds (waited {waited:.1f} s; classes took {counted:.1f} s)', + r['success'] and r['body']['result'] == str(n), r) + counts = {c['name']: c['instances'] for c in ok(box['r'])} + check(f'classes counts {n} Heap$Live', counts.get('Heap$Live') == n, counts.get('Heap$Live')) + timeout = next(v['timeout'] for v in ok(lsp.inspect('vms')) if v['remotePort'] == port) + check('the request timeout is back to 3000 after classes', timeout == 3000, timeout) + instance = live(by_port(port), 1)[0] + referrers = ok(lsp.inspect('referrers', by_port(port), instance['id'], 50)) + check(f'a referrer array of {n} elements is not searched for the index', + [r['via'] for r in referrers] == [[]] and referrers[0]['value']['length'] == n, referrers) + timeout = next(v['timeout'] for v in ok(lsp.inspect('vms')) if v['remotePort'] == port) + check('the request timeout is back to 3000 after referrers', timeout == 3000, timeout) + end(d) + + +try: + uri = project.as_uri() + r = lsp.request('initialize', {'processId': os.getpid(), 'rootUri': uri, 'capabilities': {'workspace': {'executeCommand': {}}}, + 'initializationOptions': {'bundles': [str(java_debug), str(bundle)], + 'settings': {'java': {'project': {'sourcePaths': ['src']}}}}, + 'workspaceFolders': [{'uri': uri, 'name': 'project'}]}, timeout=180) + registered = r['result']['capabilities'].get('executeCommandProvider', {}).get('commands', []) + if not check('JDT LS registers the bundle commands and java-debug', all(c in registered for c in COMMANDS + ['vscode.java.startDebugSession']), + [c for c in registered if c.startswith(('openidea', 'vscode.java.start'))]): + sys.exit(1) + lsp.notify('initialized', {}) + lsp.wait_event(lambda m: m.get('method') == 'language/status' and m['params'].get('type') in ('ServiceReady', 'Started'), 240, 'ServiceReady') + lsp.notify('textDocument/didOpen', {'textDocument': {'uri': pathlib.Path(source).as_uri(), 'languageId': 'java', 'version': 1, 'text': HEAP}}) + check('vms is empty before any session', ok(lsp.inspect('vms')) == []) + for _ in range(30): + main = lsp.command('vscode.java.resolveMainClass', uri) + if main: + break + time.sleep(2) + project_name = main[0].get('projectName') + modules, classpaths = lsp.command('vscode.java.resolveClasspath', main[0]['mainClass'], project_name) + check_selection_and_objects() + if heap: + check_large_heap(heap) +except Exception as e: + report('FAIL', 'harness', repr(e)) +finally: + for p in jvms: + if p.poll() is None: + p.kill() + p.wait(10) + for pid in launched: + try: + os.kill(pid, signal.SIGKILL) + except (ProcessLookupError, PermissionError): + pass + try: + lsp.request('shutdown', None, 10) + lsp.notify('exit', None) + jdtls.wait(10) + except Exception: + pass + if jdtls.poll() is None: + jdtls.kill() + jdtls.wait(10) + if failures: + print(f'kept {work} (JDT LS stderr in jdtls.err, log in data/.metadata/.log)', flush=True) + else: + shutil.rmtree(work, ignore_errors=True) +print('FAILED: ' + ', '.join(failures) if failures else 'ALL OK', flush=True) +sys.exit(1 if failures else 0) diff --git a/smoke/debug.test.ts b/smoke/debug.test.ts index ee0083550..1dcd86d36 100644 --- a/smoke/debug.test.ts +++ b/smoke/debug.test.ts @@ -21,7 +21,7 @@ import { activate as activateBuildTools } from "@/features/build-tools"; import { useBuild } from "@/features/build-tools/store"; import { activate as activateDebugger } from "@/features/debugger"; import { addBreakpoint } from "@/features/debugger/breakpoints/store"; -import { activeSession, evaluate, useDebug } from "@/features/debugger/state"; +import { activeSession, evaluate, sessionSupports, useDebug } from "@/features/debugger/state"; import { activate as activateRun } from "@/features/run"; import { resetRuns, useRun } from "@/features/run/store"; import { activateWorkbench } from "@/workbench/activate"; @@ -45,6 +45,10 @@ interface Case { variable: string; /** An expression evaluated there (Evaluate Expression), and its value. */ evaluate: [string, RegExp]; + /** JVM languages: the live instances of a class the paused program holds (Memory tab). */ + memory?: { className: string; instances: number }; + /** JVM languages: that class's instances, a field of the first one and its referrers (Show Instances). */ + instances?: { className: string; count: number; field: string }; /** A server: the breakpoint is in this request's handler, and the session is stopped at the end. */ request?: string; } @@ -87,6 +91,8 @@ const CASES: Record = { file: "src/main/java/com/example/orders/App.java", variable: "book", evaluate: ["book.size()", /^3$/], + memory: { className: "com.example.orders.OrderBook", instances: 1 }, + instances: { className: "com.example.orders.OrderBook", count: 1, field: "orders" }, }, kotlin: { fixture: "kotlin-gradle", @@ -94,6 +100,9 @@ const CASES: Record = { file: "src/main/kotlin/com/example/library/App.kt", variable: "library", evaluate: ["library", /Library/], + // Gradle forks the JVM and the debugger attaches to it: the attach-session path. + memory: { className: "com.example.library.Book", instances: 3 }, + instances: { className: "com.example.library.Book", count: 3, field: "title" }, }, // GDB's own DAP interpreter (GDB 14+), the configuration's explicit debugger choice: // the C/C++ default stays lldb-dap. GDB 15.0.50.20240403-0ubuntu1 says `15`. @@ -282,6 +291,46 @@ async function smoke(language: string) { expect(environment.value).toBe("1"); } + if (c.memory) { + const memory = await call(commands.debugJvmMemoryClasses(s.id)); + if (memory.status === "ready") { + const row = memory.snapshot.rows.find((r) => r.name === c.memory!.className); + expect(row?.instances, `${c.memory.className} in the histogram\n${shown()}`).toBe(c.memory.instances); + } else { + // Attaching to a suspended JVM older than 17 hangs, so the backend refuses it by design. + const { reason } = memory; + const refused = reason.kind === "unsafeWhileSuspended" && reason.javaMajor > 0 && reason.javaMajor < 17; + expect(refused, `memory unavailable: ${JSON.stringify(reason)}\n${shown()}`).toBe(true); + } + } + + if (c.instances) { + const { className, count, field } = c.instances; + // The inspector comes up beside the session (jdtls bundle, Kotlin JDWP link). + await until("the JVM inspector", () => { + const session = useDebug.getState().sessions[s.id] ?? null; + const backend = session?.jvmBackend; + if (backend && backend.kind !== "ready") throw new Error(`JVM inspector ${JSON.stringify(backend)}\n${shown()}`); + return sessionSupports("jvmInstances", session); + }); + const listed = await call(commands.debugJvmMemoryInstances(s.id, className, 1000)); + expect(listed.status, `instances: ${JSON.stringify(listed)}\n${shown()}`).toBe("ready"); + if (listed.status !== "ready") return; + expect(listed.instances, `${className} instances`).toHaveLength(count); + const first = listed.instances[0]!; + if (!("id" in first)) throw new Error(`${className} instance without an id: ${JSON.stringify(first)}`); + const object = { generation: listed.generation, id: first.id }; + const fields = await call(commands.debugJvmMemoryFields(s.id, object)); + if (fields.status !== "ready") throw new Error(`fields: ${JSON.stringify(fields)}\n${shown()}`); + const held = fields.fields.find((f) => f.name === field)?.value; + if (!held || !("id" in held)) throw new Error(`${className}.${field}: ${JSON.stringify(fields.fields)}`); + const heldRef = { generation: listed.generation, id: held.id }; + const referrers = await call(commands.debugJvmMemoryReferrers(s.id, heldRef, 50)); + if (referrers.status !== "ready") throw new Error(`referrers: ${JSON.stringify(referrers)}\n${shown()}`); + const back = referrers.referrers.find((r) => "id" in r.value && r.value.id === first.id); + expect(back?.via, `referrers of ${className}.${field}: ${JSON.stringify(referrers)}`).toContain(field); + } + // Python 3.12 reports a conditional expression's line twice: Step Over may stop on it // (and its breakpoint) again. for (let step = 0; step < 3 && paused()!.frames[0]!.line === line; step++) { diff --git a/src-tauri/src/commands/debug.rs b/src-tauri/src/commands/debug.rs index c99fb0656..8afdf5c74 100644 --- a/src-tauri/src/commands/debug.rs +++ b/src-tauri/src/commands/debug.rs @@ -316,3 +316,52 @@ pub async fn debug_listen_for_jvm( pub async fn debug_redefine_classes(state: State<'_, AppState>, session: DebugSessionId) -> IdeResult { ide_host::ops::debug::debug_redefine_classes(&state.host, "", session).await } + +/// The debuggee JVM's loaded classes (instances and shallow bytes), or why they cannot be read. +#[tauri::command] +#[specta::specta] +pub async fn debug_jvm_memory_classes( + window: Window, + state: State<'_, AppState>, + session: DebugSessionId, +) -> IdeResult { + ide_host::ops::debug::debug_jvm_memory_classes(&state.host, window.label(), session).await +} + +/// Up to `limit` live instances of the class in the stopped JVM. +#[tauri::command] +#[specta::specta] +pub async fn debug_jvm_memory_instances( + window: Window, + state: State<'_, AppState>, + session: DebugSessionId, + class_name: String, + limit: u32, +) -> IdeResult { + ide_host::ops::debug::debug_jvm_memory_instances(&state.host, window.label(), session, class_name, limit).await +} + +/// The fields (or array elements) of an instance in the stopped JVM. +#[tauri::command] +#[specta::specta] +pub async fn debug_jvm_memory_fields( + window: Window, + state: State<'_, AppState>, + session: DebugSessionId, + object: JvmObjectRef, +) -> IdeResult { + ide_host::ops::debug::debug_jvm_memory_fields(&state.host, window.label(), session, object).await +} + +/// The objects that hold a reference to `object`; `limit` 0 selects the default. +#[tauri::command] +#[specta::specta] +pub async fn debug_jvm_memory_referrers( + window: Window, + state: State<'_, AppState>, + session: DebugSessionId, + object: JvmObjectRef, + limit: u32, +) -> IdeResult { + ide_host::ops::debug::debug_jvm_memory_referrers(&state.host, window.label(), session, object, limit).await +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 12ad98c62..81b622900 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -505,6 +505,10 @@ pub fn builder() -> tauri_specta::Builder { debug::debug_free_port, debug::debug_listen_for_jvm, debug::debug_redefine_classes, + debug::debug_jvm_memory_classes, + debug::debug_jvm_memory_instances, + debug::debug_jvm_memory_fields, + debug::debug_jvm_memory_referrers, coverage::coverage_prepare, coverage::coverage_collect, coverage::coverage_import, diff --git a/src/bindings.ts b/src/bindings.ts index 2df37baa8..ee9c24d61 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1121,6 +1121,14 @@ export const commands = { debugListenForJvm: (request: RequestId, host: string, port: number) => typedError(__TAURI_INVOKE("debug_listen_for_jvm", { request, host, port })), /** HotSwap (Reload Changed Classes) in a java-debug session. */ debugRedefineClasses: (session: DebugSessionId) => typedError(__TAURI_INVOKE("debug_redefine_classes", { session })), + /** The debuggee JVM's loaded classes (instances and shallow bytes), or why they cannot be read. */ + debugJvmMemoryClasses: (session: DebugSessionId) => typedError(__TAURI_INVOKE("debug_jvm_memory_classes", { session })), + /** Up to `limit` live instances of the class in the stopped JVM. */ + debugJvmMemoryInstances: (session: DebugSessionId, className: string, limit: number) => typedError(__TAURI_INVOKE("debug_jvm_memory_instances", { session, className, limit })), + /** The fields (or array elements) of an instance in the stopped JVM. */ + debugJvmMemoryFields: (session: DebugSessionId, object: JvmObjectRef) => typedError(__TAURI_INVOKE("debug_jvm_memory_fields", { session, object })), + /** The objects that hold a reference to `object`; `limit` 0 selects the default. */ + debugJvmMemoryReferrers: (session: DebugSessionId, object: JvmObjectRef, limit: number) => typedError(__TAURI_INVOKE("debug_jvm_memory_referrers", { session, object, limit })), /** * How to run `config` with coverage: the command to launch and where its report will be, * or which tool is missing. @@ -3510,6 +3518,16 @@ export type DebugCapabilities = { logPoints: boolean, /** HotSwap ("Reload Changed Classes"): java-debug's `redefineClasses` request. */ hotCodeReplace: boolean, + /** + * The session debugs a JVM, so it has a Memory view; [`DebugManager::jvm_memory_classes`] + * says why when it has no data (platform, remote JVM, ...). + */ + jvmMemory: boolean, + /** + * The session has a JVM inspector: instances, fields and referrers while suspended. Can + * change during the session ([`ide_core::event::DebugEvent::Capabilities`]). + */ + jvmInstances: boolean, }; /** A completion the adapter offers for an expression (DAP `completions`). */ @@ -3538,7 +3556,9 @@ export type DebugEvent = * java-debug's `hotcodereplace` event: `change_type` is `BUILD_COMPLETE` (classes * changed and can be reloaded), `STARTING`, `END`, `WARNING` or `ERROR`. */ -{ kind: "hotCodeReplace"; changeType: string; message: string }; +{ kind: "hotCodeReplace"; changeType: string; message: string } | +/** The session's capabilities changed after it started: fetch them again. */ +{ kind: "capabilities" } | { kind: "jvmBackend"; state: JvmBackendState }; /** A debug adapter session. */ export type DebugSessionId = number; @@ -5632,19 +5652,116 @@ export type JetBrainsSettings = { files: { [key in string]: string }, }; +/** The backend that reads a debugged JVM's objects (instances, fields, referrers). */ +export type JvmBackendState = { kind: "ready" } | +/** Turned off for the rest of the session: the debug adapter lacks what it needs. */ +{ kind: "unsupported"; detail: string } | +/** The Kotlin JDWP link only forwards now; inspecting stops. */ +{ kind: "degraded"; detail: string } | { kind: "closed" }; + +export type JvmClassRow = { + name: string, + module: string | null, + instances: number, + bytes: number, + /** Change since the previous snapshot; `None` on the first one and for ambiguous rows. */ + deltaInstances: number | null, + deltaBytes: number | null, + /** Several classes share this name (different class loaders), so it has no delta. */ + ambiguous: boolean, +}; + +/** For arrays `name` is `"[i]"`. */ +export type JvmField = { + name: string, + value: JvmValue, +}; + +export type JvmFieldsResult = { status: "ready"; fields: JvmField[] } | { status: "unavailable"; reason: JvmInspectUnavailable }; + export type JvmFilePresentation = { path: string, presentation: JvmPresentation, }; +export type JvmInspectUnavailable = +/** The session has no inspector: not a JVM session, not ready yet, or turned off. */ +{ kind: "noBackend" } | +/** The debug adapter lacks what the inspector needs; turned off for the session. */ +{ kind: "unsupported"; detail: string } | { kind: "notSuspended" } | +/** The object is from an earlier stop, or the program stopped or resumed meanwhile. */ +{ kind: "stale" } | { kind: "collected" } | { kind: "classNotLoaded" } | { kind: "vmNotFound" } | +/** Transient: ask again. */ +{ kind: "timedOut" } | { kind: "backendClosed" }; + +export type JvmInstancesResult = { status: "ready"; +/** Goes into the [`JvmObjectRef`] of each instance. */ +generation: number; instances: JvmValue[]; +/** The limit was reached; there may be more. */ +truncated: boolean } | { status: "unavailable"; reason: JvmInspectUnavailable }; + +export type JvmMemoryResult = { status: "ready"; snapshot: JvmMemorySnapshot } | { status: "unavailable"; reason: JvmMemoryUnavailable; +/** The debugger could count the classes if the program were paused. */ +canCountWhenPaused: boolean }; + +export type JvmMemorySnapshot = { + /** Largest `bytes` first. */ + rows: JvmClassRow[], + totalInstances: number, + totalBytes: number, + /** Empty when unknown. */ + javaVersion: string, + takenWhileSuspended: boolean, + /** A previous snapshot from the same source exists; deltas are against it. */ + hasBaseline: boolean, + /** Wall time of the histogram or count request. */ + elapsedMs: number, + source: JvmMemorySource, +}; + +export type JvmMemorySource = +/** `GC.class_histogram -all`: sizes, and objects not yet collected. */ +"histogram" | +/** JDWP `InstanceCounts`: reachable objects only, no sizes (`bytes` is 0). */ +"debugger"; + +export type JvmMemoryUnavailable = +/** An attach session whose JVM is remote, or local but not found by its JDWP port. */ +{ kind: "attachedJvmNotFound" } | +/** + * An attach through the IDE's listen-mode relay: the JVM connected to the IDE, so it has + * no JDWP listener to be found by. + */ +{ kind: "listenModeNotSupported" } | { kind: "unsupportedPlatform" } | { kind: "noJvmProcess" } | { kind: "listenerNotRunning" } | +/** `java_major` is 0 when the version is unknown. */ +{ kind: "unsafeWhileSuspended"; javaMajor: number } | { kind: "programExited" } | { kind: "timedOut" }; + +export type JvmObjectRef = { + /** The `generation` of the [`JvmInstancesResult`] the object came from. */ + generation: number, + id: number, +}; + export type JvmPresentation = { kind: JvmTypeKind | null, springBoot: boolean, runnable: boolean, }; +export type JvmReferrer = { + value: JvmValue, + /** Field names, or `"[i]"` for array slots, through which `value` points at the object. */ + via: string[], +}; + +export type JvmReferrersResult = { status: "ready"; referrers: JvmReferrer[]; truncated: boolean } | { status: "unavailable"; reason: JvmInspectUnavailable }; + export type JvmTypeKind = "class" | "abstractClass" | "interface" | "enum" | "record" | "annotation" | "exception" | "object"; +export type JvmValue = { kind: "null" } | { kind: "primitive"; typeName: string; text: string } | +/** `text` is `None` when `length` exceeds [`STRING_PREVIEW_CHARS`]. */ +{ kind: "string"; id: number; text: string | null; length: number } | { kind: "boxed"; id: number; typeName: string; text: string } | { kind: "object"; id: number; typeName: string } | { kind: "array"; id: number; typeName: string; length: number }; + /** Editor › Code Style › › Tabs and Indents (unset fields inherit). */ export type LanguageCodeStyle = LanguageCodeStyle_Serialize | LanguageCodeStyle_Deserialize; diff --git a/src/features/debugger/__tests__/actions.test.ts b/src/features/debugger/__tests__/actions.test.ts index e9d9dd08d..678e613fb 100644 --- a/src/features/debugger/__tests__/actions.test.ts +++ b/src/features/debugger/__tests__/actions.test.ts @@ -1,5 +1,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { EditorState } from "@codemirror/state"; +import { createElement } from "react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; const ipc = vi.hoisted(() => { const ok = (data: T) => Promise.resolve({ status: "ok" as const, data }); @@ -20,6 +22,20 @@ const ipc = vi.hoisted(() => { ), debugStackTrace: vi.fn(() => ok([])), debugThreads: vi.fn(() => ok([])), + debugJvmMemoryClasses: vi.fn(() => ok({ status: "unavailable", reason: { kind: "programExited" } })), + debugJvmMemoryInstances: vi.fn(() => ok({ status: "ready", generation: 1, instances: [], truncated: false })), + debugJvmMemoryFields: vi.fn(() => ok({ status: "ready", fields: [] })), + debugJvmMemoryReferrers: vi.fn(() => ok({ status: "ready", referrers: [], truncated: false })), + lspWorkspaceSymbols: vi.fn(() => + ok([ + { + name: "Order", + kind: "class", + container: "com.x", + location: { path: "/p/com/x/Order.java", range: { start: { line: 2, col: 13 }, end: { line: 2, col: 18 } } }, + }, + ]), + ), debugScopes: vi.fn(() => ok([])), debugStart: vi.fn((_launch: { extraJson: string | null }) => ok(42)), debugSetBreakpoints: vi.fn(() => ok([])), @@ -61,7 +77,8 @@ vi.mock("@/workbench/contributions", async (importOriginal) => { }; }); -import { executeAction, getAction, registerActions } from "@/platform/actions"; +import { executeAction, getAction, isActionEnabled, registerActions } from "@/platform/actions"; +import { clearContext, setContext } from "@/platform/context"; import { intellijMacos } from "@/platform/keymaps/intellij-macos"; import type { DebugCapabilities, RunConfig } from "@/platform/ipc"; import { useProject } from "@/platform/project"; @@ -72,6 +89,9 @@ import { loadSpecialBreakpoints, removeAllSpecial, useSpecialBreakpoints } from import { publishContext, updateExecutionPoint, useExecutionPoint, useDebug, type DebugSession } from "../state"; import { focusDebugView, blurDebugView } from "../ui/focusedView"; import { useOverlays } from "../ui/popoverStore"; +import { forgetMemory, selectMemoryClass } from "../memory/memoryState"; +import { forgetInstances, setInstancesLead, showInstances, useInstances } from "../memory/instancesState"; +import { InstancesPane } from "../memory/InstancesPane"; const caps = (p: Partial = {}): DebugCapabilities => ({ restartFrame: false, @@ -83,6 +103,8 @@ const caps = (p: Partial = {}): DebugCapabilities => ({ conditionalBreakpoints: true, hitConditionalBreakpoints: true, logPoints: true, + jvmMemory: false, + jvmInstances: false, ...p, }); @@ -106,6 +128,7 @@ function paused(p: Partial = {}) { ], selectedFrame: 71, hits: 1, + stops: 0, exitCode: null, caps: caps(), ...p, @@ -158,6 +181,11 @@ describe("debugger actions (IDEA parity)", () => { "debug.setValue": ["F2"], "debug.jumpToSource": ["F4", "Meta+Down"], "debug.resetFrame": undefined, + "debug.memory.refresh": undefined, + "debug.memory.showInstances": undefined, + "debug.memory.jumpToSource": undefined, + "debug.memory.showReferrers": undefined, + "debug.memory.closeInstances": undefined, "debug.toggleMethodBreakpoint": undefined, "debug.toggleFieldWatchpoint": undefined, }; @@ -243,6 +271,140 @@ describe("debugger actions (IDEA parity)", () => { expect(ipc.debugRestartFrame).toHaveBeenCalledWith(3, 71); }); + it("Refresh Memory View reads the class table of a session that supports it", async () => { + const result = { status: "unavailable", reason: { kind: "programExited" } }; + ipc.debugJvmMemoryClasses.mockReturnValue(ipc.ok(result) as never); + paused({ caps: caps({ jvmMemory: false }) }); + await executeAction("debug.memory.refresh"); + expect(ipc.debugJvmMemoryClasses).not.toHaveBeenCalled(); + paused({ caps: caps({ jvmMemory: true }) }); + await executeAction("debug.memory.refresh"); + await settle(); + expect(ipc.debugJvmMemoryClasses).toHaveBeenCalledWith(3); + }); + + it("Show Instances lists the selected Memory row of a paused session that can list instances", async () => { + expect(getAction("debug.memory.showInstances")!.when).toBe("debugSessionPaused"); + selectMemoryClass(3, "com.x.Order"); + paused({ caps: caps({ jvmMemory: true, jvmInstances: false }) }); + await executeAction("debug.memory.showInstances"); + expect(ipc.debugJvmMemoryInstances).not.toHaveBeenCalled(); + paused({ caps: caps({ jvmMemory: true, jvmInstances: true }) }); + await executeAction("debug.memory.showInstances"); + expect(ipc.debugJvmMemoryInstances).toHaveBeenCalledWith(3, "com.x.Order", 1000); + await executeAction("debug.memory.showInstances", "com.x.Line"); + expect(ipc.debugJvmMemoryInstances).toHaveBeenLastCalledWith(3, "com.x.Line", 1000); + forgetInstances(3); + forgetMemory(3); + }); + + it("Show Referrers runs for the lead row of the open instances list", async () => { + paused({ caps: caps({ jvmMemory: true, jvmInstances: true }) }); + await executeAction("debug.memory.showReferrers"); + expect(ipc.debugJvmMemoryReferrers).not.toHaveBeenCalled(); + ipc.debugJvmMemoryInstances.mockReturnValueOnce( + ipc.ok({ + status: "ready", + generation: 4, + instances: [{ kind: "object", id: 9, typeName: "com.x.Order" }], + truncated: false, + }) as never, + ); + await showInstances(3, "com.x.Order"); + // No lead row yet. + await executeAction("debug.memory.showReferrers"); + expect(ipc.debugJvmMemoryReferrers).not.toHaveBeenCalled(); + setInstancesLead(3, { key: "#0", ref: { generation: 4, id: 9 } }); + await executeAction("debug.memory.showReferrers"); + await settle(); + expect(ipc.debugJvmMemoryReferrers).toHaveBeenCalledWith(3, { generation: 4, id: 9 }, 50); + expect(useInstances.getState().bySession[3]!.expanded).toEqual(["#0", "#0/referrers"]); + // Objects from before the last stop cannot be read. + ipc.debugJvmMemoryReferrers.mockClear(); + paused({ stops: 1, caps: caps({ jvmMemory: true, jvmInstances: true }) }); + await executeAction("debug.memory.showReferrers"); + expect(ipc.debugJvmMemoryReferrers).not.toHaveBeenCalled(); + forgetInstances(3); + }); + + it("Close Instances closes the open instances list", async () => { + paused({ caps: caps({ jvmMemory: true, jvmInstances: true }) }); + await executeAction("debug.memory.closeInstances"); + await showInstances(3, "com.x.Order"); + expect(useInstances.getState().bySession[3]).toBeTruthy(); + await executeAction("debug.memory.closeInstances"); + expect(useInstances.getState().bySession[3]).toBeUndefined(); + }); + + it("closes the instances pane of a session that has ended, from its button or the action", async () => { + const jvm = caps({ jvmMemory: true, jvmInstances: true }); + ipc.debugJvmMemoryInstances.mockReturnValueOnce( + ipc.ok({ + status: "ready", + generation: 1, + instances: [{ kind: "object", id: 9, typeName: "com.x.Order" }], + truncated: false, + }) as never, + ); + paused({ caps: jvm }); + await showInstances(3, "com.x.Order"); + paused({ status: "terminated", caps: jvm }); + const session = useDebug.getState().sessions[3]!; + render(createElement(InstancesPane, { session })); + // Reading objects needs a paused program: both instance actions are off, the row button too. + setInstancesLead(3, { key: "#0", ref: { generation: 1, id: 9 } }); + expect(await executeAction("debug.memory.showReferrers")).toBe(false); + expect(await executeAction("debug.memory.showInstances", "com.x.Order")).toBe(false); + expect(ipc.debugJvmMemoryReferrers).not.toHaveBeenCalled(); + expect(ipc.debugJvmMemoryInstances).toHaveBeenCalledTimes(1); + expect(screen.getByRole("button", { name: "Show Referrers" }).hasAttribute("disabled")).toBe(true); + + fireEvent.click(screen.getByRole("button", { name: "Close Instances" })); + await settle(); + expect(useInstances.getState().bySession[3]).toBeUndefined(); + expect(screen.queryByRole("tree")).toBeNull(); + + await showInstances(3, "com.x.Order"); + expect(await executeAction("debug.memory.closeInstances")).toBe(true); + expect(useInstances.getState().bySession[3]).toBeUndefined(); + cleanup(); + }); + + it("Jump to Source opens the class of the open instances list", async () => { + paused({ caps: caps({ jvmMemory: true, jvmInstances: true }) }); + setContext("projectOpen", true); + await showInstances(3, "com.x.Order$Line"); + await executeAction("debug.memory.jumpToSource"); + await settle(); + expect(ipc.lspWorkspaceSymbols).toHaveBeenCalledWith(expect.anything(), "Order"); + expect(opened.at(-1)?.path).toBe("/p/com/x/Order.java"); + clearContext("projectOpen"); + forgetInstances(3); + }); + + it("Jump to Source still opens the class once the session has ended", async () => { + paused({ caps: caps({ jvmMemory: true, jvmInstances: true }) }); + await showInstances(3, "com.x.Order$Line"); + paused({ status: "terminated", caps: caps({ jvmMemory: true, jvmInstances: true }) }); + setContext("projectOpen", true); + expect(await executeAction("debug.memory.jumpToSource")).toBe(true); + await settle(); + expect(opened.at(-1)?.path).toBe("/p/com/x/Order.java"); + clearContext("projectOpen"); + forgetInstances(3); + }); + + it("offers Refresh Memory View only for a live session of a JVM", () => { + const refresh = getAction("debug.memory.refresh")!; + expect(refresh.when).toBe("debugHasJvmMemory"); + paused({ adapter: "debugpy", caps: caps({ jvmMemory: false }) }); + expect(isActionEnabled(refresh)).toBe(false); + paused({ caps: caps({ jvmMemory: true }) }); + expect(isActionEnabled(refresh)).toBe(true); + paused({ status: "terminated", caps: caps({ jvmMemory: true }) }); + expect(isActionEnabled(refresh)).toBe(false); + }); + it("Show Execution Point reveals the top frame with focus", async () => { paused(); await executeAction("debug.showExecutionPoint"); diff --git a/src/features/debugger/__tests__/expressionCompletion.test.tsx b/src/features/debugger/__tests__/expressionCompletion.test.tsx index f76dba5c4..721e35de6 100644 --- a/src/features/debugger/__tests__/expressionCompletion.test.tsx +++ b/src/features/debugger/__tests__/expressionCompletion.test.tsx @@ -29,6 +29,7 @@ const session: DebugSession = { frames: [{ id: 7, name: "run", path: "/p/App.java", line: 11, column: 0, presentationHint: null }], selectedFrame: 7, hits: 1, + stops: 0, exitCode: null, caps: null, }; diff --git a/src/features/debugger/__tests__/hotswap.test.ts b/src/features/debugger/__tests__/hotswap.test.ts index 5a53de92d..87ace58f2 100644 --- a/src/features/debugger/__tests__/hotswap.test.ts +++ b/src/features/debugger/__tests__/hotswap.test.ts @@ -74,6 +74,7 @@ function session(id: number, hotCodeReplace: boolean): DebugSession { frames: [], selectedFrame: null, hits: 0, + stops: 0, exitCode: null, caps: { hotCodeReplace } as DebugCapabilities, }; diff --git a/src/features/debugger/__tests__/instancesPane.test.tsx b/src/features/debugger/__tests__/instancesPane.test.tsx new file mode 100644 index 000000000..149dccaac --- /dev/null +++ b/src/features/debugger/__tests__/instancesPane.test.tsx @@ -0,0 +1,353 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +const ipc = vi.hoisted(() => ({ + debugJvmMemoryInstances: vi.fn(), + debugJvmMemoryFields: vi.fn(), + debugJvmMemoryReferrers: vi.fn(), + lspWorkspaceSymbols: vi.fn(), +})); + +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { ...real, isTauri: () => true, commands: { ...real.commands, ...ipc } }; +}); + +import type { + DebugCapabilities, + JvmFieldsResult, + JvmInspectUnavailable, + JvmInstancesResult, + JvmReferrersResult, + WorkspaceSymbol, +} from "@/platform/ipc"; +import { registerActions } from "@/platform/actions"; +import { setLocale } from "@/platform/i18n"; +import { provideEditorService, provideNotificationService, type EditorService } from "@/workbench/contributions"; +import { useDebug, type DebugSession } from "../state"; +import { closeInstances, showInstances, showReferrers, useInstances } from "../memory/instancesState"; +import { InstancesPane } from "../memory/InstancesPane"; +import { jumpToClass } from "../memory/jumpToClass"; + +const CLASS = "com.x.Order"; + +const session = { + id: 1, + name: "main", + adapter: "java-debug", + parent: null, + launch: null, + process: null, + status: "paused", + stoppedThread: 1, + stopReason: "breakpoint", + stopDescription: null, + threads: [], + selectedThread: 1, + frames: [], + selectedFrame: null, + hits: 1, + stops: 0, + exitCode: null, + caps: { jvmInstances: true } as DebugCapabilities, +} as DebugSession; + +const LIST: JvmInstancesResult = { + status: "ready", + generation: 2, + instances: [ + { kind: "object", id: 12, typeName: CLASS }, + { kind: "string", id: 13, text: "hi", length: 2 }, + { kind: "boxed", id: 14, typeName: "java.lang.Integer", text: "42" }, + { kind: "array", id: 16, typeName: "int[]", length: 3 }, + ], + truncated: false, +}; +const FIELDS: JvmFieldsResult = { + status: "ready", + fields: [ + { name: "items", value: { kind: "object", id: 15, typeName: "java.util.ArrayList" } }, + { name: "total", value: { kind: "primitive", typeName: "int", text: "3" } }, + ], +}; +const REFERRERS: JvmReferrersResult = { + status: "ready", + referrers: [{ value: { kind: "object", id: 3, typeName: "com.x.Book" }, via: ["orders"] }], + truncated: false, +}; +const ok = (data: T) => Promise.resolve({ status: "ok" as const, data }); +const unavailable = (reason: JvmInspectUnavailable) => ({ status: "unavailable" as const, reason }); + +let unregister: () => void; + +async function open(result: JvmInstancesResult = LIST, s: DebugSession = session) { + ipc.debugJvmMemoryInstances.mockReturnValueOnce(ok(result)); + await showInstances(s.id, CLASS); + return render(); +} + +const item = (text: string) => screen.getByText(text).closest("[role=treeitem]")!; +const toggle = (row: HTMLElement) => fireEvent.mouseDown(row.querySelector("[data-tree-toggle]")!, { button: 0 }); + +beforeEach(() => { + useInstances.setState({ bySession: {} }); + useDebug.setState({ sessions: { 1: session }, active: 1, stopSeq: 0 }); + for (const m of Object.values(ipc)) m.mockReset(); + // The real actions live in actions.ts; the buttons only need their ids to resolve. + unregister = registerActions([ + { + id: "debug.memory.showInstances", + title: "Show Instances", + run: ({ arg }) => void showInstances(1, arg as string), + }, + { id: "debug.memory.jumpToSource", title: "Jump to Source", run: () => {} }, + { id: "debug.memory.closeInstances", title: "Close Instances", run: () => closeInstances(1) }, + ]); +}); + +afterEach(() => { + unregister(); + cleanup(); +}); + +describe("InstancesPane", () => { + it("lists instances with previews", async () => { + await open(); + expect(screen.getByText(`Instances of ${CLASS}`)).toBeTruthy(); + const tree = screen.getByRole("tree"); + const rows = within(tree).getAllByRole("treeitem"); + expect(rows).toHaveLength(4); + for (const text of ["Order@12", '"hi"', "42", "int[3]"]) expect(within(tree).getByText(text)).toBeTruthy(); + expect(screen.getByRole("button", { name: "Jump to Source" })).toBeTruthy(); + expect(screen.queryByText(/Showing the first/)).toBeNull(); + }); + + it("says when only the first instances are shown", async () => { + await open({ ...LIST, truncated: true }); + expect(screen.getByText("Showing the first 4 instances.")).toBeTruthy(); + }); + + it("expands fields one level on demand", async () => { + ipc.debugJvmMemoryFields.mockReturnValue(ok(FIELDS)); + await open(); + const order = item("Order@12"); + expect(order.getAttribute("aria-expanded")).toBe("false"); + toggle(order); + expect(await screen.findByText("ArrayList@15")).toBeTruthy(); + expect(ipc.debugJvmMemoryFields).toHaveBeenCalledTimes(1); + expect(ipc.debugJvmMemoryFields).toHaveBeenCalledWith(1, { generation: 2, id: 12 }); + expect(screen.getByText("items")).toBeTruthy(); + // An object field opens further; a primitive does not. + expect(item("ArrayList@15").getAttribute("aria-expanded")).toBe("false"); + expect(item("3").getAttribute("aria-expanded")).toBeNull(); + toggle(item("Order@12")); + toggle(item("Order@12")); + expect(ipc.debugJvmMemoryFields).toHaveBeenCalledTimes(1); + }); + + it("shows referrers with the field that points at the object", async () => { + ipc.debugJvmMemoryReferrers.mockReturnValue(ok(REFERRERS)); + await open(); + fireEvent.click(within(item("Order@12")).getByRole("button", { name: "Show Referrers" })); + expect(await screen.findByText("Book@3 · orders")).toBeTruthy(); + expect(ipc.debugJvmMemoryReferrers).toHaveBeenCalledWith(1, { generation: 2, id: 12 }, 50); + expect(item("Referrers").getAttribute("aria-expanded")).toBe("true"); + }); + + it("says when nothing refers to the object", async () => { + ipc.debugJvmMemoryReferrers.mockReturnValue(ok({ ...REFERRERS, referrers: [] })); + await open(); + fireEvent.click(within(item("Order@12")).getByRole("button", { name: "Show Referrers" })); + expect(await screen.findByText("No referrers")).toBeTruthy(); + }); + + it("marks the list outdated after another stop and asks again", async () => { + const { rerender } = await open(); + const banner = "These values are from before the last stop. Show the instances again to update them."; + expect(screen.queryByText(banner)).toBeNull(); + const later = { ...session, stops: 1 }; + useDebug.setState({ sessions: { 1: later } }); + rerender(); + expect(screen.getByText(banner)).toBeTruthy(); + expect(item("Order@12").getAttribute("aria-expanded")).toBeNull(); + expect(within(item("Order@12")).getByRole("button", { name: "Show Referrers" }).hasAttribute("disabled")).toBe( + true, + ); + + ipc.debugJvmMemoryInstances.mockReturnValueOnce(ok({ ...LIST, generation: 3 })); + fireEvent.click(screen.getByRole("button", { name: "Show Instances" })); + await waitFor(() => expect(screen.queryByText(banner)).toBeNull()); + expect(ipc.debugJvmMemoryInstances).toHaveBeenCalledTimes(2); + expect(item("Order@12").getAttribute("aria-expanded")).toBe("false"); + }); + + it("explains a collected object", async () => { + ipc.debugJvmMemoryFields.mockReturnValue(ok(unavailable({ kind: "collected" }))); + await open(); + toggle(item("Order@12")); + expect(await screen.findByText("The object has been garbage collected.")).toBeTruthy(); + }); + + it.each<[JvmInspectUnavailable, string]>([ + [{ kind: "noBackend" }, "This debugger cannot list instances."], + [{ kind: "unsupported", detail: "no VM" }, "The debugger cannot list instances: no VM"], + [{ kind: "stale" }, "These values are from before the last stop. Show the instances again to update them."], + [{ kind: "collected" }, "The object has been garbage collected."], + [{ kind: "classNotLoaded" }, "The class is not loaded in the JVM."], + [{ kind: "vmNotFound" }, "Could not tell which JVM this session debugs."], + [{ kind: "timedOut" }, "The JVM did not answer in time."], + [{ kind: "backendClosed" }, "The connection to the JVM is closed."], + ])("words the %j reason", async (reason, text) => { + await open(unavailable(reason)); + expect(screen.getByText(text)).toBeTruthy(); + expect(screen.queryByRole("tree")).toBeNull(); + }); + + it("asks to pause a running program for its instances", async () => { + const running = { ...session, status: "running" } as DebugSession; + await open(unavailable({ kind: "notSuspended" }), running); + expect(screen.getByText("Pause the program to see instances.")).toBeTruthy(); + }); + + it("says instances need a breakpoint or step stop when the debugger finds no stop after Pause", async () => { + await open(unavailable({ kind: "notSuspended" })); + expect( + screen.getByText( + "This debugger lists instances only when the program stops at a breakpoint or after a step, not after Pause.", + ), + ).toBeTruthy(); + expect(screen.queryByText("Pause the program to see instances.")).toBeNull(); + }); + + it("retries a list that timed out", async () => { + await open(unavailable({ kind: "timedOut" })); + ipc.debugJvmMemoryInstances.mockReturnValueOnce(ok(LIST)); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findByText("Order@12")).toBeTruthy(); + expect(ipc.debugJvmMemoryInstances).toHaveBeenCalledTimes(2); + expect(ipc.debugJvmMemoryInstances).toHaveBeenLastCalledWith(1, CLASS, 1000); + }); + + it("retries fields that timed out", async () => { + ipc.debugJvmMemoryFields.mockReturnValueOnce(ok(unavailable({ kind: "timedOut" }))); + await open(); + toggle(item("Order@12")); + await screen.findByText("The JVM did not answer in time."); + ipc.debugJvmMemoryFields.mockReturnValueOnce(ok(FIELDS)); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findByText("ArrayList@15")).toBeTruthy(); + expect(ipc.debugJvmMemoryFields).toHaveBeenCalledTimes(2); + }); + + it("Enter on a row's Show Referrers button shows that row's referrers, not the lead row's", async () => { + const user = userEvent.setup(); + ipc.debugJvmMemoryReferrers.mockReturnValue(ok({ ...REFERRERS, referrers: [] })); + ipc.debugJvmMemoryFields.mockReturnValue(ok({ status: "ready", fields: [] })); + await open(); + fireEvent.mouseDown(item("Order@12"), { button: 0 }); + within(item('"hi"')).getByRole("button", { name: "Show Referrers" }).focus(); + await user.keyboard("{Enter}"); + await waitFor(() => expect(ipc.debugJvmMemoryReferrers).toHaveBeenCalledWith(1, { generation: 2, id: 13 }, 50)); + expect(ipc.debugJvmMemoryReferrers).toHaveBeenCalledTimes(1); + expect(item("Order@12").getAttribute("aria-expanded")).toBe("false"); + expect(item('"hi"').getAttribute("aria-expanded")).toBe("true"); + }); + + it("Enter on the tree still opens the lead row", async () => { + ipc.debugJvmMemoryFields.mockReturnValue(ok(FIELDS)); + await open(); + fireEvent.mouseDown(item("Order@12"), { button: 0 }); + fireEvent.keyDown(screen.getByRole("tree"), { key: "Enter" }); + expect(await screen.findByText("ArrayList@15")).toBeTruthy(); + expect(item("Order@12").getAttribute("aria-expanded")).toBe("true"); + }); + + it("keeps the lead object row in the store for the Show Referrers action", async () => { + await open(); + expect(useInstances.getState().bySession[1]!.lead).toBeNull(); + fireEvent.mouseDown(item('"hi"'), { button: 0 }); + expect(useInstances.getState().bySession[1]!.lead).toEqual({ key: "#1", ref: { generation: 2, id: 13 } }); + }); + + it("opens the referrers the store was asked to show", async () => { + ipc.debugJvmMemoryReferrers.mockReturnValue(ok(REFERRERS)); + ipc.debugJvmMemoryFields.mockReturnValue(ok(FIELDS)); + await open(); + showReferrers(1, "#0", { generation: 2, id: 12 }); + expect(await screen.findByText("Book@3 · orders")).toBeTruthy(); + expect(item("Order@12").getAttribute("aria-expanded")).toBe("true"); + expect(screen.getByText("ArrayList@15")).toBeTruthy(); + }); + + it("closes", async () => { + await open(); + fireEvent.click(screen.getByRole("button", { name: "Close Instances" })); + expect(useInstances.getState().bySession[1]).toBeUndefined(); + expect(screen.queryByRole("tree")).toBeNull(); + }); + + it("shows Chinese text in zh", async () => { + await setLocale("zh"); + try { + ipc.debugJvmMemoryReferrers.mockReturnValue(ok(REFERRERS)); + await open(); + expect(screen.getByText(`${CLASS} 的实例`)).toBeTruthy(); + expect(screen.getByRole("button", { name: "关闭实例列表" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "跳转到源代码" })).toBeTruthy(); + fireEvent.click(within(item("Order@12")).getByRole("button", { name: "显示引用者" })); + expect(await screen.findByText("引用者")).toBeTruthy(); + } finally { + await setLocale("en"); + } + }); +}); + +describe("jumpToClass", () => { + const symbol = (container: string, path: string, line = 4): WorkspaceSymbol => ({ + name: "Order", + kind: "class", + container, + location: { path, range: { start: { line, col: 13 }, end: { line, col: 18 } } }, + }); + const openFile = vi.fn(); + const notify = vi.fn(); + + beforeEach(() => { + openFile.mockReset().mockResolvedValue(undefined); + notify.mockReset(); + provideEditorService({ openFile } as unknown as EditorService); + provideNotificationService({ notify }); + }); + + it("opens the class whose package matches", async () => { + ipc.lspWorkspaceSymbols.mockReturnValue( + ok([symbol("com.a", "/p/com/a/Order.java"), symbol("com.x", "/p/com/x/Order.java", 9)]), + ); + await expect(jumpToClass("com.x.Order")).resolves.toBe(true); + expect(ipc.lspWorkspaceSymbols).toHaveBeenCalledWith(expect.anything(), "Order"); + expect(openFile).toHaveBeenCalledWith("/p/com/x/Order.java", { selection: { line: 9, col: 13 } }); + }); + + it("opens the outer class of a nested class or an array", async () => { + ipc.lspWorkspaceSymbols.mockReturnValue(ok([symbol("com.x", "/p/com/x/Order.java")])); + await expect(jumpToClass("com.x.Order$Line[][]")).resolves.toBe(true); + expect(ipc.lspWorkspaceSymbols).toHaveBeenCalledWith(expect.anything(), "Order"); + expect(openFile).toHaveBeenCalledWith("/p/com/x/Order.java", { selection: { line: 4, col: 13 } }); + }); + + it("accepts a container that ends with the package", async () => { + ipc.lspWorkspaceSymbols.mockReturnValue(ok([symbol("app.jar com.x", "jdt://contents/Order.class")])); + await expect(jumpToClass("com.x.Order")).resolves.toBe(true); + expect(openFile).toHaveBeenCalledWith("jdt://contents/Order.class", { selection: { line: 4, col: 13 } }); + }); + + it("says when no source is found", async () => { + ipc.lspWorkspaceSymbols.mockReturnValue(ok([symbol("com.a", "/p/com/a/Order.java"), symbol("com.ax", "/x")])); + await expect(jumpToClass("com.x.Order")).resolves.toBe(false); + expect(openFile).not.toHaveBeenCalled(); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ title: "Could not find the source of com.x.Order." }), + ); + }); +}); diff --git a/src/features/debugger/__tests__/instancesState.test.ts b/src/features/debugger/__tests__/instancesState.test.ts new file mode 100644 index 000000000..6ce7de6e6 --- /dev/null +++ b/src/features/debugger/__tests__/instancesState.test.ts @@ -0,0 +1,302 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const ipc = vi.hoisted(() => ({ + debugJvmMemoryInstances: vi.fn(), + debugJvmMemoryFields: vi.fn(), + debugJvmMemoryReferrers: vi.fn(), + debugCapabilities: vi.fn(), +})); + +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { ...real, isTauri: () => true, commands: { ...real.commands, ...ipc } }; +}); + +import type { + DebugSessionId, + JvmFieldsResult, + JvmInstancesResult, + JvmObjectRef, + JvmReferrersResult, +} from "@/platform/ipc"; +import { adoptSession, handleDebugEvent, useDebug, type DebugSession } from "../state"; +import { + closeInstances, + forgetInstances, + instancesOutdated, + loadFields, + loadReferrers, + showInstances, + useInstances, +} from "../memory/instancesState"; + +const ID = 7 as DebugSessionId; +const CLASS = "com.x.Order"; +const ref: JvmObjectRef = { generation: 2, id: 7 }; + +const instances = (generation = 2): JvmInstancesResult => ({ + status: "ready", + generation, + instances: [{ kind: "object", id: 7, typeName: CLASS }], + truncated: false, +}); +const fields: JvmFieldsResult = { + status: "ready", + fields: [{ name: "total", value: { kind: "primitive", typeName: "int", text: "3" } }], +}; +const referrers: JvmReferrersResult = { status: "ready", referrers: [], truncated: false }; +const ok = (data: T) => Promise.resolve({ status: "ok" as const, data }); +const fail = (message: string) => Promise.resolve({ status: "error" as const, error: { kind: "internal", message } }); +const later = () => { + let release!: (r: unknown) => void; + const promise = new Promise((r) => (release = r)); + return { promise, release: (data: unknown) => release({ status: "ok", data }) }; +}; + +const setStops = (stops: number) => useDebug.setState({ sessions: { [ID]: { id: ID, stops } as DebugSession } }); +const entry = () => useInstances.getState().bySession[ID]!; + +beforeEach(() => { + useInstances.setState({ bySession: {} }); + useDebug.setState({ sessions: {}, active: null, stopSeq: 0 }); + for (const m of Object.values(ipc)) m.mockReset(); + ipc.debugCapabilities.mockResolvedValue({ status: "ok", data: null }); +}); + +describe("showInstances", () => { + it("stores the result and the stop it was taken at", async () => { + setStops(3); + ipc.debugJvmMemoryInstances.mockReturnValue(ok(instances())); + await showInstances(ID, CLASS); + expect(ipc.debugJvmMemoryInstances).toHaveBeenCalledWith(ID, CLASS, 1000); + expect(entry()).toMatchObject({ + className: CLASS, + result: instances(), + takenAtStop: 3, + loading: false, + error: null, + nodes: {}, + }); + }); + + it("records the error and settles loading when the call fails", async () => { + ipc.debugJvmMemoryInstances.mockReturnValue(fail("boom")); + await showInstances(ID, CLASS); + expect(entry()).toMatchObject({ className: CLASS, result: null, error: "boom", loading: false }); + }); + + it("a new class replaces the previous instances and their nodes", async () => { + ipc.debugJvmMemoryInstances.mockReturnValue(ok(instances())); + ipc.debugJvmMemoryFields.mockReturnValue(ok(fields)); + await showInstances(ID, CLASS); + await loadFields(ID, ref); + expect(Object.keys(entry().nodes)).toEqual(["2:7"]); + + const next = instances(3); + ipc.debugJvmMemoryInstances.mockReturnValue(ok(next)); + await showInstances(ID, "com.x.Line"); + expect(entry()).toMatchObject({ className: "com.x.Line", result: next, nodes: {} }); + }); + + it("ignores a second request for the same class while it is loading", async () => { + const pending = later(); + ipc.debugJvmMemoryInstances.mockReturnValueOnce(pending.promise); + const first = showInstances(ID, CLASS); + await showInstances(ID, CLASS); + expect(ipc.debugJvmMemoryInstances).toHaveBeenCalledTimes(1); + pending.release(instances()); + await first; + expect(entry().result).toEqual(instances()); + }); + + it("asks again for the same class at a new stop while the old request is loading", async () => { + setStops(1); + const old = later(); + ipc.debugJvmMemoryInstances.mockReturnValueOnce(old.promise); + const first = showInstances(ID, CLASS); + setStops(2); + ipc.debugJvmMemoryInstances.mockReturnValueOnce(ok(instances(3))); + await showInstances(ID, CLASS); + expect(ipc.debugJvmMemoryInstances).toHaveBeenCalledTimes(2); + old.release(instances(2)); + await first; + expect(entry()).toMatchObject({ result: instances(3), takenAtStop: 2, loading: false }); + }); + + it("a late reply for a closed or replaced list is ignored", async () => { + const closed = later(); + ipc.debugJvmMemoryInstances.mockReturnValueOnce(closed.promise); + const afterClose = showInstances(ID, CLASS); + closeInstances(ID); + closed.release(instances()); + await afterClose; + expect(useInstances.getState().bySession[ID]).toBeUndefined(); + + const replaced = later(); + ipc.debugJvmMemoryInstances.mockReturnValueOnce(replaced.promise); + const stale = showInstances(ID, CLASS); + ipc.debugJvmMemoryInstances.mockReturnValueOnce(ok(instances(5))); + await showInstances(ID, "com.x.Line"); + replaced.release(instances(4)); + await stale; + expect(entry()).toMatchObject({ className: "com.x.Line", result: instances(5), loading: false }); + + const forgotten = later(); + ipc.debugJvmMemoryInstances.mockReturnValueOnce(forgotten.promise); + const gone = showInstances(ID, CLASS); + forgetInstances(ID); + forgotten.release(instances()); + await gone; + expect(useInstances.getState().bySession[ID]).toBeUndefined(); + }); + + it("a late node reply for a replaced list is ignored", async () => { + ipc.debugJvmMemoryInstances.mockReturnValueOnce(ok(instances())); + await showInstances(ID, CLASS); + const slow = later(); + ipc.debugJvmMemoryFields.mockReturnValueOnce(slow.promise); + const pending = loadFields(ID, ref); + ipc.debugJvmMemoryInstances.mockReturnValueOnce(ok(instances(3))); + await showInstances(ID, "com.x.Line"); + slow.release(fields); + await pending; + expect(entry().nodes).toEqual({}); + }); + + it("a timed-out list can be retried", async () => { + setStops(1); + const timedOut: JvmInstancesResult = { status: "unavailable", reason: { kind: "timedOut" } }; + ipc.debugJvmMemoryInstances.mockReturnValueOnce(ok(timedOut)); + await showInstances(ID, CLASS); + expect(entry()).toMatchObject({ result: timedOut, loading: false, error: null }); + + ipc.debugJvmMemoryInstances.mockReturnValueOnce(ok(instances())); + await showInstances(ID, CLASS); + expect(ipc.debugJvmMemoryInstances).toHaveBeenCalledTimes(2); + expect(entry()).toMatchObject({ result: instances(), loading: false }); + }); +}); + +describe("loadFields", () => { + beforeEach(async () => { + ipc.debugJvmMemoryInstances.mockReturnValue(ok(instances())); + await showInstances(ID, CLASS); + }); + + it("loadFields keys nodes by generation and id", async () => { + ipc.debugJvmMemoryFields.mockReturnValue(ok(fields)); + await loadFields(ID, ref); + expect(ipc.debugJvmMemoryFields).toHaveBeenCalledWith(ID, ref); + expect(entry().nodes["2:7"]).toEqual({ + fields, + referrers: null, + loadingFields: false, + loadingReferrers: false, + error: null, + }); + }); + + it("keeps fields and referrers of one node apart", async () => { + ipc.debugJvmMemoryFields.mockReturnValue(ok(fields)); + ipc.debugJvmMemoryReferrers.mockReturnValue(ok(referrers)); + await loadFields(ID, ref); + await loadReferrers(ID, ref); + expect(entry().nodes["2:7"]).toMatchObject({ fields, referrers }); + }); + + it("records a failure on the node and keeps what it had", async () => { + ipc.debugJvmMemoryFields.mockReturnValueOnce(ok(fields)); + await loadFields(ID, ref); + ipc.debugJvmMemoryFields.mockReturnValueOnce(fail("boom")); + await loadFields(ID, ref); + expect(entry().nodes["2:7"]).toMatchObject({ fields, error: "boom", loadingFields: false }); + }); + + it("does nothing without an open list", async () => { + closeInstances(ID); + await loadFields(ID, ref); + expect(ipc.debugJvmMemoryFields).not.toHaveBeenCalled(); + expect(useInstances.getState().bySession[ID]).toBeUndefined(); + }); + + it("ignores a second request for the same node while it is loading", async () => { + const pending = later(); + ipc.debugJvmMemoryFields.mockReturnValueOnce(pending.promise); + const first = loadFields(ID, ref); + expect(entry().nodes["2:7"]!.loadingFields).toBe(true); + await loadFields(ID, ref); + expect(ipc.debugJvmMemoryFields).toHaveBeenCalledTimes(1); + pending.release(fields); + await first; + expect(entry().nodes["2:7"]!.fields).toEqual(fields); + }); + + it("a timed-out node can be retried", async () => { + const timedOut: JvmFieldsResult = { status: "unavailable", reason: { kind: "timedOut" } }; + ipc.debugJvmMemoryFields.mockReturnValueOnce(ok(timedOut)); + await loadFields(ID, ref); + expect(entry().nodes["2:7"]).toMatchObject({ fields: timedOut, loadingFields: false }); + + ipc.debugJvmMemoryFields.mockReturnValueOnce(ok(fields)); + await loadFields(ID, ref); + expect(entry().nodes["2:7"]).toMatchObject({ fields, loadingFields: false }); + }); +}); + +describe("loadReferrers", () => { + beforeEach(async () => { + ipc.debugJvmMemoryInstances.mockReturnValue(ok(instances())); + await showInstances(ID, CLASS); + }); + + it("loadReferrers asks for 50", async () => { + ipc.debugJvmMemoryReferrers.mockReturnValue(ok(referrers)); + await loadReferrers(ID, ref); + expect(ipc.debugJvmMemoryReferrers).toHaveBeenCalledWith(ID, ref, 50); + expect(entry().nodes["2:7"]).toMatchObject({ referrers, loadingReferrers: false, error: null }); + }); + + it("a timed-out node can be retried", async () => { + const timedOut: JvmReferrersResult = { status: "unavailable", reason: { kind: "timedOut" } }; + ipc.debugJvmMemoryReferrers.mockReturnValueOnce(ok(timedOut)); + await loadReferrers(ID, ref); + ipc.debugJvmMemoryReferrers.mockReturnValueOnce(ok(referrers)); + await loadReferrers(ID, ref); + expect(entry().nodes["2:7"]).toMatchObject({ referrers, loadingReferrers: false }); + }); +}); + +describe("staleness and cleanup", () => { + it("is outdated once another stop happened", async () => { + setStops(2); + ipc.debugJvmMemoryInstances.mockReturnValue(ok(instances())); + await showInstances(ID, CLASS); + expect(instancesOutdated(entry(), 2)).toBe(false); + expect(instancesOutdated(entry(), 3)).toBe(true); + }); + + it("closing a list removes it", async () => { + ipc.debugJvmMemoryInstances.mockReturnValue(ok(instances())); + await showInstances(ID, CLASS); + closeInstances(ID); + expect(useInstances.getState().bySession[ID]).toBeUndefined(); + }); +}); + +describe("debug events", () => { + it("capabilities event reloads capabilities; jvmBackend event is stored", async () => { + adoptSession(ID, { adapter: "java-debug" }); + await Promise.resolve(); + ipc.debugCapabilities.mockClear(); + const caps = { jvmInstances: true }; + ipc.debugCapabilities.mockResolvedValue({ status: "ok", data: caps }); + + handleDebugEvent(ID, { kind: "capabilities" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(ipc.debugCapabilities).toHaveBeenCalledWith(ID); + expect(useDebug.getState().sessions[ID]!.caps).toEqual(caps); + + handleDebugEvent(ID, { kind: "jvmBackend", state: { kind: "degraded", detail: "forwarding only" } }); + expect(useDebug.getState().sessions[ID]!.jvmBackend).toEqual({ kind: "degraded", detail: "forwarding only" }); + }); +}); diff --git a/src/features/debugger/__tests__/memoryLayout.test.ts b/src/features/debugger/__tests__/memoryLayout.test.ts new file mode 100644 index 000000000..48c13ee4b --- /dev/null +++ b/src/features/debugger/__tests__/memoryLayout.test.ts @@ -0,0 +1,36 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +// `.iconButton:disabled { opacity: 0.4 }` (src/ui/Button.module.css) outranks a bare `.rowButton`, so a +// disabled row button (program running, list outdated) would show on every row unless hidden again. +describe.each(["MemoryView", "InstancesPane"])("%s row buttons", (name) => { + const css = readFileSync(`src/features/debugger/memory/${name}.module.css`, "utf8"); + + it("stay hidden when disabled, outside the hovered or lead row", () => { + expect(css).toMatch(/\.row \.rowButton:disabled \{[^}]*opacity: 0;/); + }); + + it("show disabled ones faded on the hovered row", () => { + expect(css).toMatch(/\.row:hover \.rowButton:disabled[^{]*\{[^}]*opacity: 0\.4;/); + }); +}); + +// The pane has a fixed height: in a short Debug tool window it squeezed the class table to nothing, +// whose header and footer then painted over the pane, and the pane's last rows fell out of view. +describe("instances pane in a short Memory view", () => { + const read = (name: string) => readFileSync(`src/features/debugger/memory/${name}.module.css`, "utf8"); + + it("takes at most 60% of the view", () => { + expect(read("InstancesPane")).toMatch(/\.pane \{[^}]*height: min\([^;]*, 60%\);/); + }); + + it("keeps the squeezed class table from painting over it", () => { + expect(read("MemoryView")).toMatch(/\.table \{[^}]*overflow: hidden;/); + }); + + it("shrinks before the class table, which keeps a few rows", () => { + expect(read("InstancesPane")).toMatch(/\.pane \{[^}]*flex: 0 1 auto;[^}]*min-height: calc\(/); + expect(read("MemoryView")).toMatch(/\.table \{[^}]*min-height: calc\([^;]*3 \* 22px\);/); + expect(read("MemoryView")).toMatch(/\.root \{[^}]*overflow-y: auto;/); + }); +}); diff --git a/src/features/debugger/__tests__/memoryRows.test.ts b/src/features/debugger/__tests__/memoryRows.test.ts new file mode 100644 index 000000000..3098c38e4 --- /dev/null +++ b/src/features/debugger/__tests__/memoryRows.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import type { JvmClassRow } from "@/platform/ipc"; +import { columnsOf, formatBytes, formatDelta, queryFor, visibleRows, type RowQuery } from "../memory/rows"; + +const row = (name: string, instances: number, bytes: number, deltaInstances: number | null): JvmClassRow => ({ + name, + module: null, + instances, + bytes, + deltaInstances, + deltaBytes: deltaInstances == null ? null : deltaInstances * 16, + ambiguous: false, +}); + +const rows = [ + row("java.lang.String", 100, 2400, 0), + row("java.util.HashMap$Node", 50, 1600, 5), + row("com.example.Order", 10, 4000, -3), + row("com.example.Customer", 20, 800, null), +]; +const q: RowQuery = { filter: "", sort: "bytes", desc: true, changedOnly: false }; +const names = (r: JvmClassRow[]) => r.map((x) => x.name); + +describe("visibleRows", () => { + it("filters by substring, case-insensitive", () => { + expect(names(visibleRows(rows, { ...q, filter: "hashmap" }))).toEqual(["java.util.HashMap$Node"]); + }); + + it("keeps only non-zero instance deltas when changedOnly", () => { + expect(names(visibleRows(rows, { ...q, changedOnly: true }))).toEqual([ + "com.example.Order", + "java.util.HashMap$Node", + ]); + }); + + it("sorts by delta with nulls last in both directions", () => { + expect(names(visibleRows(rows, { ...q, sort: "deltaInstances", desc: true }))).toEqual([ + "java.util.HashMap$Node", + "java.lang.String", + "com.example.Order", + "com.example.Customer", + ]); + expect(names(visibleRows(rows, { ...q, sort: "deltaInstances", desc: false }))).toEqual([ + "com.example.Order", + "java.lang.String", + "java.util.HashMap$Node", + "com.example.Customer", + ]); + }); + + it("sorts by name ascending and by instances descending", () => { + expect(names(visibleRows(rows, { ...q, sort: "name", desc: false }))[0]).toBe("com.example.Customer"); + expect(names(visibleRows(rows, { ...q, sort: "instances" }))[0]).toBe("java.lang.String"); + }); + + it("does not mutate its input", () => { + const copy = [...rows]; + visibleRows(rows, { ...q, sort: "name", desc: false }); + expect(rows).toEqual(copy); + }); +}); + +describe("columns", () => { + it("keeps debugger tables without sizes", () => { + expect(columnsOf("histogram")).toEqual(["name", "instances", "deltaInstances", "bytes"]); + expect(columnsOf("debugger")).toEqual(["name", "instances", "deltaInstances"]); + }); + + it("falls back to the count when the sort column is gone", () => { + expect(queryFor({ ...q, sort: "bytes" }, "debugger").sort).toBe("instances"); + expect(queryFor({ ...q, sort: "bytes" }, "histogram").sort).toBe("bytes"); + }); +}); + +describe("formatting", () => { + it("formats bytes", () => { + expect(formatBytes(0)).toBe("0 B"); + expect(formatBytes(512)).toBe("512 B"); + expect(formatBytes(1536)).toBe("1.5 KB"); + expect(formatBytes(16_000_000)).toBe("15.3 MB"); + }); + + it("rolls to the next unit instead of rounding up to 1024", () => { + expect(formatBytes(1023)).toBe("1023 B"); + expect(formatBytes(1024)).toBe("1.0 KB"); + expect(formatBytes(1_048_575)).toBe("1.0 MB"); + }); + + it("formats deltas", () => { + expect(formatDelta(null)).toBe(""); + expect(formatDelta(0)).toBe("0"); + expect(formatDelta(3)).toBe("+3"); + expect(formatDelta(-2)).toBe("−2"); + expect(formatDelta(4312)).toBe(`+${(4312).toLocaleString()}`); + expect(formatDelta(-1500)).toBe(`−${(1500).toLocaleString()}`); + }); +}); diff --git a/src/features/debugger/__tests__/memoryState.test.ts b/src/features/debugger/__tests__/memoryState.test.ts new file mode 100644 index 000000000..08e356b3c --- /dev/null +++ b/src/features/debugger/__tests__/memoryState.test.ts @@ -0,0 +1,140 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const ipc = vi.hoisted(() => ({ debugJvmMemoryClasses: vi.fn() })); + +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { ...real, isTauri: () => true, commands: { ...real.commands, ...ipc } }; +}); + +import type { DebugSessionId, JvmMemoryResult } from "@/platform/ipc"; +import { useDebug, type DebugSession } from "../state"; +import { forgetMemory, isOutdated, refreshMemory, setMemoryQuery, useMemory } from "../memory/memoryState"; + +const ID = 7 as DebugSessionId; +const ready = (totalBytes: number): JvmMemoryResult => ({ + status: "ready", + snapshot: { + rows: [], + totalInstances: 0, + totalBytes, + javaVersion: "21", + takenWhileSuspended: true, + hasBaseline: false, + elapsedMs: 5, + source: "histogram", + }, +}); +const ok = (data: T) => Promise.resolve({ status: "ok" as const, data }); +const fail = (message: string) => Promise.resolve({ status: "error" as const, error: { kind: "internal", message } }); + +const setStops = (stops: number) => useDebug.setState({ sessions: { [ID]: { id: ID, stops } as DebugSession } }); + +beforeEach(() => { + useMemory.setState({ bySession: {} }); + useDebug.setState({ sessions: {}, stopSeq: 0 }); + ipc.debugJvmMemoryClasses.mockReset(); +}); + +describe("refreshMemory", () => { + it("stores the result and the stop it was taken at", async () => { + setStops(4); + ipc.debugJvmMemoryClasses.mockReturnValue(ok(ready(10))); + await refreshMemory(ID); + const e = useMemory.getState().bySession[ID]!; + expect(ipc.debugJvmMemoryClasses).toHaveBeenCalledWith(ID); + expect(e.result).toEqual(ready(10)); + expect(e.takenAtStop).toBe(4); + expect(e.loading).toBe(false); + expect(e.error).toBeNull(); + }); + + it("keeps the old snapshot and records the error on failure", async () => { + ipc.debugJvmMemoryClasses.mockReturnValueOnce(ok(ready(10))); + await refreshMemory(ID); + ipc.debugJvmMemoryClasses.mockReturnValueOnce(fail("boom")); + await refreshMemory(ID); + const e = useMemory.getState().bySession[ID]!; + expect(e.result).toEqual(ready(10)); + expect(e.error).toBe("boom"); + expect(e.loading).toBe(false); + }); + + it("keeps the last snapshot and its stop when a refresh is refused", async () => { + setStops(1); + ipc.debugJvmMemoryClasses.mockReturnValueOnce(ok(ready(10))); + await refreshMemory(ID); + setStops(2); + const refused: JvmMemoryResult = { + status: "unavailable", + reason: { kind: "timedOut" }, + canCountWhenPaused: false, + }; + ipc.debugJvmMemoryClasses.mockReturnValueOnce(ok(refused)); + await refreshMemory(ID); + const e = useMemory.getState().bySession[ID]!; + expect(e.result).toEqual(refused); + expect(e.snapshot).toEqual((ready(10) as Extract).snapshot); + // The table still shows the snapshot from stop 1, so it reads as outdated. + expect(isOutdated(e, 2)).toBe(true); + }); + + it("ignores a second refresh while one is loading", async () => { + let release!: (r: unknown) => void; + ipc.debugJvmMemoryClasses.mockReturnValueOnce(new Promise((r) => (release = r))); + const first = refreshMemory(ID); + await refreshMemory(ID); + expect(ipc.debugJvmMemoryClasses).toHaveBeenCalledTimes(1); + expect(useMemory.getState().bySession[ID]!.loading).toBe(true); + release({ status: "ok", data: ready(1) }); + await first; + expect(useMemory.getState().bySession[ID]!.loading).toBe(false); + }); + + it("drops a result that lands after the session was forgotten", async () => { + let release!: (r: unknown) => void; + ipc.debugJvmMemoryClasses.mockReturnValueOnce(new Promise((r) => (release = r))); + const pending = refreshMemory(ID); + forgetMemory(ID); + release({ status: "ok", data: ready(1) }); + await pending; + expect(useMemory.getState().bySession[ID]).toBeUndefined(); + }); +}); + +describe("query and staleness", () => { + it("merges query patches and keeps them across refreshes", async () => { + setMemoryQuery(ID, { filter: "map", desc: false }); + ipc.debugJvmMemoryClasses.mockReturnValue(ok(ready(1))); + await refreshMemory(ID); + expect(useMemory.getState().bySession[ID]!.query).toEqual({ + filter: "map", + sort: "bytes", + desc: false, + changedOnly: false, + }); + }); + + it("is outdated once another stop happened", async () => { + setStops(2); + ipc.debugJvmMemoryClasses.mockReturnValue(ok(ready(1))); + await refreshMemory(ID); + const e = useMemory.getState().bySession[ID]!; + expect(isOutdated(e, 2)).toBe(false); + expect(isOutdated(e, 3)).toBe(true); + }); + + it("takes the stop count from its own session, not the global counter", async () => { + setStops(2); + useDebug.setState({ stopSeq: 50 }); + ipc.debugJvmMemoryClasses.mockReturnValue(ok(ready(1))); + await refreshMemory(ID); + expect(useMemory.getState().bySession[ID]!.takenAtStop).toBe(2); + }); + + it("forgets a session", async () => { + setMemoryQuery(ID, { filter: "x" }); + forgetMemory(ID); + expect(useMemory.getState().bySession[ID]).toBeUndefined(); + }); +}); diff --git a/src/features/debugger/__tests__/memoryValues.test.ts b/src/features/debugger/__tests__/memoryValues.test.ts new file mode 100644 index 000000000..b322c8e8f --- /dev/null +++ b/src/features/debugger/__tests__/memoryValues.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { formatJvmValue, objectRef, refKey } from "../memory/values"; + +describe("formatJvmValue", () => { + it("formats values without calling toString", () => { + expect(formatJvmValue({ kind: "object", id: 12, typeName: "com.x.Order" })).toBe("Order@12"); + expect(formatJvmValue({ kind: "array", id: 3, typeName: "java.lang.String[][]", length: 2 })).toBe("String[][2]"); + expect(formatJvmValue({ kind: "string", id: 4, text: 'a"b', length: 3 })).toBe('"a\\"b"'); + expect(formatJvmValue({ kind: "string", id: 5, text: null, length: 50000000 })).toBe("String (50,000,000 chars)"); + expect(formatJvmValue({ kind: "boxed", id: 6, typeName: "java.lang.Integer", text: "42" })).toBe("42"); + }); + + it("shows null and primitives as their text", () => { + expect(formatJvmValue({ kind: "null" })).toBe("null"); + expect(formatJvmValue({ kind: "primitive", typeName: "int", text: "7" })).toBe("7"); + }); + + it("keeps the nested-class marker and names a default-package class as is", () => { + expect(formatJvmValue({ kind: "object", id: 1, typeName: "com.x.Outer$Inner" })).toBe("Outer$Inner@1"); + expect(formatJvmValue({ kind: "object", id: 2, typeName: "Main" })).toBe("Main@2"); + }); + + it("writes an array's length after its element type", () => { + expect(formatJvmValue({ kind: "array", id: 3, typeName: "int[]", length: 3 })).toBe("int[3]"); + expect(formatJvmValue({ kind: "array", id: 3, typeName: "int[]", length: 0 })).toBe("int[0]"); + }); +}); + +describe("objectRef", () => { + it("pairs the generation with the id of a value that has one", () => { + expect(objectRef(2, { kind: "object", id: 7, typeName: "a.B" })).toEqual({ generation: 2, id: 7 }); + expect(objectRef(2, { kind: "string", id: 8, text: "x", length: 1 })).toEqual({ generation: 2, id: 8 }); + expect(objectRef(2, { kind: "boxed", id: 9, typeName: "java.lang.Long", text: "1" })).toEqual({ + generation: 2, + id: 9, + }); + expect(objectRef(2, { kind: "array", id: 10, typeName: "int[]", length: 1 })).toEqual({ generation: 2, id: 10 }); + }); + + it("has no reference for null and primitives", () => { + expect(objectRef(2, { kind: "null" })).toBeNull(); + expect(objectRef(2, { kind: "primitive", typeName: "int", text: "1" })).toBeNull(); + }); +}); + +describe("refKey", () => { + it("joins generation and id", () => { + expect(refKey({ generation: 2, id: 7 })).toBe("2:7"); + }); +}); diff --git a/src/features/debugger/__tests__/memoryView.test.tsx b/src/features/debugger/__tests__/memoryView.test.tsx new file mode 100644 index 000000000..569e4a0af --- /dev/null +++ b/src/features/debugger/__tests__/memoryView.test.tsx @@ -0,0 +1,620 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +vi.mock("@tauri-apps/api/core", () => ({ + Channel: class { + onmessage: (p: unknown) => void = () => {}; + }, +})); + +const ipc = vi.hoisted(() => ({ + debugJvmMemoryClasses: vi.fn(), + debugJvmMemoryInstances: vi.fn(), + cancelRequest: vi.fn(), +})); + +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { ...real, isTauri: () => true, commands: { ...real.commands, ...ipc } }; +}); + +import type { + DebugCapabilities, + JvmClassRow, + JvmInstancesResult, + JvmMemoryResult, + JvmMemoryUnavailable, +} from "@/platform/ipc"; +import { useDebug, type DebugSession } from "../state"; +import { registerActions } from "@/platform/actions"; +import { clearContext, setContext } from "@/platform/context"; +import { setLocale } from "@/platform/i18n"; +import { refreshMemory, useMemory } from "../memory/memoryState"; +import { showInstances, useInstances } from "../memory/instancesState"; +import { MemoryView } from "../memory/MemoryView"; + +const session = { + id: 1, + name: "main", + adapter: "java-debug", + parent: null, + launch: null, + process: null, + status: "paused", + stoppedThread: 1, + stopReason: "breakpoint", + stopDescription: null, + threads: [], + selectedThread: 1, + frames: [], + selectedFrame: null, + hits: 1, + stops: 0, + exitCode: null, + caps: null, +} as DebugSession; + +const row = (name: string, p: Partial = {}): JvmClassRow => ({ + name, + module: null, + instances: 1, + bytes: 16, + deltaInstances: null, + deltaBytes: null, + ambiguous: false, + ...p, +}); + +const ready = (rows: JvmClassRow[]): JvmMemoryResult => ({ + status: "ready", + snapshot: { + rows, + totalInstances: rows.reduce((n, r) => n + r.instances, 0), + totalBytes: rows.reduce((n, r) => n + r.bytes, 0), + javaVersion: "21", + takenWhileSuspended: true, + hasBaseline: true, + elapsedMs: 5, + source: "histogram", + }, +}); + +const unavailable = (reason: JvmMemoryUnavailable): JvmMemoryResult => ({ + status: "unavailable", + reason, + canCountWhenPaused: false, +}); +const ok = (data: T) => Promise.resolve({ status: "ok" as const, data }); +const fail = (message: string) => Promise.resolve({ status: "error" as const, error: { kind: "internal", message } }); + +const HEAP = ready([ + row("Heap$Live", { module: "java.base", instances: 1_000_000, bytes: 16_000_000, deltaInstances: 3 }), + row("Cache$Entry", { instances: 40, bytes: 2_000, deltaInstances: 0 }), + row("Dup", { instances: 2, bytes: 64, ambiguous: true }), +]); + +const withInstances = { ...session, caps: { jvmInstances: true } as DebugCapabilities } as DebugSession; + +const debuggerCounts = (rows: JvmClassRow[]): JvmMemoryResult => { + const r = ready(rows) as Extract; + return { ...r, snapshot: { ...r.snapshot, source: "debugger", totalBytes: 0 } }; +}; + +const NO_INSTANCES: JvmInstancesResult = { status: "ready", generation: 1, instances: [], truncated: false }; + +let unregister: () => void; + +beforeEach(() => { + useMemory.setState({ bySession: {} }); + useInstances.setState({ bySession: {} }); + useDebug.setState({ sessions: { 1: session }, active: 1, stopSeq: 0 }); + for (const m of Object.values(ipc)) m.mockReset(); + // The real actions live in actions.ts (covered by actions.test.ts, which pins their `when`); the + // buttons only need their ids and `when` clauses to resolve. + unregister = registerActions([ + { + id: "debug.memory.refresh", + title: "Refresh Memory View", + when: "debugHasJvmMemory", + run: () => void refreshMemory(1), + }, + { + id: "debug.memory.showInstances", + title: "Show Instances", + run: ({ arg }) => void showInstances(1, arg as string), + }, + { id: "debug.memory.jumpToSource", title: "Jump to Source", when: "projectOpen", run: () => {} }, + ]); + setContext("debugHasJvmMemory", true); + setContext("projectOpen", true); +}); + +afterEach(() => { + unregister(); + clearContext("debugHasJvmMemory"); + clearContext("projectOpen"); + cleanup(); +}); + +describe("MemoryView", () => { + it("disables Refresh once the session has ended", async () => { + ipc.debugJvmMemoryClasses.mockReturnValue(ok(HEAP)); + const ended = { ...session, status: "terminated" } as DebugSession; + render(); + await screen.findByText("Heap$Live"); + const refresh = screen.getByRole("button", { name: "Refresh Memory View" }); + expect(refresh.hasAttribute("disabled")).toBe(false); + act(() => setContext("debugHasJvmMemory", false)); + expect(refresh.hasAttribute("disabled")).toBe(true); + }); + + it("loads a snapshot when the tab first opens", async () => { + ipc.debugJvmMemoryClasses.mockReturnValue(ok(HEAP)); + const { unmount } = render(); + await screen.findByText("Heap$Live"); + expect(ipc.debugJvmMemoryClasses).toHaveBeenCalledTimes(1); + unmount(); + render(); + expect(screen.getByText("Heap$Live")).toBeTruthy(); + expect(ipc.debugJvmMemoryClasses).toHaveBeenCalledTimes(1); + }); + + it("renders rows with formatted size and delta", async () => { + ipc.debugJvmMemoryClasses.mockReturnValue(ok(HEAP)); + render(); + await screen.findByText("Heap$Live"); + expect(screen.getByText("java.base")).toBeTruthy(); + expect(screen.getByText((1_000_000).toLocaleString())).toBeTruthy(); + expect(screen.getByText("15.3 MB")).toBeTruthy(); + expect(screen.getByText("+3")).toBeTruthy(); + expect(screen.getByText(`3 classes · ${(1_000_042).toLocaleString()} objects · 15.3 MB`)).toBeTruthy(); + expect(screen.getByText("Counts include objects that are unreachable but not yet collected.")).toBeTruthy(); + }); + + it("annotates classes that several class loaders define", async () => { + ipc.debugJvmMemoryClasses.mockReturnValue(ok(HEAP)); + render(); + await screen.findByText("Dup"); + expect(screen.getAllByRole("img", { name: "Loaded by more than one class loader" })).toHaveLength(1); + }); + + it("explains why memory is unavailable", async () => { + ipc.debugJvmMemoryClasses.mockReturnValue(ok(unavailable({ kind: "unsafeWhileSuspended", javaMajor: 11 }))); + render(); + expect( + await screen.findByText("JDK 11 cannot report memory while suspended. Resume the program, then refresh."), + ).toBeTruthy(); + }); + + it.each<[JvmMemoryUnavailable, string]>([ + [ + { kind: "attachedJvmNotFound" }, + "Memory view needs a JVM on this computer started with -XX:+StartAttachListener. Programs the IDE debugs get it automatically.", + ], + [{ kind: "listenModeNotSupported" }, "Memory view is not available when the IDE listens for the JVM to connect."], + [{ kind: "unsupportedPlatform" }, "Memory view is not available on this platform yet."], + [{ kind: "noJvmProcess" }, "Could not find the program's JVM process."], + [ + { kind: "listenerNotRunning" }, + "The JVM does not accept attach requests. Restart the debug session without -XX:+DisableAttachMechanism.", + ], + [ + { kind: "unsafeWhileSuspended", javaMajor: 0 }, + "This JDK cannot report memory while suspended. Resume the program, then refresh.", + ], + [{ kind: "programExited" }, "The program has exited."], + [{ kind: "timedOut" }, "The JVM did not answer within 10 seconds."], + ])("words the %j reason", async (reason, text) => { + ipc.debugJvmMemoryClasses.mockReturnValue(ok(unavailable(reason))); + render(); + expect(await screen.findByText(text)).toBeTruthy(); + }); + + it("translates the toolbar and the attach hint in zh", async () => { + await setLocale("zh"); + try { + ipc.debugJvmMemoryClasses.mockReturnValue(ok(unavailable({ kind: "attachedJvmNotFound" }))); + render(); + expect( + await screen.findByText( + "内存视图需要本机上以 -XX:+StartAttachListener 启动的 JVM。由 IDE 调试的程序会自动添加该选项。", + ), + ).toBeTruthy(); + expect(screen.getByRole("button", { name: "刷新内存视图" }).getAttribute("data-tooltip")).toBe("刷新内存视图"); + const filter = screen.getByRole("textbox", { name: "筛选类" }); + expect(filter.getAttribute("placeholder")).toBe("筛选类"); + expect(screen.getByRole("checkbox", { name: "仅显示有变化的" })).toBeTruthy(); + } finally { + await setLocale("en"); + } + }); + + it("follows a live switch to Chinese in the footer, the instances title and the class rows", async () => { + ipc.debugJvmMemoryClasses.mockReturnValue(ok(HEAP)); + ipc.debugJvmMemoryInstances.mockReturnValue(ok({ ...NO_INSTANCES, truncated: true })); + await showInstances(1, "Heap$Live"); + render(); + await screen.findByText("Instances of Heap$Live"); + try { + await act(() => setLocale("zh")); + expect(screen.getByText(`3 个类 · ${(1_000_042).toLocaleString()} 个对象 · 15.3 MB`)).toBeTruthy(); + expect(screen.getByText("Heap$Live 的实例")).toBeTruthy(); + expect(screen.getByText("仅显示前 0 个实例。")).toBeTruthy(); + expect(screen.getByRole("toolbar", { name: "内存" })).toBeTruthy(); + expect(screen.getByRole("img", { name: "由多个类加载器加载" })).toBeTruthy(); + } finally { + await act(() => setLocale("en")); + } + }); + + it("marks the snapshot outdated after a stop of this session only", async () => { + ipc.debugJvmMemoryClasses.mockReturnValue(ok(HEAP)); + const { rerender } = render(); + await screen.findByText("Heap$Live"); + const banner = "Taken before the last stop. Refresh to update."; + expect(screen.queryByText(banner)).toBeNull(); + // A stop in another session, a value edit or a session switch bump the global counter only. + act(() => useDebug.setState({ stopSeq: 5 })); + expect(screen.queryByText(banner)).toBeNull(); + rerender(); + expect(screen.getByText(banner)).toBeTruthy(); + useDebug.setState({ sessions: { 1: { ...session, stops: 1 } } }); + ipc.debugJvmMemoryClasses.mockReturnValue(ok(HEAP)); + fireEvent.click(screen.getByRole("button", { name: "Refresh Memory View" })); + await waitFor(() => expect(screen.queryByText(banner)).toBeNull()); + }); + + it("does not call a missing snapshot outdated", async () => { + ipc.debugJvmMemoryClasses.mockReturnValue(ok(unavailable({ kind: "programExited" }))); + render(); + await screen.findByText("The program has exited."); + expect(screen.queryByText("Taken before the last stop. Refresh to update.")).toBeNull(); + }); + + it("filters and toggles changed only", async () => { + ipc.debugJvmMemoryClasses.mockReturnValue(ok(HEAP)); + render(); + await screen.findByText("Heap$Live"); + + fireEvent.change(screen.getByRole("textbox", { name: "Filter classes" }), { target: { value: "cache" } }); + expect(screen.queryByText("Heap$Live")).toBeNull(); + expect(screen.getByText("Cache$Entry")).toBeTruthy(); + + fireEvent.change(screen.getByRole("textbox", { name: "Filter classes" }), { target: { value: "" } }); + fireEvent.click(screen.getByRole("checkbox", { name: "Changed only" })); + expect(screen.getByText("Heap$Live")).toBeTruthy(); + expect(screen.queryByText("Cache$Entry")).toBeNull(); + expect(screen.queryByText("Dup")).toBeNull(); + }); + + it("sorts by the clicked column and reports it with aria-sort", async () => { + ipc.debugJvmMemoryClasses.mockReturnValue(ok(HEAP)); + render(); + await screen.findByText("Heap$Live"); + const header = (name: string) => screen.getByRole("columnheader", { name }); + const body = () => screen.getAllByRole("rowgroup")[1]!; + const order = () => + within(body()) + .getAllByRole("row") + .map((o) => o.textContent?.slice(0, 3)); + + expect(header("Shallow Size").getAttribute("aria-sort")).toBe("descending"); + expect(header("Class").getAttribute("aria-sort")).toBe("none"); + expect(order()).toEqual(["Hea", "Cac", "Dup"]); + + fireEvent.click(screen.getByRole("button", { name: "Class" })); + expect(header("Class").getAttribute("aria-sort")).toBe("ascending"); + expect(order()).toEqual(["Cac", "Dup", "Hea"]); + + fireEvent.click(screen.getByRole("button", { name: "Class" })); + expect(header("Class").getAttribute("aria-sort")).toBe("descending"); + }); + + it("exposes the classes as a table whose cells sit under the column headers", async () => { + ipc.debugJvmMemoryClasses.mockReturnValue(ok(HEAP)); + render(); + await screen.findByText("Heap$Live"); + const table = screen.getByRole("grid", { name: "Memory" }); + expect(table.getAttribute("aria-rowcount")).toBe("4"); + const headers = within(table) + .getAllByRole("columnheader") + .map((h) => h.textContent); + expect(headers).toEqual(["Class", "Count", "Diff", "Shallow Size"]); + const [head, body] = within(table).getAllByRole("rowgroup"); + expect(within(head!).getByRole("row").getAttribute("aria-rowindex")).toBe("1"); + const rows = within(body!).getAllByRole("row"); + expect(rows.map((r) => r.getAttribute("aria-rowindex"))).toEqual(["2", "3", "4"]); + const cells = within(rows[0]!) + .getAllByRole("gridcell") + .map((c) => c.textContent); + expect(cells).toHaveLength(headers.length); + expect(cells[0]).toContain("Heap$Live"); + expect(cells[3]).toBe("15.3 MB"); + }); + + it("tells a screen reader which class row the arrow keys select", async () => { + ipc.debugJvmMemoryClasses.mockReturnValue(ok(HEAP)); + render(); + await screen.findByText("Heap$Live"); + const [, body] = within(screen.getByRole("grid", { name: "Memory" })).getAllByRole("rowgroup"); + fireEvent.keyDown(body!, { key: "ArrowDown" }); + const selected = within(body!) + .getAllByRole("row") + .filter((r) => r.getAttribute("aria-selected") === "true"); + expect(selected).toHaveLength(1); + expect(body).toHaveAttribute("aria-activedescendant", selected[0]!.id); + }); + + it("keeps the old snapshot under the reason when a refresh is refused", async () => { + ipc.debugJvmMemoryClasses.mockReturnValueOnce(ok(HEAP)); + render(); + await screen.findByText("Heap$Live"); + ipc.debugJvmMemoryClasses.mockReturnValueOnce(ok(unavailable({ kind: "unsafeWhileSuspended", javaMajor: 11 }))); + fireEvent.click(screen.getByRole("button", { name: "Refresh Memory View" })); + const reason = "JDK 11 cannot report memory while suspended. Resume the program, then refresh."; + expect((await screen.findByText(reason)).getAttribute("role")).toBe("status"); + expect(screen.getByText("Heap$Live")).toBeTruthy(); + }); + + it("shows the reason alone when there is no earlier snapshot", async () => { + ipc.debugJvmMemoryClasses.mockReturnValue(ok(unavailable({ kind: "timedOut" }))); + render(); + expect((await screen.findByText("The JVM did not answer within 10 seconds.")).getAttribute("role")).toBeNull(); + expect(screen.queryByRole("columnheader")).toBeNull(); + }); + + it("keeps the old snapshot and shows the error when a refresh fails", async () => { + ipc.debugJvmMemoryClasses.mockReturnValueOnce(ok(HEAP)); + render(); + await screen.findByText("Heap$Live"); + ipc.debugJvmMemoryClasses.mockReturnValueOnce(fail("boom")); + fireEvent.click(screen.getByRole("button", { name: "Refresh Memory View" })); + expect((await screen.findByRole("alert")).textContent).toBe("boom"); + expect(screen.getByText("Heap$Live")).toBeTruthy(); + }); + + describe("instances", () => { + const ORDER_ROWS = [row("com.x.Order", { instances: 3, bytes: 72 }), row("com.x.Line", { bytes: 8 })]; + const ORDERS = ready(ORDER_ROWS); + const rowOf = (name: string) => screen.getByText(name).closest("[role=row]")!; + const body = () => screen.getAllByRole("rowgroup")[1]!; + + it("opens the instances of the selected class with Enter", async () => { + ipc.debugJvmMemoryClasses.mockReturnValue(ok(ORDERS)); + ipc.debugJvmMemoryInstances.mockReturnValue(ok(NO_INSTANCES)); + render(); + await screen.findByText("com.x.Order"); + fireEvent.mouseDown(rowOf("com.x.Line"), { button: 0 }); + fireEvent.click(rowOf("com.x.Line")); + // A click only selects. + expect(ipc.debugJvmMemoryInstances).not.toHaveBeenCalled(); + expect(useMemory.getState().bySession[1]!.selected).toBe("com.x.Line"); + fireEvent.keyDown(body(), { key: "ArrowUp" }); + expect(useMemory.getState().bySession[1]!.selected).toBe("com.x.Order"); + fireEvent.keyDown(body(), { key: "Enter" }); + await waitFor(() => expect(ipc.debugJvmMemoryInstances).toHaveBeenCalledWith(1, "com.x.Order", 1000)); + expect(await screen.findByText("Instances of com.x.Order")).toBeTruthy(); + }); + + it("Enter on a row's Show Instances button opens that row's class, not the selected one", async () => { + const user = userEvent.setup(); + ipc.debugJvmMemoryClasses.mockReturnValue(ok(ORDERS)); + ipc.debugJvmMemoryInstances.mockReturnValue(ok(NO_INSTANCES)); + render(); + await screen.findByText("com.x.Order"); + fireEvent.mouseDown(rowOf("com.x.Order"), { button: 0 }); + expect(useMemory.getState().bySession[1]!.selected).toBe("com.x.Order"); + within(rowOf("com.x.Line")).getByRole("button", { name: "Show Instances" }).focus(); + await user.keyboard("{Enter}"); + await waitFor(() => expect(ipc.debugJvmMemoryInstances).toHaveBeenCalledWith(1, "com.x.Line", 1000)); + expect(ipc.debugJvmMemoryInstances).toHaveBeenCalledTimes(1); + }); + + it("opens the instances of a class with a double-click or its row button", async () => { + ipc.debugJvmMemoryClasses.mockReturnValue(ok(ORDERS)); + ipc.debugJvmMemoryInstances.mockReturnValue(ok(NO_INSTANCES)); + render(); + await screen.findByText("com.x.Order"); + fireEvent.doubleClick(screen.getByText("com.x.Line")); + await waitFor(() => expect(ipc.debugJvmMemoryInstances).toHaveBeenLastCalledWith(1, "com.x.Line", 1000)); + fireEvent.click(within(rowOf("com.x.Order")).getByRole("button", { name: "Show Instances" })); + await waitFor(() => expect(ipc.debugJvmMemoryInstances).toHaveBeenLastCalledWith(1, "com.x.Order", 1000)); + }); + + it("has no Show Instances button without jvmInstances", async () => { + ipc.debugJvmMemoryClasses.mockReturnValue(ok(ORDERS)); + render(); + await screen.findByText("com.x.Order"); + expect(screen.queryByRole("button", { name: "Show Instances" })).toBeNull(); + fireEvent.mouseDown(rowOf("com.x.Order"), { button: 0 }); + fireEvent.keyDown(body(), { key: "Enter" }); + fireEvent.doubleClick(screen.getByText("com.x.Order")); + expect(ipc.debugJvmMemoryInstances).not.toHaveBeenCalled(); + }); + + it("hides Shallow Size for debugger counts", async () => { + ipc.debugJvmMemoryClasses.mockReturnValue(ok(debuggerCounts(ORDER_ROWS))); + render(); + await screen.findByText("com.x.Order"); + const headers = screen.getAllByRole("columnheader").map((h) => h.textContent); + expect(headers).toEqual(["Class", "Count", "Diff"]); + expect(within(rowOf("com.x.Order")).getAllByRole("gridcell")).toHaveLength(3); + // Without sizes the table sorts by count. + expect(screen.getByRole("columnheader", { name: "Count" }).getAttribute("aria-sort")).toBe("descending"); + expect(screen.getByText("Counted by the debugger: reachable objects only, no sizes.")).toBeTruthy(); + expect(screen.queryByText("Counts include objects that are unreachable but not yet collected.")).toBeNull(); + expect(screen.getByText("2 classes · 4 objects")).toBeTruthy(); + }); + + it("offers counting when paused", async () => { + ipc.debugJvmMemoryClasses.mockReturnValue( + ok({ status: "unavailable", reason: { kind: "listenerNotRunning" }, canCountWhenPaused: true }), + ); + render(); + expect( + await screen.findByText( + "The JVM does not accept attach requests. Restart the debug session without -XX:+DisableAttachMechanism.", + ), + ).toBeTruthy(); + expect(screen.getByText("Pause the program to count objects through the debugger.")).toBeTruthy(); + }); + + describe("when the first answer predates the debugger", () => { + const NO_LISTENER = + "Memory view needs a JVM on this computer started with -XX:+StartAttachListener. Programs the IDE debugs get it automatically."; + const PAUSE_HINT = "Pause the program to count objects through the debugger."; + const OUTDATED = "Taken before the last stop. Refresh to update."; + const promised = (canCountWhenPaused: boolean): JvmMemoryResult => ({ + status: "unavailable", + reason: { kind: "attachedJvmNotFound" }, + canCountWhenPaused, + }); + // `refreshMemory` reads the stop count from the store, which the tool window's session prop comes from. + const show = (s: DebugSession) => { + useDebug.setState({ sessions: { 1: s } }); + return ; + }; + const running = { ...session, status: "running" } as DebugSession; + + it("reloads once the session can list instances", async () => { + ipc.debugJvmMemoryClasses.mockReturnValue(ok(promised(false))); + const { rerender } = render(show(running)); + await screen.findByText(NO_LISTENER); + expect(screen.queryByText(PAUSE_HINT)).toBeNull(); + ipc.debugJvmMemoryClasses.mockReturnValue(ok(promised(true))); + rerender(show({ ...running, caps: { jvmInstances: true } as DebugCapabilities })); + expect(await screen.findByText(PAUSE_HINT)).toBeTruthy(); + expect(ipc.debugJvmMemoryClasses).toHaveBeenCalledTimes(2); + // A later capability event asks nothing more. + rerender(show({ ...running, caps: { jvmInstances: true, jvmMemory: true } as DebugCapabilities })); + await act(async () => {}); + expect(ipc.debugJvmMemoryClasses).toHaveBeenCalledTimes(2); + }); + + it("reloads when the session could list instances before the first answer came back", async () => { + let answer!: (r: Awaited>>) => void; + ipc.debugJvmMemoryClasses.mockReturnValueOnce(new Promise((r) => (answer = r))); + const { rerender } = render(show(running)); + await waitFor(() => expect(ipc.debugJvmMemoryClasses).toHaveBeenCalledTimes(1)); + rerender(show({ ...running, caps: { jvmInstances: true } as DebugCapabilities })); + ipc.debugJvmMemoryClasses.mockReturnValue(ok(promised(true))); + await act(async () => answer({ status: "ok", data: promised(false) })); + expect(await screen.findByText(PAUSE_HINT)).toBeTruthy(); + expect(ipc.debugJvmMemoryClasses).toHaveBeenCalledTimes(2); + }); + + it("does not count again on later stops after a failed count", async () => { + // Paused with the inspector, the count timed out: only Refresh tries again. + ipc.debugJvmMemoryClasses.mockReturnValue(ok(promised(false))); + const { rerender } = render(show({ ...withInstances, stops: 1 })); + await screen.findByText(NO_LISTENER); + rerender(show({ ...withInstances, status: "running", stops: 1 })); + rerender(show({ ...withInstances, stops: 2 })); + await act(async () => {}); + expect(ipc.debugJvmMemoryClasses).toHaveBeenCalledTimes(1); + }); + + it("counts once the program stops after the pause hint", async () => { + const live = { ...withInstances, status: "running" } as DebugSession; + ipc.debugJvmMemoryClasses.mockReturnValue(ok(promised(true))); + const { rerender } = render(show(live)); + await screen.findByText(PAUSE_HINT); + ipc.debugJvmMemoryClasses.mockReturnValue(ok(debuggerCounts(ORDER_ROWS))); + rerender(show({ ...live, status: "paused", stops: 1 })); + await screen.findByText("com.x.Order"); + expect(ipc.debugJvmMemoryClasses).toHaveBeenCalledTimes(2); + expect(screen.getByText("Counted by the debugger: reachable objects only, no sizes.")).toBeTruthy(); + expect(screen.queryByText(PAUSE_HINT)).toBeNull(); + expect(screen.queryByText(OUTDATED)).toBeNull(); + // The next stop only marks the table outdated (§8.4). + rerender(show({ ...live, stops: 1 })); + rerender(show({ ...live, status: "paused", stops: 2 })); + await act(async () => {}); + expect(ipc.debugJvmMemoryClasses).toHaveBeenCalledTimes(2); + expect(screen.getByText(OUTDATED)).toBeTruthy(); + }); + + it("does not count again on a stop once a table is shown", async () => { + ipc.debugJvmMemoryClasses.mockReturnValue(ok(HEAP)); + const { rerender } = render(show({ ...withInstances, status: "running" } as DebugSession)); + await screen.findByText("Heap$Live"); + rerender(show({ ...withInstances, status: "paused", stops: 1 })); + await act(async () => {}); + expect(ipc.debugJvmMemoryClasses).toHaveBeenCalledTimes(1); + expect(screen.getByText(OUTDATED)).toBeTruthy(); + }); + + it("does not ask again on a stop without a promise to count", async () => { + ipc.debugJvmMemoryClasses.mockReturnValue(ok(promised(false))); + const { rerender } = render(show(running)); + await screen.findByText(NO_LISTENER); + rerender(show({ ...running, status: "paused", stops: 1 })); + await act(async () => {}); + expect(ipc.debugJvmMemoryClasses).toHaveBeenCalledTimes(1); + }); + + describe("when asking again fails", () => { + // Long enough for a request loop to show. + const settle = () => act(() => new Promise((r) => setTimeout(r, 50))); + + it("asks once when the session can list instances", async () => { + ipc.debugJvmMemoryClasses.mockReturnValueOnce(ok(promised(false))); + const { rerender } = render(show(running)); + await screen.findByText(NO_LISTENER); + ipc.debugJvmMemoryClasses.mockReturnValue(fail("session gone")); + rerender(show({ ...running, caps: { jvmInstances: true } as DebugCapabilities })); + expect((await screen.findByRole("alert")).textContent).toBe("session gone"); + await settle(); + expect(ipc.debugJvmMemoryClasses).toHaveBeenCalledTimes(2); + }); + + it("asks once per stop after the pause hint", async () => { + const live = { ...withInstances, status: "running" } as DebugSession; + ipc.debugJvmMemoryClasses.mockReturnValueOnce(ok(promised(true))); + const { rerender } = render(show(live)); + await screen.findByText(PAUSE_HINT); + ipc.debugJvmMemoryClasses.mockReturnValue(fail("session gone")); + rerender(show({ ...live, status: "paused", stops: 1 })); + await screen.findByRole("alert"); + await settle(); + expect(ipc.debugJvmMemoryClasses).toHaveBeenCalledTimes(2); + rerender(show({ ...live, stops: 1 })); + rerender(show({ ...live, status: "paused", stops: 2 })); + await settle(); + expect(ipc.debugJvmMemoryClasses).toHaveBeenCalledTimes(3); + }); + }); + }); + + it("says the debugger is counting while paused", async () => { + ipc.debugJvmMemoryClasses.mockReturnValue(new Promise(() => {})); + const counting = "Counting objects through the debugger. This can take several seconds on a large heap."; + const { unmount } = render(); + expect(await screen.findByText(counting)).toBeTruthy(); + unmount(); + render(); + expect(screen.queryByText(counting)).toBeNull(); + }); + + it.each([ + [{ kind: "unsupported", detail: "no VM field" }, "The debugger cannot list instances: no VM field"], + [{ kind: "degraded", detail: "id in range" }, "The debugger link stopped reading the JVM. Debugging continues."], + [{ kind: "closed" }, "The connection to the JVM is closed."], + ] as const)("explains the %j instance backend and keeps the table", async (jvmBackend, text) => { + ipc.debugJvmMemoryClasses.mockReturnValue(ok(ORDERS)); + render(); + expect(await screen.findByText(text)).toBeTruthy(); + expect(screen.getByText("com.x.Order")).toBeTruthy(); + }); + + it("shows the instances of the open class under the table", async () => { + ipc.debugJvmMemoryClasses.mockReturnValue(ok(ORDERS)); + ipc.debugJvmMemoryInstances.mockReturnValue(ok(NO_INSTANCES)); + await showInstances(1, "com.x.Order"); + render(); + expect(await screen.findByText("Instances of com.x.Order")).toBeTruthy(); + expect(screen.getByRole("grid", { name: "Memory" })).toBeTruthy(); + }); + }); +}); diff --git a/src/features/debugger/__tests__/toolWindow.test.tsx b/src/features/debugger/__tests__/toolWindow.test.tsx index cb851bc16..4c09df598 100644 --- a/src/features/debugger/__tests__/toolWindow.test.tsx +++ b/src/features/debugger/__tests__/toolWindow.test.tsx @@ -15,6 +15,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => { ...real, commands: { ...real.commands, + debugJvmMemoryClasses: () => ok({ status: "unavailable", reason: { kind: "programExited" } }), debugEvaluateIn, debugScopes: () => ok([{ name: "Locals", variablesReference: 1, expensive: false }]), debugVariables: () => @@ -37,6 +38,7 @@ const confirmTerminate = vi.hoisted(() => vi.fn(async () => false)); vi.mock("@/workbench/confirmTerminate", () => ({ confirmTerminate })); import type { RunConfig } from "@/platform/ipc"; +import { setLocale } from "@/platform/i18n"; import { useDebug, type DebugSession } from "../state"; import { DebugTabs, DebugToolWindow } from "../ui/DebugToolWindow"; import { DebuggerOverlays } from "../ui/Overlays"; @@ -58,6 +60,7 @@ const session: DebugSession = { frames: [{ id: 7, name: "main", path: "/p/app/main.py", line: 11, column: 0, presentationHint: null }], selectedFrame: 7, hits: 1, + stops: 0, exitCode: null, caps: null, }; @@ -94,6 +97,62 @@ describe("debug tool window", () => { expect(hot()).toEqual([true, true]); }); + it("shows the Memory tab only for sessions with jvmMemory", async () => { + const withCaps = (jvmMemory: boolean) => ({ ...session, caps: { jvmMemory } as DebugSession["caps"] }); + useDebug.setState({ sessions: { 1: withCaps(false) }, active: 1 }); + const { unmount } = render(); + expect(screen.queryByRole("tab", { name: "Memory" })).toBeNull(); + unmount(); + useDebug.setState({ sessions: { 1: withCaps(true) }, active: 1 }); + render(); + const memory = screen.getByRole("tab", { name: "Memory" }); + fireEvent.click(memory); + expect(memory.getAttribute("aria-selected")).toBe("true"); + expect(await screen.findByText("The program has exited.")).toBeTruthy(); + // Switching to a session without the capability leaves the tab it no longer has. + act(() => useDebug.setState({ sessions: { 1: withCaps(false) } })); + expect(screen.queryByText("The program has exited.")).toBeNull(); + expect(screen.getByRole("tab", { name: "Threads & Variables" }).getAttribute("aria-selected")).toBe("true"); + }); + + it("shows the Memory tab for a java-debug attach session", () => { + const attach = { + ...session, + adapter: "java-debug", + launch: { request: "attach" } as DebugSession["launch"], + caps: { jvmMemory: true } as DebugSession["caps"], + }; + useDebug.setState({ sessions: { 1: attach }, active: 1 }); + render(); + expect(screen.getByRole("tab", { name: "Memory" })).toBeTruthy(); + }); + + it("keeps the Memory tab selected when the session pauses", async () => { + const caps = { jvmMemory: true } as DebugSession["caps"]; + useDebug.setState({ sessions: { 1: { ...session, caps, status: "running" } }, active: 1 }); + render(); + const memory = screen.getByRole("tab", { name: "Memory" }); + fireEvent.click(memory); + expect(await screen.findByText("The program has exited.")).toBeTruthy(); + act(() => useDebug.setState({ sessions: { 1: { ...session, caps, status: "paused" } } })); + expect(memory.getAttribute("aria-selected")).toBe("true"); + expect(screen.getByText("The program has exited.")).toBeTruthy(); + }); + + it("renames the view tabs when the locale switches to Chinese", async () => { + const caps = { jvmMemory: true } as DebugSession["caps"]; + useDebug.setState({ sessions: { 1: { ...session, caps } }, active: 1 }); + render(); + expect(screen.getByRole("tab", { name: "Memory" })).toBeTruthy(); + try { + await act(() => setLocale("zh")); + const tabs = screen.getAllByRole("tab").map((t) => t.textContent); + expect(tabs).toEqual(["线程和变量", "控制台", "内存"]); + } finally { + await act(() => setLocale("en")); + } + }); + it("says a PHP Remote Debug session listens for Xdebug while nothing is suspended", () => { const runConfig = { id: "php-remote:web", diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index ec7768b39..da4ed5190 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -30,6 +30,9 @@ import { fieldAt, methodAt } from "./breakpoints/symbols"; import { toggleBreakpointSmart } from "./breakpoints/toggle"; import { isJavaAttachTarget, isJavaDebuggable, javaAttachConfig, javaLaunchConfig } from "./java"; import { cancelHotSwapBuild, reloadChangedClasses } from "./hotswap"; +import { refreshMemory, useMemory } from "./memory/memoryState"; +import { closeInstances, instancesOutdated, showInstances, showReferrers, useInstances } from "./memory/instancesState"; +import { jumpToClass } from "./memory/jumpToClass"; import { debugUnsupportedReason, launchConfigFor, withEnvFiles } from "./launch"; import { isPythonAttachTarget, @@ -500,6 +503,80 @@ export function registerDebugActions(): () => void { : undefined, ), }, + { + id: "debug.memory.refresh", + title: "Refresh Memory View", + category: "Debug", + icon: "refresh", + keywords: ["memory", "heap", "instances", "classes"], + when: "debugHasJvmMemory", + run: () => { + const s = activeSession(); + if (s && sessionSupports("jvmMemory", s)) void refreshMemory(s.id); + }, + }, + { + // Runs for the class given as `arg`, else the selected row of the Memory view. + id: "debug.memory.showInstances", + title: "Show Instances", + category: "Debug", + icon: "list", + keywords: ["memory", "heap", "objects", "instances"], + when: "debugSessionPaused", + run: ({ arg }) => { + const s = activeSession(); + if (!s || !sessionSupports("jvmInstances", s)) return; + const name = typeof arg === "string" ? arg : useMemory.getState().bySession[s.id]?.selected; + if (name) void showInstances(s.id, name); + }, + }, + { + // Runs for the class given as `arg`, else the open instances list, else the selected Memory row. + // Needs only the project's language server, so it also works once the session has ended. + id: "debug.memory.jumpToSource", + title: "Jump to Source", + category: "Debug", + icon: "edit-source", + keywords: ["memory", "class", "instances"], + when: "projectOpen", + run: ({ arg }) => { + const s = activeSession(); + const name = + typeof arg === "string" + ? arg + : s && + (useInstances.getState().bySession[s.id]?.className ?? useMemory.getState().bySession[s.id]?.selected); + if (name) void jumpToClass(name); + }, + }, + { + // Runs for the lead object row of the open instances list. + id: "debug.memory.showReferrers", + title: "Show Referrers", + category: "Debug", + icon: "hierarchy", + keywords: ["memory", "instances", "references", "referring objects"], + when: "debugSessionPaused", + run: () => { + const s = activeSession(); + const entry = s && useInstances.getState().bySession[s.id]; + if (!s || !entry?.lead || !sessionSupports("jvmInstances", s) || instancesOutdated(entry, s.stops)) return; + showReferrers(s.id, entry.lead.key, entry.lead.ref); + }, + }, + { + // Closes the pane of the session given as `arg`, else of the active one. No `when`: the pane stays + // after its session ends and must still close. + id: "debug.memory.closeInstances", + title: "Close Instances", + category: "Debug", + icon: "close", + keywords: ["memory", "instances"], + run: ({ arg }) => { + const id = typeof arg === "number" ? arg : activeSession()?.id; + if (id != null && useInstances.getState().bySession[id]) closeInstances(id); + }, + }, { id: "debug.cancelHotSwap", title: "Cancel Hot Swap Build", diff --git a/src/features/debugger/index.ts b/src/features/debugger/index.ts index a6e39bc29..4312ed510 100644 --- a/src/features/debugger/index.ts +++ b/src/features/debugger/index.ts @@ -37,6 +37,8 @@ import { } from "./state"; import { useOverlays } from "./ui/popoverStore"; import { REMOTE_TYPES } from "./remote"; +import { forgetInstances } from "./memory/instancesState"; +import { forgetMemory } from "./memory/memoryState"; /** Whether a breakpoint popover, debugger dialog or chooser is open. */ const useAnyOverlay = () => @@ -107,6 +109,14 @@ export function activate(): () => void { if (e.event.kind === "hotCodeReplace") onHotCodeReplace(e.session, e.event); else handleDebugEvent(e.session, e.event); }), + useDebug.subscribe((s, prev) => { + if (s.sessions === prev.sessions) return; + for (const id of Object.keys(prev.sessions)) { + if (id in s.sessions) continue; + forgetMemory(Number(id)); + forgetInstances(Number(id)); + } + }), useProject.subscribe((s, prev) => { const root = s.project?.root ?? null; if (root !== (prev.project?.root ?? null) || s.project?.trusted !== prev.project?.trusted) { diff --git a/src/features/debugger/memory/InstancesPane.module.css b/src/features/debugger/memory/InstancesPane.module.css new file mode 100644 index 000000000..f8e8ad528 --- /dev/null +++ b/src/features/debugger/memory/InstancesPane.module.css @@ -0,0 +1,119 @@ +.pane { + flex: 0 1 auto; + display: flex; + flex-direction: column; + /* A short Debug tool window keeps room for the class table above: the pane shrinks down to its header and a row. */ + height: min(calc(var(--h-row) + 10 * var(--h-list-row)), 60%); + min-height: calc(var(--h-row) + var(--h-list-row)); + border-top: 1px solid var(--border); +} + +.header { + flex: none; + display: flex; + align-items: center; + gap: 4px; + height: var(--h-row); + padding: 0 4px 0 12px; + border-bottom: 1px solid var(--border); +} + +.title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--fg-strong); + font-size: var(--fs-ui); + font-family: var(--font-mono); +} + +.note, +.banner, +.error { + flex: none; + display: flex; + align-items: center; + gap: 8px; + padding: 4px 12px; + font-size: var(--fs-ui); +} +.note { + color: var(--fg-subtle); +} +.banner { + color: var(--fg); + background: var(--bg-feedback-warning); + border-bottom: 1px solid var(--border-feedback-warning); +} +.error { + color: var(--fg); + background: var(--bg-feedback-error); + border-bottom: 1px solid var(--border-feedback-error); + -webkit-user-select: text; + user-select: text; +} + +.retry { + flex: none; +} + +.tree { + flex: 1; + min-height: 0; +} + +.node, +.message { + display: flex; + align-items: center; + min-width: 0; + font-size: var(--fs-ui); +} + +.name { + color: var(--fg-strong); +} + +.equals { + white-space: pre; + color: var(--fg-subtle); +} + +.value { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--fg); + font-family: var(--font-mono); +} + +.group { + color: var(--fg-muted); +} + +.message { + gap: 8px; + color: var(--fg-subtle); +} + +.rowButton { + flex: none; + margin-left: 8px; + opacity: 0; +} +/* Outranks `.iconButton:disabled`, which would show it on every row of an outdated list. */ +.row .rowButton:disabled { + opacity: 0; +} +.row:hover .rowButton, +.row[aria-selected="true"] .rowButton, +.rowButton:focus-visible { + opacity: 1; +} +.row:hover .rowButton:disabled, +.row[aria-selected="true"] .rowButton:disabled { + opacity: 0.4; +} diff --git a/src/features/debugger/memory/InstancesPane.tsx b/src/features/debugger/memory/InstancesPane.tsx new file mode 100644 index 000000000..193fb1791 --- /dev/null +++ b/src/features/debugger/memory/InstancesPane.tsx @@ -0,0 +1,256 @@ +/** Instances of one class under the Memory table: each opens into its fields and the objects that refer to it. */ +import { useCallback, useEffect, useMemo } from "react"; +import { Button, IconButton, Spinner, T, Tree } from "@/ui"; +import { useTx } from "@/platform/i18n"; +import type { DebugSessionId, JvmValue } from "@/platform/ipc"; +import type { DebugSession } from "../state"; +import { useActionEnabled } from "../ui/useActionEnabled"; +import { + ensureFields, + instancesOutdated, + loadFields, + loadReferrers, + setInstancesExpanded, + setInstancesLead, + showInstances, + showReferrers, + useInstances, + type NodeEntry, +} from "./instancesState"; +import { + childrenOf, + inspectUnavailableText, + instanceNodes, + labelOf, + outdatedText, + type PaneNode, + type Retry, +} from "./instancesTree"; +import { keepKeysOnButton } from "./rowButtonKeys"; +import { formatCount } from "./rows"; +import styles from "./InstancesPane.module.css"; + +export function InstancesPane({ session }: { session: DebugSession }) { + const tx = useTx(); + const id = session.id; + const entry = useInstances((s) => s.bySession[id]); + const canJump = useActionEnabled("debug.memory.jumpToSource"); + if (!entry) return null; + const name = entry.className; + const title = tx`Instances of ${name}`; + const result = entry.result; + const outdated = instancesOutdated(entry, session.stops); + return ( +
+
+ {title} + + +
+ {entry.loading && ( +
+ Loading… +
+ )} + {entry.error && ( +
+ {entry.error} +
+ )} + {result?.status === "unavailable" && ( +
+ {result.reason.kind === "notSuspended" && session.status === "paused" + ? // kotlin-lsp suspends the JVM for Pause without a stop event the instance reader can use. + tx( + "This debugger lists instances only when the program stops at a breakpoint or after a step, not after Pause.", + ) + : inspectUnavailableText(result.reason, tx)} + {result.reason.kind === "timedOut" && ( + + )} +
+ )} + {result?.status === "ready" && ( + <> + {outdated && ( +
+ {outdatedText(tx)} + +
+ )} + {result.truncated && } + + + )} +
+ ); +} + +function Truncated({ count }: { count: number }) { + const tx = useTx(); + const n = formatCount(count); + return
{tx`Showing the first ${n} instances.`}
; +} + +function InstanceTree({ + id, + label, + instances, + generation, + nodes, + expandedKeys, + outdated, + paused, +}: { + id: DebugSessionId; + label: string; + instances: JvmValue[]; + generation: number; + nodes: Record; + expandedKeys: string[]; + outdated: boolean; + /** Referrers are read only while the program is paused, like the Show Referrers action. */ + paused: boolean; +}) { + const tx = useTx(); + const expanded = useMemo(() => new Set(expandedKeys), [expandedKeys]); + const roots = useMemo(() => instanceNodes(generation, instances), [generation, instances]); + // The lead row only means something while this tree shows it. + useEffect(() => () => setInstancesLead(id, null), [id]); + const getChildren = useCallback((n: PaneNode) => childrenOf(n, nodes, tx), [nodes, tx]); + const getLabel = useCallback((n: PaneNode) => labelOf(n, tx), [tx]); + // Objects of an earlier stop cannot be read any more; what was already loaded stays open. + const isExpandable = useCallback( + (n: PaneNode) => + n.kind === "referrers" || (n.kind === "value" && !!n.ref && (!outdated || childrenOf(n, nodes) !== undefined)), + [nodes, outdated], + ); + + const onReferrers = (n: PaneNode) => { + if (n.kind === "value" && n.ref) showReferrers(id, n.key, n.ref); + }; + + const retry = ({ what, ref }: Retry) => void (what === "fields" ? loadFields(id, ref) : loadReferrers(id, ref)); + + return ( + + className={styles.tree} + aria-label={label} + roots={roots} + getKey={(n) => n.key} + getLabel={getLabel} + getChildren={getChildren} + isExpandable={isExpandable} + expanded={expanded} + onExpandedChange={(next, n, open) => { + setInstancesExpanded(id, next); + if (open && n.kind === "value" && n.ref) ensureFields(id, n.ref); + }} + onSelectionChange={(_selected, lead) => + setInstancesLead(id, lead?.kind === "value" && lead.ref ? { key: lead.key, ref: lead.ref } : null) + } + rowClassName={() => styles.row} + renderNode={(n) => ( + + )} + empty={ + + Nothing to show + + } + /> + ); +} + +function NodeRow({ + node, + referrersDisabled, + onReferrers, + onRetry, +}: { + node: PaneNode; + referrersDisabled: boolean; + onReferrers: (n: PaneNode) => void; + onRetry: (r: Retry) => void; +}) { + switch (node.kind) { + case "value": + return ( + + {node.name != null && ( + <> + {node.name} + = + + )} + {labelOf(node)} + {node.ref && ( + onReferrers(node)} + /> + )} + + ); + case "referrers": + return ( + + Referrers + + ); + case "message": + return ( + + {node.text} + {node.retry && ( + + )} + + ); + } +} diff --git a/src/features/debugger/memory/MemoryView.module.css b/src/features/debugger/memory/MemoryView.module.css new file mode 100644 index 000000000..cc5918000 --- /dev/null +++ b/src/features/debugger/memory/MemoryView.module.css @@ -0,0 +1,216 @@ +.root { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; + /* Shorter than the class table and the instances pane at their smallest: scroll rather than overlap. */ + overflow-y: auto; +} + +.toolbar { + flex: none; + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + border-bottom: 1px solid var(--border); +} + +.filter { + flex: 0 1 240px; + min-width: 80px; +} + +.banner, +.error { + flex: none; + padding: 4px 12px; + font-size: var(--fs-ui); + color: var(--fg); + border-bottom: 1px solid var(--border); +} +.banner { + background: var(--bg-feedback-warning); + border-bottom-color: var(--border-feedback-warning); +} +.error { + background: var(--bg-feedback-error); + border-bottom-color: var(--border-feedback-error); + -webkit-user-select: text; + user-select: text; +} + +.note { + padding: 8px 12px; + color: var(--fg-subtle); + font-size: var(--fs-ui); +} + +.table { + flex: 1; + /* Its header, three rows and the footer; the instances pane below gives way first. */ + min-height: calc(2 * var(--h-row) + 3 * 22px); + /* Squeezed by the instances pane, it must not paint over it. */ + overflow: hidden; + display: flex; + flex-direction: column; + --memory-columns: minmax(0, 1fr) 96px 72px 104px; +} +.noSizes { + --memory-columns: minmax(0, 1fr) 96px 72px; +} + +.head, +.row { + display: grid; + grid-template-columns: var(--memory-columns); + align-items: center; + column-gap: 12px; + width: 100%; +} + +.head { + flex: none; + height: var(--h-row); + padding: 0 16px 0 14px; + border-bottom: 1px solid var(--border); + color: var(--fg-muted); + font-size: var(--fs-small); +} + +.headCell { + display: flex; + min-width: 0; +} +.headCell button { + flex: 1; + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + height: 100%; + padding: 0; + color: inherit; + font: inherit; + white-space: nowrap; + text-align: left; +} +.headCell button:hover { + color: var(--fg-strong); +} +.headCell button:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: -2px; +} +.numeric button { + justify-content: flex-end; + text-align: right; +} +.sorted button { + color: var(--fg-strong); +} + +.grid { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.headGroup { + flex: none; +} + +.list { + flex: 1; + min-height: 0; +} + +.row { + font-size: var(--fs-ui); + color: var(--fg); +} + +.cell { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.className { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; + font-family: var(--font-mono); +} + +.classText { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--fg-strong); +} + +.module { + flex: 1 100 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--fg-subtle); + font-size: var(--fs-small); +} + +.ambiguous { + flex: none; + align-self: center; + color: var(--warning); +} + +.rowButton { + flex: none; + align-self: center; + margin-left: auto; + opacity: 0; +} +/* Outranks `.iconButton:disabled`, which would show it on every row while the program runs. */ +.row .rowButton:disabled { + opacity: 0; +} +.row:hover .rowButton, +:global([data-lead]) > .row .rowButton, +.rowButton:focus-visible { + opacity: 1; +} +.row:hover .rowButton:disabled, +:global([data-lead]) > .row .rowButton:disabled { + opacity: 0.4; +} + +.num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.zero { + color: var(--fg-subtle); +} + +.footer { + flex: none; + display: flex; + flex-wrap: wrap; + align-items: baseline; + column-gap: 12px; + padding: 4px 12px; + border-top: 1px solid var(--border); + color: var(--fg-muted); + font-size: var(--fs-small); +} + +.source { + color: var(--fg-subtle); +} diff --git a/src/features/debugger/memory/MemoryView.tsx b/src/features/debugger/memory/MemoryView.tsx new file mode 100644 index 000000000..20ef5dad2 --- /dev/null +++ b/src/features/debugger/memory/MemoryView.tsx @@ -0,0 +1,249 @@ +/** Memory tab of the Debug tool window: live instance counts per class of the debugged JVM. */ +import { useEffect, useMemo } from "react"; +import clsx from "clsx"; +import { Checkbox, Icon, IconButton, Input, List, T } from "@/ui"; +import { executeAction } from "@/platform/actions"; +import { useTx } from "@/platform/i18n"; +import type { JvmClassRow, JvmMemorySnapshot } from "@/platform/ipc"; +import { sessionSupports, type DebugSession } from "../state"; +import { useActionEnabled } from "../ui/useActionEnabled"; +import { + DEFAULT_QUERY, + isOutdated, + isOvertaken, + refreshMemory, + selectMemoryClass, + setMemoryQuery, + useMemory, +} from "./memoryState"; +import { InstancesPane } from "./InstancesPane"; +import { backendText } from "./instancesTree"; +import { keepKeysOnButton } from "./rowButtonKeys"; +import { columnsOf, formatBytes, formatCount, formatDelta, queryFor, visibleRows, type SortKey } from "./rows"; +import { unavailableText } from "./unavailableText"; +import styles from "./MemoryView.module.css"; + +const COLUMNS: ReadonlyArray<{ key: SortKey; label: string; numeric: boolean }> = [ + { key: "name", label: "Class", numeric: false }, + { key: "instances", label: "Count", numeric: true }, + { key: "deltaInstances", label: "Diff", numeric: true }, + { key: "bytes", label: "Shallow Size", numeric: true }, +]; + +const showInstancesOf = (name: string) => void executeAction("debug.memory.showInstances", name); + +/** `listsInstances`: the session can list the class's instances, which it reads only while paused. */ +function ClassRow({ + row, + sizes, + listsInstances, + paused, +}: { + row: JvmClassRow; + sizes: boolean; + listsInstances: boolean; + paused: boolean; +}) { + const tx = useTx(); + const delta = formatDelta(row.deltaInstances); + const opens = listsInstances && paused; + return ( +
showInstancesOf(row.name) : undefined}> + + {row.name} + {row.ambiguous && ( + + + + )} + {row.module && {row.module}} + {listsInstances && ( + + )} + + + {formatCount(row.instances)} + + + {delta} + + {sizes && ( + + {formatBytes(row.bytes)} + + )} +
+ ); +} + +export function MemoryView({ session }: { session: DebugSession }) { + // Also re-renders the reason text, which reads the catalog when called. + const tx = useTx(); + const id = session.id; + const entry = useMemory((s) => s.bySession[id]); + const query = entry?.query ?? DEFAULT_QUERY; + const result = entry?.result ?? null; + const snapshot = entry?.snapshot ?? null; + const source = snapshot?.source ?? "histogram"; + const sorted = useMemo(() => queryFor(query, source), [query, source]); + const listsInstances = sessionSupports("jvmInstances", session); + const paused = session.status === "paused"; + const canRefresh = useActionEnabled("debug.memory.refresh"); + const backend = backendText(session.jvmBackend, tx); + + // Only the first visit loads: later stops mark the table outdated instead of re-running a heap walk. + useEffect(() => { + const e = useMemory.getState().bySession[id]; + if (!e?.result && !e?.loading) void refreshMemory(id); + }, [id]); + const overtaken = entry ? isOvertaken(entry, session) : false; + useEffect(() => { + if (overtaken) void refreshMemory(id); + }, [id, overtaken]); + + const rows = useMemo(() => (snapshot ? visibleRows(snapshot.rows, sorted) : []), [snapshot, sorted]); + const columns = COLUMNS.filter((c) => columnsOf(source).includes(c.key)); + const selected = entry?.selected ?? null; + + const sortBy = (key: SortKey) => + setMemoryQuery(id, sorted.sort === key ? { sort: key, desc: !sorted.desc } : { sort: key, desc: key !== "name" }); + + return ( +
+
+ + setMemoryQuery(id, { filter: e.target.value })} + placeholder="Filter classes" + aria-label="Filter classes" + /> + setMemoryQuery(id, { changedOnly })} + label="Changed only" + /> +
+ {backend && ( +
+ {backend} +
+ )} + {entry?.loading && listsInstances && paused && ( +
+ Counting objects through the debugger. This can take several seconds on a large heap. +
+ )} + {entry?.error && ( +
+ {entry.error} +
+ )} + {snapshot && entry && isOutdated(entry, session.stops) && ( +
+ Taken before the last stop. Refresh to update. +
+ )} + {result?.status === "unavailable" && ( +
+ {unavailableText(result.reason)} + {result.canCountWhenPaused && ( + <> + {" "} + + Pause the program to count objects through the debugger. + + + )} +
+ )} + {snapshot && ( +
+
+
+
+ {columns.map((c) => ( +
+ +
+ ))} +
+
+ `${i}:${r.name}`} + renderItem={(r) => ( + + )} + activeIndex={selected == null ? -1 : rows.findIndex((r) => r.name === selected)} + onActiveChange={(i) => selectMemoryClass(id, rows[i]?.name ?? null)} + // A click only selects; Enter opens (double-click is handled by the row). + onActivate={(r, _i, e) => { + if (e.type === "keydown" && listsInstances && paused) showInstancesOf(r.name); + }} + rowHeight={22} + tableBody={{ headerRows: 1 }} + empty={{tx("No matches")}} + /> +
+ +
+ )} + +
+ ); +} + +function FooterTotals({ snapshot }: { snapshot: JvmMemorySnapshot }) { + const tx = useTx(); + const n = formatCount(snapshot.rows.length); + const m = formatCount(snapshot.totalInstances); + const size = formatBytes(snapshot.totalBytes); + const counted = snapshot.source === "debugger"; + return ( +
+ {counted ? tx`${n} classes · ${m} objects` : tx`${n} classes · ${m} objects · ${size}`} + + {counted ? ( + Counted by the debugger: reachable objects only, no sizes. + ) : ( + Counts include objects that are unreachable but not yet collected. + )} + +
+ ); +} diff --git a/src/features/debugger/memory/instancesState.ts b/src/features/debugger/memory/instancesState.ts new file mode 100644 index 000000000..0e80151ba --- /dev/null +++ b/src/features/debugger/memory/instancesState.ts @@ -0,0 +1,185 @@ +import { create } from "zustand"; +import { + call, + commands, + type DebugSessionId, + type JvmFieldsResult, + type JvmInstancesResult, + type JvmObjectRef, + type JvmReferrersResult, +} from "@/platform/ipc"; +import { useDebug } from "../state"; +import { refKey } from "./values"; + +const INSTANCE_LIMIT = 1000; +const REFERRER_LIMIT = 50; + +export interface NodeEntry { + fields: JvmFieldsResult | null; + referrers: JvmReferrersResult | null; + loadingFields: boolean; + loadingReferrers: boolean; + error: string | null; +} + +export interface InstancesEntry { + className: string; + /** The latest answer, which may say why the instances could not be listed (`timedOut` is worth a retry). */ + result: JvmInstancesResult | null; + loading: boolean; + error: string | null; + /** The session's `stops` when the list was requested; compare with the current value to tell the objects are from an earlier stop. */ + takenAtStop: number; + /** Opened objects by `refKey`. */ + nodes: Record; + /** Tree keys of the open rows; kept here so actions can open rows too. */ + expanded: string[]; + /** The tree's lead row when it is an object; the Show Referrers action runs for it. */ + lead: InstancesLead | null; +} + +export interface InstancesLead { + key: string; + ref: JvmObjectRef; +} + +/** Tree key of the "Referrers" group under the row `key`. */ +export const referrersKey = (key: string) => `${key}/referrers`; + +const emptyNode = (): NodeEntry => ({ + fields: null, + referrers: null, + loadingFields: false, + loadingReferrers: false, + error: null, +}); + +export const useInstances = create<{ bySession: Record }>(() => ({ bySession: {} })); + +/** + * The list each session is showing. A reply (of the list or of one of its objects) counts only + * while its token is still current: closing, forgetting or replacing the list drops the token. + */ +const current = new Map(); + +function patchEntry(id: DebugSessionId, token: symbol, patch: (e: InstancesEntry) => Partial) { + if (current.get(id) !== token) return; + useInstances.setState((s) => { + const e = s.bySession[id]; + return e ? { bySession: { ...s.bySession, [id]: { ...e, ...patch(e) } } } : s; + }); +} + +function patchNode(id: DebugSessionId, token: symbol, ref: JvmObjectRef, patch: Partial) { + const key = refKey(ref); + patchEntry(id, token, (e) => ({ nodes: { ...e.nodes, [key]: { ...(e.nodes[key] ?? emptyNode()), ...patch } } })); +} + +const messageOf = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +export async function showInstances(id: DebugSessionId, className: string): Promise { + const open = useInstances.getState().bySession[id]; + const stop = useDebug.getState().sessions[id]?.stops ?? 0; + // A request still loading from an earlier stop would only bring outdated objects: replace it. + if (open?.loading && open.className === className && open.takenAtStop === stop) return; + const token = Symbol("instances"); + current.set(id, token); + useInstances.setState((s) => ({ + bySession: { + ...s.bySession, + [id]: { + className, + result: null, + loading: true, + error: null, + takenAtStop: stop, + nodes: {}, + expanded: [], + lead: null, + }, + }, + })); + try { + const result = await call(commands.debugJvmMemoryInstances(id, className, INSTANCE_LIMIT)); + patchEntry(id, token, () => ({ result })); + } catch (e) { + patchEntry(id, token, () => ({ error: messageOf(e) })); + } finally { + patchEntry(id, token, () => ({ loading: false })); + } +} + +export function closeInstances(id: DebugSessionId): void { + forgetInstances(id); +} + +export function forgetInstances(id: DebugSessionId): void { + current.delete(id); + useInstances.setState((s) => { + if (!(id in s.bySession)) return s; + const { [id]: _gone, ...rest } = s.bySession; + return { bySession: rest }; + }); +} + +export function loadFields(id: DebugSessionId, ref: JvmObjectRef): Promise { + return loadNode(id, ref, "loadingFields", async () => { + const fields = await call(commands.debugJvmMemoryFields(id, ref)); + return { fields }; + }); +} + +export function loadReferrers(id: DebugSessionId, ref: JvmObjectRef): Promise { + return loadNode(id, ref, "loadingReferrers", async () => { + const referrers = await call(commands.debugJvmMemoryReferrers(id, ref, REFERRER_LIMIT)); + return { referrers }; + }); +} + +async function loadNode( + id: DebugSessionId, + ref: JvmObjectRef, + flag: "loadingFields" | "loadingReferrers", + fetch: () => Promise>, +): Promise { + const token = current.get(id); + const entry = useInstances.getState().bySession[id]; + if (!token || !entry || entry.nodes[refKey(ref)]?.[flag]) return; + patchNode(id, token, ref, { [flag]: true, error: null }); + try { + patchNode(id, token, ref, await fetch()); + } catch (e) { + patchNode(id, token, ref, { error: messageOf(e) }); + } finally { + patchNode(id, token, ref, { [flag]: false }); + } +} + +/** Loads the object's fields unless they are loaded or loading. */ +export function ensureFields(id: DebugSessionId, ref: JvmObjectRef): void { + const n = useInstances.getState().bySession[id]?.nodes[refKey(ref)]; + if (!n?.fields && !n?.loadingFields) void loadFields(id, ref); +} + +export function setInstancesExpanded(id: DebugSessionId, keys: Iterable): void { + const token = current.get(id); + if (token) patchEntry(id, token, () => ({ expanded: [...keys] })); +} + +export function setInstancesLead(id: DebugSessionId, lead: InstancesLead | null): void { + const token = current.get(id); + if (token) patchEntry(id, token, () => ({ lead })); +} + +/** Opens the row `key` of `ref` with its fields and its "Referrers" group, loading both. */ +export function showReferrers(id: DebugSessionId, key: string, ref: JvmObjectRef): void { + const token = current.get(id); + if (!token) return; + void loadReferrers(id, ref); + ensureFields(id, ref); + patchEntry(id, token, (e) => ({ expanded: [...new Set([...e.expanded, key, referrersKey(key)])] })); +} + +export function instancesOutdated(entry: InstancesEntry, sessionStops: number): boolean { + return entry.takenAtStop !== sessionStops; +} diff --git a/src/features/debugger/memory/instancesTree.ts b/src/features/debugger/memory/instancesTree.ts new file mode 100644 index 000000000..13d05f375 --- /dev/null +++ b/src/features/debugger/memory/instancesTree.ts @@ -0,0 +1,138 @@ +/** Rows of the instances tree, built from what `instancesState` has loaded so far. */ +import type { JvmBackendState, JvmInspectUnavailable, JvmObjectRef, JvmValue } from "@/platform/ipc"; +import { tx as catalogTx } from "@/platform/i18n"; +import { referrersKey, type NodeEntry } from "./instancesState"; +import { formatJvmValue, objectRef, refKey } from "./values"; + +/** A component passes its `useTx()`: message rows keep their text, so they must be rebuilt for a new locale. */ +type Tx = typeof catalogTx; + +export type Retry = { what: "fields" | "referrers"; ref: JvmObjectRef }; + +export type PaneNode = + | { + kind: "value"; + key: string; + value: JvmValue; + /** `null` for values without fields to open (null, primitives). */ + ref: JvmObjectRef | null; + /** Field name, or `null` for an instance or a referrer. */ + name: string | null; + /** Fields through which a referrer points at its parent object. */ + via: string[] | null; + } + | { kind: "referrers"; key: string; ref: JvmObjectRef } + | { kind: "message"; key: string; text: string; retry: Retry | null }; + +export function outdatedText(tx: Tx = catalogTx): string { + return tx("These values are from before the last stop. Show the instances again to update them."); +} + +export function inspectUnavailableText(reason: JvmInspectUnavailable, tx: Tx = catalogTx): string { + switch (reason.kind) { + case "noBackend": + return tx("This debugger cannot list instances."); + case "unsupported": { + const detail = reason.detail; + return tx`The debugger cannot list instances: ${detail}`; + } + case "notSuspended": + return tx("Pause the program to see instances."); + case "stale": + return outdatedText(tx); + case "collected": + return tx("The object has been garbage collected."); + case "classNotLoaded": + return tx("The class is not loaded in the JVM."); + case "vmNotFound": + return tx("Could not tell which JVM this session debugs."); + case "timedOut": + return tx("The JVM did not answer in time."); + case "backendClosed": + return tx("The connection to the JVM is closed."); + } +} + +/** Banner for a session whose instance backend stopped; `null` while it works. */ +export function backendText(state: JvmBackendState | undefined, tx: Tx = catalogTx): string | null { + switch (state?.kind) { + case "unsupported": { + const detail = state.detail; + return tx`The debugger cannot list instances: ${detail}`; + } + case "degraded": + return tx("The debugger link stopped reading the JVM. Debugging continues."); + case "closed": + return tx("The connection to the JVM is closed."); + default: + return null; + } +} + +const valueNode = (key: string, generation: number, value: JvmValue, name: string | null, via: string[] | null) => + ({ kind: "value", key, value, ref: objectRef(generation, value), name, via }) satisfies PaneNode; + +const message = (key: string, text: string, retry: Retry | null = null): PaneNode => ({ + kind: "message", + key, + text, + retry, +}); + +export function instanceNodes(generation: number, instances: readonly JvmValue[]): PaneNode[] { + return instances.map((v, i) => valueNode(`#${i}`, generation, v, null, null)); +} + +export function labelOf(node: PaneNode, tx: Tx = catalogTx): string { + switch (node.kind) { + case "value": { + const text = formatJvmValue(node.value); + return node.via?.length ? `${text} · ${node.via.join(", ")}` : text; + } + case "referrers": + return tx("Referrers"); + case "message": + return node.text; + } +} + +/** Loaded children of `node`, or `undefined` while there is nothing to show yet (the tree shows "Loading…"). */ +export function childrenOf( + node: PaneNode, + nodes: Readonly>, + tx: Tx = catalogTx, +): PaneNode[] | undefined { + if (node.kind === "message" || !node.ref) return undefined; + const ref = node.ref; + const n = nodes[refKey(ref)]; + if (!n) return undefined; + if (node.kind === "referrers") return referrerChildren(node.key, ref, n, tx); + + const out: PaneNode[] = []; + const fields = n.fields; + if (fields?.status === "ready") { + fields.fields.forEach((f, i) => out.push(valueNode(`${node.key}/${i}`, ref.generation, f.value, f.name, null))); + } else if (fields) { + const retry = fields.reason.kind === "timedOut" ? { what: "fields" as const, ref } : null; + out.push(message(`${node.key}/!fields`, inspectUnavailableText(fields.reason, tx), retry)); + } else if (n.loadingFields) { + if (!n.referrers && !n.loadingReferrers) return undefined; + out.push(message(`${node.key}/!loading`, tx("Loading…"))); + } else if (n.error) { + out.push(message(`${node.key}/!error`, n.error, { what: "fields", ref })); + } + if (n.referrers || n.loadingReferrers) out.push({ kind: "referrers", key: referrersKey(node.key), ref }); + return fields || out.length ? out : undefined; +} + +function referrerChildren(key: string, ref: JvmObjectRef, n: NodeEntry, tx: Tx): PaneNode[] | undefined { + const r = n.referrers; + if (!r) + return n.error && !n.loadingReferrers ? [message(`${key}/!error`, n.error, { what: "referrers", ref })] : undefined; + if (r.status === "unavailable") { + const retry = r.reason.kind === "timedOut" ? { what: "referrers" as const, ref } : null; + return [message(`${key}/!reason`, inspectUnavailableText(r.reason, tx), retry)]; + } + if (!r.referrers.length) return [message(`${key}/!none`, tx("No referrers"))]; + return r.referrers.map((x, i) => valueNode(`${key}/${i}`, ref.generation, x.value, null, x.via)); +} diff --git a/src/features/debugger/memory/jumpToClass.ts b/src/features/debugger/memory/jumpToClass.ts new file mode 100644 index 000000000..488cb945d --- /dev/null +++ b/src/features/debugger/memory/jumpToClass.ts @@ -0,0 +1,36 @@ +import { call, commands, nextRequestId, type SymbolKind, type WorkspaceSymbol } from "@/platform/ipc"; +import { tx } from "@/platform/i18n"; +import { maybeEditorService, notifications } from "@/workbench/contributions"; + +const TYPE_KINDS = new Set(["class", "interface", "enum", "struct"]); + +/** Some servers put more before the package (`app.jar com.x`); a bare suffix match would also take `com.ax` or `a.x` for `x`. */ +function inPackage(container: string | null, pkg: string): boolean { + const c = container ?? ""; + if (c === pkg) return true; + return pkg !== "" && c.endsWith(pkg) && /[^\w$.]$/.test(c.slice(0, -pkg.length)); +} + +/** Opens the source of a JVM class (`com.x.Order$Line[]` opens `com.x.Order`); `false` and a notification when none is found. */ +export async function jumpToClass(className: string): Promise { + const outer = className.replace(/(\[\])+$/, "").split("$")[0]!; + const dot = outer.lastIndexOf("."); + const simple = outer.slice(dot + 1); + const pkg = dot < 0 ? "" : outer.slice(0, dot); + let hit: WorkspaceSymbol | undefined; + try { + const symbols = await call(commands.lspWorkspaceSymbols(nextRequestId(), simple)); + hit = symbols.find((s) => s.name === simple && TYPE_KINDS.has(s.kind) && inPackage(s.container, pkg)); + } catch { + hit = undefined; + } + const editor = maybeEditorService(); + if (!hit || !editor) { + const name = className; + notifications().notify({ level: "warning", title: tx`Could not find the source of ${name}.` }); + return false; + } + const { line, col } = hit.location.range.start; + await editor.openFile(hit.location.path, { selection: { line, col } }); + return true; +} diff --git a/src/features/debugger/memory/memoryState.ts b/src/features/debugger/memory/memoryState.ts new file mode 100644 index 000000000..c367739f0 --- /dev/null +++ b/src/features/debugger/memory/memoryState.ts @@ -0,0 +1,105 @@ +import { create } from "zustand"; +import { call, commands, type DebugSessionId, type JvmMemoryResult, type JvmMemorySnapshot } from "@/platform/ipc"; +import { sessionSupports, useDebug, type DebugSession } from "../state"; +import type { RowQuery } from "./rows"; + +export interface MemoryEntry { + /** The latest answer, which may say why no snapshot could be taken. */ + result: JvmMemoryResult | null; + /** The session when the last answered or failed request was sent: its `stops` and whether it could list instances. */ + asked: { stop: number; listsInstances: boolean } | null; + /** The last snapshot taken: it stays on screen when a later refresh is refused or fails. */ + snapshot: JvmMemorySnapshot | null; + loading: boolean; + error: string | null; + /** The session's `stops` when `snapshot` was requested; compare with the current value to tell the table is stale. */ + takenAtStop: number; + query: RowQuery; + /** Name of the class row the user selected; the Show Instances action runs for it. */ + selected: string | null; +} + +export const DEFAULT_QUERY: RowQuery = { filter: "", sort: "bytes", desc: true, changedOnly: false }; + +const emptyEntry = (): MemoryEntry => ({ + result: null, + asked: null, + snapshot: null, + loading: false, + error: null, + takenAtStop: 0, + query: DEFAULT_QUERY, + selected: null, +}); + +export const useMemory = create<{ bySession: Record }>(() => ({ bySession: {} })); + +/** The request each session is waiting on; `forgetMemory` clears it so a late reply cannot resurrect the entry. */ +const inflight = new Map(); + +function patchEntry(id: DebugSessionId, patch: (e: MemoryEntry) => Partial) { + useMemory.setState((s) => { + const e = s.bySession[id] ?? emptyEntry(); + return { bySession: { ...s.bySession, [id]: { ...e, ...patch(e) } } }; + }); +} + +export async function refreshMemory(id: DebugSessionId): Promise { + if (useMemory.getState().bySession[id]?.loading) return; + const token = Symbol("memory"); + inflight.set(id, token); + const session = useDebug.getState().sessions[id] ?? null; + const stop = session?.stops ?? 0; + const asked = { stop, listsInstances: sessionSupports("jvmInstances", session) }; + patchEntry(id, () => ({ loading: true, error: null })); + try { + const result = await call(commands.debugJvmMemoryClasses(id)); + if (inflight.get(id) !== token) return; + patchEntry(id, () => + result.status === "ready" ? { result, asked, snapshot: result.snapshot, takenAtStop: stop } : { result, asked }, + ); + } catch (e) { + if (inflight.get(id) !== token) return; + // A failed request was still asked: `isOvertaken` must not send it again at once. + patchEntry(id, () => ({ asked, error: e instanceof Error ? e.message : String(e) })); + } finally { + if (inflight.get(id) === token) { + inflight.delete(id); + patchEntry(id, () => ({ loading: false })); + } + } +} + +export function setMemoryQuery(id: DebugSessionId, patch: Partial): void { + patchEntry(id, (e) => ({ query: { ...e.query, ...patch } })); +} + +export function selectMemoryClass(id: DebugSessionId, name: string | null): void { + patchEntry(id, () => ({ selected: name })); +} + +export function isOutdated(entry: MemoryEntry, sessionStops: number): boolean { + return entry.takenAtStop !== sessionStops; +} + +/** + * Whether asking again could now give a table where the last answer gave none: the session got + * its JVM inspector after a "no" (it attaches after the session starts), or stopped after the + * "Pause the program to count" hint. A table on screen is never refetched by itself (§8.4). + */ +export function isOvertaken(entry: MemoryEntry, session: DebugSession): boolean { + const { result, asked } = entry; + if (entry.snapshot || entry.loading || !asked || result?.status !== "unavailable") return false; + return result.canCountWhenPaused + ? session.status === "paused" && session.stops !== asked.stop + : !asked.listsInstances && sessionSupports("jvmInstances", session); +} + +export function forgetMemory(id: DebugSessionId): void { + inflight.delete(id); + useMemory.setState((s) => { + if (!(id in s.bySession)) return s; + const { [id]: _gone, ...rest } = s.bySession; + return { bySession: rest }; + }); +} diff --git a/src/features/debugger/memory/rowButtonKeys.ts b/src/features/debugger/memory/rowButtonKeys.ts new file mode 100644 index 000000000..fd90e4eee --- /dev/null +++ b/src/features/debugger/memory/rowButtonKeys.ts @@ -0,0 +1,9 @@ +import type { KeyboardEvent } from "react"; + +/** + * `onKeyDown` of a button inside a List or Tree row. The container acts on its selected row for + * Enter (and cancels the key), so without this Enter on the button would run for another row. + */ +export function keepKeysOnButton(e: KeyboardEvent): void { + if (e.key === "Enter" || e.key === " ") e.stopPropagation(); +} diff --git a/src/features/debugger/memory/rows.ts b/src/features/debugger/memory/rows.ts new file mode 100644 index 000000000..cc1cd4522 --- /dev/null +++ b/src/features/debugger/memory/rows.ts @@ -0,0 +1,66 @@ +import type { JvmClassRow, JvmMemorySource } from "@/platform/ipc"; + +export type SortKey = "name" | "instances" | "deltaInstances" | "bytes"; + +export interface RowQuery { + filter: string; + sort: SortKey; + desc: boolean; + changedOnly: boolean; +} + +/** The debugger counts objects without sizes, so its table has no Shallow Size column. */ +export function columnsOf(source: JvmMemorySource): SortKey[] { + return source === "debugger" + ? ["name", "instances", "deltaInstances"] + : ["name", "instances", "deltaInstances", "bytes"]; +} + +/** `q` sorted by a column the table has: a size sort falls back to the count. */ +export function queryFor(q: RowQuery, source: JvmMemorySource): RowQuery { + return columnsOf(source).includes(q.sort) ? q : { ...q, sort: "instances" }; +} + +/** Filter, then sort a copy of `rows`; rows without a delta always sort last so a baseline-less table stays readable. */ +export function visibleRows(rows: readonly JvmClassRow[], q: RowQuery): JvmClassRow[] { + const needle = q.filter.trim().toLowerCase(); + const kept = rows.filter( + (r) => + (!needle || r.name.toLowerCase().includes(needle)) && + (!q.changedOnly || (r.deltaInstances != null && r.deltaInstances !== 0)), + ); + const dir = q.desc ? -1 : 1; + return kept.sort((a, b) => { + if (q.sort === "name") return dir * a.name.localeCompare(b.name); + if (q.sort === "deltaInstances") { + if (a.deltaInstances == null || b.deltaInstances == null) { + return (a.deltaInstances == null ? 1 : 0) - (b.deltaInstances == null ? 1 : 0); + } + return dir * (a.deltaInstances - b.deltaInstances); + } + return dir * (a[q.sort] - b[q.sort]); + }); +} + +const UNITS = ["B", "KB", "MB", "GB", "TB"]; + +/** Thousands-grouped count; shared by the Count and Diff columns so they read alike. */ +export const formatCount = (n: number): string => n.toLocaleString(); + +export function formatBytes(n: number): string { + let value = n; + let unit = 0; + // Compare the rounded value so 1048575 B becomes "1.0 MB", not "1024.0 KB". + while (Math.abs(Number(value.toFixed(1))) >= 1024 && unit < UNITS.length - 1) { + value /= 1024; + unit++; + } + return unit === 0 ? `${value} B` : `${value.toFixed(1)} ${UNITS[unit]}`; +} + +/** `null` (no baseline) renders empty; negatives use U+2212 so they align with `+`. */ +export function formatDelta(n: number | null): string { + if (n == null) return ""; + if (n > 0) return `+${formatCount(n)}`; + return n < 0 ? `−${formatCount(-n)}` : "0"; +} diff --git a/src/features/debugger/memory/unavailableText.ts b/src/features/debugger/memory/unavailableText.ts new file mode 100644 index 000000000..88d05f4d1 --- /dev/null +++ b/src/features/debugger/memory/unavailableText.ts @@ -0,0 +1,32 @@ +import { tx } from "@/platform/i18n"; +import type { JvmMemoryUnavailable } from "@/platform/ipc"; + +/** Why the class table could not be read. */ +export function unavailableText(reason: JvmMemoryUnavailable): string { + switch (reason.kind) { + case "attachedJvmNotFound": + return tx( + "Memory view needs a JVM on this computer started with -XX:+StartAttachListener. Programs the IDE debugs get it automatically.", + ); + case "listenModeNotSupported": + return tx("Memory view is not available when the IDE listens for the JVM to connect."); + case "unsupportedPlatform": + return tx("Memory view is not available on this platform yet."); + case "noJvmProcess": + return tx("Could not find the program's JVM process."); + case "listenerNotRunning": + return tx( + "The JVM does not accept attach requests. Restart the debug session without -XX:+DisableAttachMechanism.", + ); + case "unsafeWhileSuspended": { + const v = reason.javaMajor; + return v > 0 + ? tx`JDK ${v} cannot report memory while suspended. Resume the program, then refresh.` + : tx("This JDK cannot report memory while suspended. Resume the program, then refresh."); + } + case "programExited": + return tx("The program has exited."); + case "timedOut": + return tx("The JVM did not answer within 10 seconds."); + } +} diff --git a/src/features/debugger/memory/values.ts b/src/features/debugger/memory/values.ts new file mode 100644 index 000000000..89122da96 --- /dev/null +++ b/src/features/debugger/memory/values.ts @@ -0,0 +1,37 @@ +import type { JvmObjectRef, JvmValue } from "@/platform/ipc"; +import { tx } from "@/platform/i18n"; +import { formatCount } from "./rows"; + +const shortName = (typeName: string): string => typeName.slice(typeName.lastIndexOf(".") + 1); + +/** One-line text of a value, built from what the debugger already sent: nothing is invoked in the debuggee. */ +export function formatJvmValue(v: JvmValue): string { + switch (v.kind) { + case "null": + return "null"; + case "primitive": + case "boxed": + return v.text; + case "string": { + if (v.text != null) return JSON.stringify(v.text); + const chars = formatCount(v.length); + return tx`String (${chars} chars)`; + } + case "object": + return `${shortName(v.typeName)}@${v.id}`; + case "array": { + const element = v.typeName.endsWith("[]") ? v.typeName.slice(0, -2) : v.typeName; + return `${shortName(element)}[${v.length}]`; + } + } +} + +/** The reference to open `v` with, or null for values that are not objects. */ +export function objectRef(generation: number, v: JvmValue): JvmObjectRef | null { + return v.kind === "null" || v.kind === "primitive" ? null : { generation, id: v.id }; +} + +/** Key of an object in `InstancesEntry.nodes`. */ +export function refKey(r: JvmObjectRef): string { + return `${r.generation}:${r.id}`; +} diff --git a/src/features/debugger/state.ts b/src/features/debugger/state.ts index b754dcd48..b9edf4ce1 100644 --- a/src/features/debugger/state.ts +++ b/src/features/debugger/state.ts @@ -16,6 +16,7 @@ import { type DebugCapabilities, type DebugEvent, type DebugSessionId, + type JvmBackendState, type LaunchConfig, type RunId, type RunConfig, @@ -68,9 +69,13 @@ export interface DebugSession { selectedFrame: number | null; /** Breakpoint hits in this session. */ hits: number; + /** `stopped` events of this session; unlike the global `stopSeq`, no other session or value edit moves it. */ + stops: number; exitCode: number | null; /** What the adapter supports (fetched after start). */ caps: DebugCapabilities | null; + /** State of the backend that reads a JVM's objects; absent until the backend reports one. */ + jvmBackend?: JvmBackendState; /** A cancelled JDT build may have lost class-file deltas needed by this session. */ hotSwapNeedsRestart?: boolean; } @@ -197,6 +202,7 @@ function blank(id: DebugSessionId, p: Partial = {}): DebugSession frames: [], selectedFrame: null, hits: 0, + stops: 0, exitCode: null, caps: null, ...p, @@ -360,6 +366,8 @@ export function publishContext() { "debugCanHotSwap", Object.values(useDebug.getState().sessions).some((x) => isLive(x) && !!x.caps?.hotCodeReplace), ); + // Refresh Memory View acts on the selected session only. + setContext("debugHasJvmMemory", !!s && isLive(s) && sessionSupports("jvmMemory", s)); } // --- console ----------------------------------------------------------------- @@ -626,7 +634,8 @@ export async function startSession( name: launch.name, runConfig: options.runConfig, startedAt: preparation?.startedAt ?? startedAt, - adapter: launch.adapter, + // The backend may start another adapter than the one asked for; `launch` keeps the request for Rerun. + adapter: cur.adapter || launch.adapter, launch: options.rerunnable === false ? null : launch, process: options.process ?? null, status: cur.status === "starting" ? "running" : cur.status, @@ -1097,6 +1106,7 @@ export function handleDebugEvent(id: DebugSessionId, event: DebugEvent) { frames: [], selectedFrame: null, hits: s.hits + (isBp ? 1 : 0), + stops: s.stops + 1, }); useDebug.setState((st) => ({ active: id, stopSeq: st.stopSeq + 1 })); publishContext(); @@ -1148,6 +1158,12 @@ export function handleDebugEvent(id: DebugSessionId, event: DebugEvent) { case "terminated": markTerminated(id); break; + case "capabilities": + void loadCapabilities(id); + break; + case "jvmBackend": + patch(id, { jvmBackend: event.state }); + break; } } diff --git a/src/features/debugger/ui/DebugToolWindow.tsx b/src/features/debugger/ui/DebugToolWindow.tsx index 6100d888f..67e15c09b 100644 --- a/src/features/debugger/ui/DebugToolWindow.tsx +++ b/src/features/debugger/ui/DebugToolWindow.tsx @@ -2,8 +2,7 @@ import { writeClipboardText } from "@/platform/clipboard"; import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import clsx from "clsx"; -import { executeAction, getAction } from "@/platform/actions"; -import { evaluateWhen, useContextKeys } from "@/platform/context"; +import { executeAction } from "@/platform/actions"; import { basename, useProject } from "@/platform/project"; import { call, commands, type Scope, type Variable } from "@/platform/ipc"; import { @@ -25,6 +24,7 @@ import { import { setMuted, useBreakpoints } from "../breakpoints/store"; import { frameLabel, groupFrames } from "../frames"; import { blurDebugView, focusDebugView } from "./focusedView"; +import { useActionEnabled } from "./useActionEnabled"; import { addWatch, closeSessionTab, @@ -39,19 +39,14 @@ import { type DebugSession, } from "../state"; import { remoteWaitingNote } from "../remote"; +import { MemoryView } from "../memory/MemoryView"; import { DebugConsole } from "./DebugConsole"; import { VariablesView, type VarRoot } from "./VariablesView"; import { useExpressionCompletion } from "./ExpressionCompletion"; import { sortVariables } from "../values"; import { workflowSettings } from "@/platform/workflowSettings"; import styles from "./DebugToolWindow.module.css"; -import { tx } from "@/platform/i18n"; - -function useActionEnabled(id: string): boolean { - const ctx = useContextKeys(); - const a = getAction(id); - return !!a && evaluateWhen(a.when, ctx); -} +import { tx, useTx } from "@/platform/i18n"; function ToolButton({ action, icon, tone }: { action: string; icon: string; tone?: "danger" }) { const enabled = useActionEnabled(action); @@ -104,7 +99,7 @@ function MoreActions() { ); } -type Tab = "variables" | "console"; +type Tab = "variables" | "console" | "memory"; function statusText(s: DebugSession): string { if (s.status === "terminated") return s.exitCode != null ? `Finished · exit code ${s.exitCode}` : "Finished"; @@ -142,6 +137,7 @@ export function DebugTabs() { } export function DebugToolWindow() { + const tx = useTx(); const active = useDebug((s) => (s.active != null ? s.sessions[s.active] : undefined)); const muted = useBreakpoints((s) => s.muted); const [view, setView] = useState<{ @@ -154,15 +150,17 @@ export function DebugToolWindow() { session: active.startedAt, status: active.status, // A breakpoint or exception brings Threads & Variables to the front; finishing a step keeps - // the tab the user is on (e.g. Console, to watch the output while stepping). + // the tab the user is on (e.g. Console, to watch the output while stepping), and so does Memory. tab: active.status === "starting" ? "console" - : active.status === "paused" && active.stopReason !== "step" + : active.status === "paused" && view.tab !== "memory" && active.stopReason !== "step" ? "variables" : view.tab, }); - const tab = view.tab; + const hasMemory = !!active && sessionSupports("jvmMemory", active); + // The selected session may be one without the Memory tab. + const tab: Tab = view.tab === "memory" && !hasMemory ? "variables" : view.tab; if (!active) { return ( @@ -183,6 +181,7 @@ export function DebugToolWindow() { [ ["variables", "Threads & Variables"], ["console", "Console"], + ...(hasMemory ? [["memory", "Memory"] as const] : []), ] as const ).map(([id, label]) => (